import * as fs from 'node:fs'; import * as path from 'node:path'; import { packageNameToPath, readVersionFromPackageDir, resolvePackagesNodeModulesRoot, } from '../utils/package-store.js'; import { computeDirectorySha256 } from '../utils/archive.js'; type JsonRecord = Record; interface IForkedFromRecord { readonly origin: string; readonly originVersion: string; readonly forkedAt: string; readonly forkedBy: string; readonly originSha256: string; } interface INameRename { readonly oldName: string; readonly newName: string; } export interface IForkCommandOptions { readonly cwd?: string; readonly packagesDir?: string; readonly name?: string; readonly replaceOrigin?: boolean; readonly forkedBy?: string; } export interface IForkCommandResult { readonly sourcePackageName: string; readonly forkedPackageName: string; readonly sourceDir: string; readonly targetDir: string; } function asRecord(value: unknown): JsonRecord | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } return value as JsonRecord; } function toPrefix(targetPackageName: string): { readonly mountPrefix: string; readonly typePrefix: string } { const scoped = targetPackageName.startsWith('@') ? targetPackageName.slice(1).split('/')[0] : targetPackageName.split('/')[0]; const mountPrefix = (scoped || 'fork').replace(/[^a-zA-Z0-9]+/g, '.').replace(/\.+/g, '.').replace(/^\./, '').toLowerCase(); const typePrefix = mountPrefix .split('.') .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(''); return { mountPrefix: mountPrefix || 'fork', typePrefix: typePrefix || 'Fork', }; } function renameType(original: unknown, typePrefix: string): string | undefined { if (typeof original !== 'string') return undefined; const trimmed = original.trim(); if (!trimmed) return undefined; return trimmed.startsWith(typePrefix) ? trimmed : `${typePrefix}${trimmed}`; } function renameMount(original: unknown, mountPrefix: string): string | undefined { if (typeof original !== 'string') return undefined; const trimmed = original.trim(); if (!trimmed) return undefined; return trimmed.startsWith(`${mountPrefix}.`) ? trimmed : `${mountPrefix}.${trimmed}`; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function deriveForkName(sourcePackageName: string): string { if (sourcePackageName.startsWith('@')) { const [, rawBase = 'package'] = sourcePackageName.split('/'); return `@fork/${rawBase}`; } return `${sourcePackageName}-fork`; } function readJson(filePath: string): JsonRecord { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; const record = asRecord(parsed); if (!record) { throw new Error(`Expected JSON object in ${filePath}`); } return record; } function writeJson(filePath: string, payload: JsonRecord): void { fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8'); } function rewriteMatrixManifest( targetDir: string, targetPackageName: string, replaceOrigin: boolean, ): ReadonlyArray { const matrixJsonPath = path.join(targetDir, 'matrix.json'); if (!fs.existsSync(matrixJsonPath)) { return []; } const manifest = readJson(matrixJsonPath); const renames: INameRename[] = []; manifest.name = targetPackageName; if (!replaceOrigin && Array.isArray(manifest.components)) { const { mountPrefix, typePrefix } = toPrefix(targetPackageName); manifest.components = manifest.components.map((entry) => { const component = asRecord(entry) ?? {}; const renamedType = renameType(component.type, typePrefix); const renamedExport = renameType(component.export, typePrefix); const renamedMount = renameMount(component.mount, mountPrefix); if (typeof component.type === 'string' && renamedType && renamedType !== component.type) { renames.push({ oldName: component.type, newName: renamedType }); } if (typeof component.export === 'string' && renamedExport && renamedExport !== component.export) { renames.push({ oldName: component.export, newName: renamedExport }); } return { ...component, type: renamedType ?? component.type, export: renamedExport ?? component.export, mount: renamedMount ?? component.mount, }; }); } writeJson(matrixJsonPath, manifest); return renames; } function rewritePackageJsonName(targetDir: string, targetPackageName: string): void { const packageJsonPath = path.join(targetDir, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; } const packageJson = readJson(packageJsonPath); packageJson.name = targetPackageName; writeJson(packageJsonPath, packageJson); } function copyPackageTree(sourceDir: string, targetDir: string): void { fs.mkdirSync(path.dirname(targetDir), { recursive: true }); fs.cpSync(sourceDir, targetDir, { recursive: true, filter: (entry) => { const base = path.basename(entry); return base !== '.git' && base !== 'node_modules' && base !== '.forked-from'; }, }); } function rewriteSourceExports(targetDir: string, renames: ReadonlyArray): void { if (renames.length === 0) { return; } const uniqueRenames = Array.from( new Map(renames.map((entry) => [`${entry.oldName}=>${entry.newName}`, entry] as const)).values() ); const filesToRewrite: string[] = []; const queue = [targetDir]; while (queue.length > 0) { const current = queue.pop()!; for (const entry of fs.readdirSync(current, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === '.git') { continue; } const absolute = path.join(current, entry.name); if (entry.isDirectory()) { queue.push(absolute); } else if (entry.isFile() && /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name)) { filesToRewrite.push(absolute); } } } for (const filePath of filesToRewrite) { let content = fs.readFileSync(filePath, 'utf8'); let changed = false; for (const rename of uniqueRenames) { const pattern = new RegExp(`\\b${escapeRegExp(rename.oldName)}\\b`, 'g'); if (pattern.test(content)) { content = content.replace(pattern, rename.newName); changed = true; } } if (changed) { fs.writeFileSync(filePath, content, 'utf8'); } } } function pruneIfEmpty(dirPath: string): void { if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { return; } if (fs.readdirSync(dirPath).length === 0) { fs.rmdirSync(dirPath); } } export async function forkCommand(sourcePackageName: string, options: IForkCommandOptions = {}): Promise { const cwd = path.resolve(options.cwd ?? process.cwd()); const nodeModulesRoot = resolvePackagesNodeModulesRoot(cwd, options.packagesDir); const sourceDir = path.join(nodeModulesRoot, packageNameToPath(sourcePackageName)); if (!fs.existsSync(sourceDir)) { throw new Error(`MX_FORK_SOURCE_MISSING: ${sourcePackageName}`); } const targetPackageName = options.name?.trim() || deriveForkName(sourcePackageName); const targetDir = path.join(nodeModulesRoot, packageNameToPath(targetPackageName)); if (fs.existsSync(targetDir)) { throw new Error(`Target package already exists: ${targetPackageName}`); } copyPackageTree(sourceDir, targetDir); rewritePackageJsonName(targetDir, targetPackageName); const renames = rewriteMatrixManifest(targetDir, targetPackageName, Boolean(options.replaceOrigin)); if (!options.replaceOrigin) { rewriteSourceExports(targetDir, renames); } const record: IForkedFromRecord = { origin: sourcePackageName, originVersion: readVersionFromPackageDir(sourceDir), forkedAt: new Date().toISOString(), forkedBy: options.forkedBy ?? process.env.USER ?? 'unknown', originSha256: computeDirectorySha256(sourceDir), }; fs.writeFileSync(path.join(targetDir, '.forked-from'), JSON.stringify(record, null, 2), 'utf8'); if (options.replaceOrigin && path.resolve(sourceDir) !== path.resolve(targetDir)) { fs.rmSync(sourceDir, { recursive: true, force: true }); pruneIfEmpty(path.dirname(sourceDir)); } return { sourcePackageName, forkedPackageName: targetPackageName, sourceDir, targetDir, }; }