import { getPrettierFormatter, toAbsolute, turnIntoExecutable } from '@alexaegis/fs'; import { WorkspacePackage, getPackageJsonTemplateVariables, type PackageJson, } from '@alexaegis/workspace-tools'; import { existsSync } from 'node:fs'; import { mkdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; import posix, { basename, dirname, join, relative } from 'node:path/posix'; import type { InternalModuleFormat } from 'rollup'; import { SimpleObjectKey } from '@alexaegis/common'; import { globby } from 'globby'; import { NormalizedAutolibContext, ViteFileNameFn } from '../../index.js'; import { NPM_INSTALL_HOOKS, PackageJsonExportTarget, PackageJsonKind, PathMap, } from '../../package-json/index.js'; import type { AutolibFeature, PackageExaminationResult } from '../autolib-feature.type.js'; import { PackageExportPathContext } from '../export/auto-export.class.js'; import { createExportMapFromPaths } from '../export/helpers/create-export-map-from-paths.function.js'; import { enterPathPosix } from '../export/helpers/enter-path.function.js'; import { stripFileExtension } from '../export/helpers/strip-file-extension.function.js'; import { AutoBinOptions, NormalizedAutoBinOptions, normalizeAutoBinOptions, } from './auto-bin.class.options.js'; import { normalizePackageName } from './helpers/normalize-package-name.function.js'; /** * TODO: use the one in core */ const mapObject = , K>( o: T, map: (value: T[keyof T], key: keyof T) => K ): Record => { return Object.fromEntries( Object.entries(o).map(([key, value]) => { return [key, map(value as T[keyof T], key)]; }) ) as Record; }; export const allBinPathCombinations = [ `${PackageJsonKind.DEVELOPMENT}-to-${PackageJsonExportTarget.SOURCE}`, `${PackageJsonKind.DEVELOPMENT}-to-${PackageJsonExportTarget.DIST}`, `${PackageJsonKind.DISTRIBUTION}-to-${PackageJsonExportTarget.DIST}`, `${PackageJsonKind.DEVELOPMENT}-to-${PackageJsonExportTarget.SHIM}`, ] as const; export const mapPathMapToFormat = ( binPaths: PathMap, format: InternalModuleFormat | 'SOURCE', fileNameFn: ViteFileNameFn ): BinPathMap => { return mapObject(binPaths, (kindsOfPaths, _binName) => { return mapObject(kindsOfPaths, (path, _pathKind) => { const fileName = basename(path); const extensionlessFileName = stripFileExtension(fileName); const dir = dirname(path); return posix.join( dir, format === 'SOURCE' ? fileName : fileNameFn(format, extensionlessFileName) ); }); }); }; /** * BinPaths never point into the nothing. Dist points to dist but source only * points to a shim generated by AutoBin not to confuse package managers when * an unbuilt package is installed locally. The shim will be there for them. * The shim will not be usable until the package is built though. * * TODO: generate shims that can self trigger builds when called and not built */ export type AllBinPathCombinations = (typeof allBinPathCombinations)[number]; export type BinPathMap = PathMap; const markComment = ' # autogenerated'; /** * Generates bin entries from files under `srcDir` + `autoBinDirectory` * It also treats all files named as npm hooks as npm hooks, prefixing them * and adding them as hooks for the npm artifact * * For example a file called `postinstall.ts` in a package called * `@org/name`, it will generate an npm script entry as such: * `"postinstall": "bin/postinstall.js"`. The hook is still treated as a * `bin` so you can invoke it directly. To avoid name collisions, all * "hookbins" are prefixed with the normalized packagename like so: * `org-name-postinstall` * * For a simpler packageJson, directories.bin could also be used in * https://docs.npmjs.com/cli/v9/configuring-npm/package-json#directories */ export class AutoBin implements AutolibFeature { private readonly options: NormalizedAutoBinOptions; private readonly context: NormalizedAutolibContext; private outDirAbs: string; private shimDirAbs: string; private outBinDirAbs: string; private binPathMap: BinPathMap = {}; private existingManualBinEntries: Record = {}; constructor(context: NormalizedAutolibContext, options?: AutoBinOptions) { this.options = normalizeAutoBinOptions(options); this.context = context; this.outDirAbs = toAbsolute(this.context.outDir, this.context); this.shimDirAbs = join(this.context.cwd, this.options.shimDir); this.outBinDirAbs = join(this.outDirAbs, this.options.binBaseDir); } private collectManualBinEntries(workspacePackage: WorkspacePackage): Record { return Object.fromEntries( Object.entries(workspacePackage.packageJson.bin ?? {}).filter( ([, path]) => !path.startsWith('.' + posix.sep + posix.normalize(this.options.shimDir)) || !path.endsWith('js') || path.includes('manual') ) ); } async examinePackage( workspacePackage: WorkspacePackage ): Promise> { this.existingManualBinEntries = this.collectManualBinEntries(workspacePackage); this.context.logger.trace('existingManualBinEntries', this.existingManualBinEntries); const absoluteBinBaseDir = toAbsolute(join(this.context.srcDir, this.options.binBaseDir), { cwd: workspacePackage.packagePath, }); const binFiles = await globby(this.options.bins, { cwd: absoluteBinBaseDir, ignore: [...this.options.binIgnore, ...this.options.defaultBinIgnore], onlyFiles: true, dot: true, }); this.binPathMap = createExportMapFromPaths(binFiles, { outDir: this.context.outDir, shimDir: this.options.shimDir, srcDir: this.context.srcDir, basePath: this.options.binBaseDir, keyKind: 'extensionless-filename-only', }); for (const binPath of binFiles) { const binName = stripFileExtension(basename(binPath)); this.binPathMap[binName] = { 'development-to-source': join( this.context.srcDir, this.options.binBaseDir, binPath ), 'development-to-dist': join(this.context.outDir, this.options.binBaseDir, binPath), 'distribution-to-dist': join(this.options.binBaseDir, binPath), 'development-to-shim': join(this.options.shimDir, binPath), }; } const packageJsonUpdates: PackageJson = {}; // Making sure removed bins and scripts will be dropped at the end packageJsonUpdates.bin = undefined; for (const script in packageJsonUpdates.scripts) { if (packageJsonUpdates.scripts[script]?.endsWith(markComment)) { packageJsonUpdates.scripts[script] = undefined; } } return { packageJsonUpdates, bundlerEntryFiles: binFiles.reduce>((acc, binFile) => { const path = posix.join(this.context.srcDir, this.options.binBaseDir, binFile); const alias = posix.join(this.options.binBaseDir, stripFileExtension(binFile)); acc[alias] = path; return acc; }, {}), }; } /** * for module based packages, bins are modules too and the adjust path * step only acts for the 'es' format */ async process( packageJson: PackageJson, pathContext: PackageExportPathContext ): Promise { if (this.context.primaryFormat === pathContext.format) { const binPathMapForFormat = mapPathMapToFormat( this.binPathMap, this.context.primaryFormat, this.context.fileName ); const packageName = normalizePackageName(packageJson.name); await this.ensureEsmBinEntriesRenamed(); if (pathContext.packageJsonKind === PackageJsonKind.DEVELOPMENT) { await this.createShims( Object.values(binPathMapForFormat).map( (pathKinds) => pathKinds['development-to-shim'] ), this.context.primaryFormat ); } // Mark all bins and shims as executable await Promise.allSettled( Object.values(binPathMapForFormat) .flatMap((pathKinds) => [ pathKinds['development-to-dist'], pathKinds['development-to-shim'], ]) .filter((executable) => existsSync(executable)) .map((executable) => turnIntoExecutable(executable, { cwd: this.context.cwd, logger: this.context.logger, }) ) ); await this.preLink( mapObject(binPathMapForFormat, (pathKinds) => pathKinds['development-to-dist']), packageName ); const update = Object.entries(binPathMapForFormat).reduce( (result, [key, value]) => { if (result.scripts && this.options.enabledNpmHooks.includes(key)) { if ( !packageJson.scripts?.[key] || packageJson.scripts[key]?.endsWith(markComment) ) { if (pathContext.packageJsonKind === PackageJsonKind.DISTRIBUTION) { result.scripts[key] = value['distribution-to-dist'] + markComment; // before update } else if (NPM_INSTALL_HOOKS.includes(key)) { // Disable local postinstall hooks result.scripts[key] = '# local install hooks are disabled' + markComment; } else { // Otherwise just point to the shim result.scripts[key] = value['development-to-shim'] + markComment; // before update } } // Hooks are renamed to avoid conflicts, except for their scripts key = packageName + '-' + key; } if (key.endsWith('index')) { key = key.replace('index', ''); } if (key === '') { const packageJsonName = getPackageJsonTemplateVariables(packageJson); key = packageJsonName.packageNameWithoutOrg; } if (!result.bin) { result.bin = {}; } // the distributed build artifacts bins point to the built bins // otherwise, the bins are pointing to their shims result.bin[key] = '.' + posix.sep + (pathContext.packageJsonKind === PackageJsonKind.DISTRIBUTION ? value['distribution-to-dist'] : value['development-to-shim']); return result; }, { bin: this.existingManualBinEntries, scripts: {}, } ); if (typeof update.bin === 'object' && Object.keys(update.bin).length === 0) { delete update.bin; } if (typeof update.scripts === 'object' && Object.keys(update.scripts).length === 0) { delete update.scripts; } return [{ bin: undefined }, update]; } else { return undefined; } } /** * Ensures shimDir exists and creates simple javascript files that are * importing their counterpart from `outDir` */ private async createShims(shimPaths: string[], format: InternalModuleFormat): Promise { if ( (this.context.packageType === 'module' && format === 'es') || (this.context.packageType === 'commonjs' && format !== 'es') ) { this.context.logger.info( `Creating shims for bins in ${format}/${this.context.packageType} format` ); // Clean up await rm(this.shimDirAbs, { force: true, recursive: true }); const shimDirToOutBin = relative(this.shimDirAbs, this.outBinDirAbs); const formatJs = await getPrettierFormatter(); // check writable shim files const shimPathsToMake = await Promise.all( shimPaths.map((path) => readFile(toAbsolute(path, this.context), { encoding: 'utf8', }) .then((content) => content.includes('// autogenerated') ? path : undefined ) .catch(() => path) ) ).then((results) => results.filter((result): result is string => result !== undefined)); if (shimPathsToMake.length > 0) { this.context.logger.info(`create shims for ${shimPathsToMake.join('; ')}`); await Promise.allSettled( shimPathsToMake.map(async (path) => { const outBinPath = enterPathPosix(path, 1); //const outBinPath = path; const builtBinFromShims = shimDirToOutBin + posix.sep + outBinPath; const formattedESShimContent = formatJs( `// autogenerated export * from '${builtBinFromShims}';` ); const formattedCJSShimContent = formatJs( `// autogenerated as seen from tsc /* eslint-disable unicorn/prefer-module */ /* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable no-prototype-builtins */ var __createBinding = function(o, m, k, k2) { if (k2 === undefined) k2 = k; Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); }; var __exportStar = function(m, exports) { for (var p in m) if (p !== 'default' && !exports.hasOwnProperty(p)) __createBinding(exports, m, p); }; __exportStar(require('${builtBinFromShims}'), exports);` ); const shimPathAbs = join(this.shimDirAbs, outBinPath); try { await mkdir(dirname(shimPathAbs), { recursive: true }); await writeFile( shimPathAbs, format === 'es' ? formattedESShimContent : formattedCJSShimContent ); } catch (error) { this.context.logger.error( "Couldn't write", shimPathAbs, 'error happened', error ); } }) ); } } } /** * Ensures that all .js files in the dist folder are renamed to the * expected name this plugin added them to the bin entry. */ private async ensureEsmBinEntriesRenamed(): Promise { if (this.context.packageType === 'module') { const esBinPathsMap = mapPathMapToFormat(this.binPathMap, 'es', this.context.fileName); const data = Object.entries(esBinPathsMap).flatMap(([_binName, binPath]) => { const extensionlessPath = stripFileExtension(binPath['development-to-dist']); return [ { binPath: extensionlessPath + '.js', newBinPath: binPath['development-to-dist'], }, { binPath: extensionlessPath + '.js.map', newBinPath: binPath['development-to-dist'] + '.map', }, ]; }); await Promise.all( data .filter(({ binPath }) => existsSync(binPath)) .map(({ binPath, newBinPath }) => rename(binPath, newBinPath).catch(() => false) ) ); } } // TODO: something is funky, there are extensionless files in the distbin dir and they are not executable. /** * */ private async preLink(binRecord: Record, packageName: string): Promise { const workspaceBinDirectoryPath = join( this.context.rootWorkspacePackage.packagePath, 'node_modules', '.bin' ); const packageBinDirectoryPath = toAbsolute(join('node_modules', '.bin'), this.context); const symlinksToMake = Object.entries(binRecord).flatMap(([binName, binPath]) => { if (this.options.enabledNpmHooks.includes(binName)) { binName = packageName + '-' + binName; } return [ join(workspaceBinDirectoryPath, binName), join(packageBinDirectoryPath, binName), ].map((targetFilePath) => { const relativeFromTargetBackToFile = relative(dirname(targetFilePath), binPath); return { relativeFromTargetBackToFile, targetFilePath }; }); }); await Promise.all( symlinksToMake.map(async ({ targetFilePath, relativeFromTargetBackToFile }) => { try { await symlink(relativeFromTargetBackToFile, targetFilePath); this.context.logger.info( `symlinked ${targetFilePath} to ${relativeFromTargetBackToFile}` ); } catch { this.context.logger.info(`${targetFilePath} is already present`); } }) ); } }