255 lines
8.3 KiB
TypeScript

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<string, unknown>;
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<INameRename> {
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<INameRename>): 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<IForkCommandResult> {
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,
};
}