255 lines
8.3 KiB
TypeScript
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,
|
|
};
|
|
}
|