Initial Commit
This commit is contained in:
410
scripts/folder-reorg/migrate-folders.ts
Normal file
410
scripts/folder-reorg/migrate-folders.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Folder reorganisation migration script.
|
||||
*
|
||||
* Reads scripts/folder-reorg/assignment.yml and moves every package currently
|
||||
* under universal/<domain>/<pkg>/ to packages/<plugin>/<pkg>/ via `git mv`.
|
||||
*
|
||||
* Run modes:
|
||||
* bun scripts/folder-reorg/migrate-folders.ts --dry-run # plan only
|
||||
* bun scripts/folder-reorg/migrate-folders.ts # execute
|
||||
* bun scripts/folder-reorg/migrate-folders.ts --check # validate manifest only
|
||||
*
|
||||
* Exits non-zero on:
|
||||
* - duplicate package across plugins
|
||||
* - on-disk package not present in any plugin's list
|
||||
* - destination path already exists
|
||||
* - git working tree dirty (execute mode only)
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..");
|
||||
const assignmentPath = join(here, "assignment.yml");
|
||||
|
||||
const LEGACY_ROOTS = [
|
||||
"universal",
|
||||
"browser",
|
||||
"react",
|
||||
"r3f",
|
||||
"server",
|
||||
"finance",
|
||||
"mcp",
|
||||
"python",
|
||||
"gaming",
|
||||
"api",
|
||||
] as const;
|
||||
|
||||
type Mode = "dry-run" | "execute" | "check";
|
||||
|
||||
interface Assignment {
|
||||
[plugin: string]: string[];
|
||||
}
|
||||
|
||||
interface PlannedMove {
|
||||
pkg: string;
|
||||
plugin: string;
|
||||
from: string; // absolute
|
||||
to: string; // absolute
|
||||
}
|
||||
|
||||
const log = (...args: unknown[]) => console.log(...args);
|
||||
const warn = (...args: unknown[]) => console.warn("⚠ ", ...args);
|
||||
const fail = (msg: string): never => {
|
||||
console.error(`✗ ${msg}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
function git(args: string[], cwd: string = repoRoot): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
||||
}
|
||||
|
||||
function parseMode(argv: string[]): Mode {
|
||||
if (argv.includes("--dry-run")) return "dry-run";
|
||||
if (argv.includes("--check")) return "check";
|
||||
return "execute";
|
||||
}
|
||||
|
||||
function loadAssignment(): Assignment {
|
||||
if (!existsSync(assignmentPath)) {
|
||||
fail(`Assignment manifest not found: ${assignmentPath}`);
|
||||
}
|
||||
const raw = readFileSync(assignmentPath, "utf8");
|
||||
const parsed = parseYaml(raw) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
fail("assignment.yml did not parse to an object");
|
||||
}
|
||||
const a = parsed as Record<string, unknown>;
|
||||
for (const [plugin, pkgs] of Object.entries(a)) {
|
||||
if (!Array.isArray(pkgs)) fail(`Plugin "${plugin}" is not a list`);
|
||||
for (const p of pkgs) {
|
||||
if (typeof p !== "string")
|
||||
fail(`Plugin "${plugin}" has non-string entry`);
|
||||
}
|
||||
}
|
||||
return a as Assignment;
|
||||
}
|
||||
|
||||
interface ParsedAssignment {
|
||||
pkgToPlugin: Map<string, string>;
|
||||
retired: Set<string>;
|
||||
}
|
||||
|
||||
function validateUniqueness(assignment: Assignment): ParsedAssignment {
|
||||
const pkgToPlugin = new Map<string, string>();
|
||||
const retired = new Set<string>(assignment.__retired__ ?? []);
|
||||
const duplicates: string[] = [];
|
||||
for (const [plugin, pkgs] of Object.entries(assignment)) {
|
||||
if (plugin === "__retired__") continue;
|
||||
for (const pkg of pkgs) {
|
||||
if (retired.has(pkg)) {
|
||||
duplicates.push(
|
||||
` ${pkg}: listed in plugin "${plugin}" AND in __retired__`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const prior = pkgToPlugin.get(pkg);
|
||||
if (prior !== undefined) {
|
||||
duplicates.push(` ${pkg}: in "${prior}" AND "${plugin}"`);
|
||||
} else {
|
||||
pkgToPlugin.set(pkg, plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
fail(`Duplicate package assignments found:\n${duplicates.join("\n")}`);
|
||||
}
|
||||
return { pkgToPlugin, retired };
|
||||
}
|
||||
|
||||
function discoverOnDiskPackages(): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const root of LEGACY_ROOTS) {
|
||||
const rootPath = join(repoRoot, root);
|
||||
if (!existsSync(rootPath) || !statSync(rootPath).isDirectory())
|
||||
continue;
|
||||
for (const domain of readdirSync(rootPath)) {
|
||||
const domainPath = join(rootPath, domain);
|
||||
if (!statSync(domainPath).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(domainPath)) {
|
||||
const pkgPath = join(domainPath, pkg);
|
||||
if (!statSync(pkgPath).isDirectory()) continue;
|
||||
if (!existsSync(join(pkgPath, "package.json"))) continue;
|
||||
if (result.has(pkg)) {
|
||||
fail(
|
||||
`On-disk duplicate package directory: "${pkg}" exists in both ${result.get(pkg)} and ${pkgPath}`
|
||||
);
|
||||
}
|
||||
result.set(pkg, pkgPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function planMoves(
|
||||
parsed: ParsedAssignment,
|
||||
onDisk: Map<string, string>
|
||||
): { moves: PlannedMove[]; orphans: string[]; retiredOnDisk: string[] } {
|
||||
const moves: PlannedMove[] = [];
|
||||
const orphans: string[] = [];
|
||||
const retiredOnDisk: string[] = [];
|
||||
for (const [pkg, fromAbs] of onDisk) {
|
||||
if (parsed.retired.has(pkg)) {
|
||||
retiredOnDisk.push(pkg);
|
||||
continue;
|
||||
}
|
||||
const plugin = parsed.pkgToPlugin.get(pkg);
|
||||
if (plugin === undefined) {
|
||||
orphans.push(pkg);
|
||||
continue;
|
||||
}
|
||||
const toAbs = join(repoRoot, "packages", plugin, pkg);
|
||||
moves.push({ pkg, plugin, from: fromAbs, to: toAbs });
|
||||
}
|
||||
moves.sort((a, b) =>
|
||||
a.plugin === b.plugin
|
||||
? a.pkg.localeCompare(b.pkg)
|
||||
: a.plugin.localeCompare(b.plugin)
|
||||
);
|
||||
return { moves, orphans, retiredOnDisk };
|
||||
}
|
||||
|
||||
function checkGitClean(): void {
|
||||
const out = git(["status", "--porcelain"]);
|
||||
if (out.trim().length > 0) {
|
||||
fail(
|
||||
`Working tree is dirty — commit or stash before migrating.\n\n${out}\n` +
|
||||
"If you really want to proceed with uncommitted changes, run with --dry-run first to verify the plan."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDestinationsClear(moves: PlannedMove[]): void {
|
||||
const collisions: string[] = [];
|
||||
for (const m of moves) {
|
||||
if (existsSync(m.to)) collisions.push(` ${relative(repoRoot, m.to)}`);
|
||||
}
|
||||
if (collisions.length > 0) {
|
||||
fail(`Destination paths already exist:\n${collisions.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip these from `cp -R` — build/test artifacts + dependency trees, regenerated
|
||||
// by `bun install` post-migration. Skipping keeps moves under 1s per package
|
||||
// instead of minutes (a single testing-integration package node_modules is ~500MB).
|
||||
const COPY_SKIP = new Set([
|
||||
"node_modules",
|
||||
"dist",
|
||||
".turbo",
|
||||
"coverage",
|
||||
".bd_modules",
|
||||
".tsbuildinfo",
|
||||
]);
|
||||
|
||||
function copyTrackedOnly(fromAbs: string, toAbs: string): void {
|
||||
cpSync(fromAbs, toAbs, {
|
||||
recursive: true,
|
||||
filter: (src) => {
|
||||
const base = src.split("/").pop() ?? "";
|
||||
if (COPY_SKIP.has(base)) return false;
|
||||
if (base.endsWith(".tsbuildinfo")) return false;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function moveOne(fromRel: string, toRel: string): "git-mv" | "cp-rm" {
|
||||
// Try `git mv` first — preserves rename in the index cleanly.
|
||||
try {
|
||||
git(["mv", fromRel, toRel]);
|
||||
return "git-mv";
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
if (!msg.includes("Permission denied")) throw err;
|
||||
}
|
||||
// WSL2/DrvFs fallback: rename syscall blocked by Windows-side handle.
|
||||
// Copy tracked files only (skip node_modules etc), remove source, restage.
|
||||
// Git's rename detection still works for `log --follow` on similar files.
|
||||
const fromAbs = join(repoRoot, fromRel);
|
||||
const toAbs = join(repoRoot, toRel);
|
||||
copyTrackedOnly(fromAbs, toAbs);
|
||||
rmSync(fromAbs, { recursive: true, force: true });
|
||||
git(["add", toRel]);
|
||||
git(["rm", "-r", "--quiet", fromRel]);
|
||||
return "cp-rm";
|
||||
}
|
||||
|
||||
function executeMoves(moves: PlannedMove[]): void {
|
||||
const seenPluginDirs = new Set<string>();
|
||||
let cpRmCount = 0;
|
||||
for (const m of moves) {
|
||||
const pluginDir = join(repoRoot, "packages", m.plugin);
|
||||
if (!seenPluginDirs.has(pluginDir)) {
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
seenPluginDirs.add(pluginDir);
|
||||
}
|
||||
const fromRel = relative(repoRoot, m.from);
|
||||
const toRel = relative(repoRoot, m.to);
|
||||
const method = moveOne(fromRel, toRel);
|
||||
const tag = method === "cp-rm" ? " cp+rm " : " git mv ";
|
||||
log(`${tag}${fromRel} → ${toRel}`);
|
||||
if (method === "cp-rm") cpRmCount += 1;
|
||||
}
|
||||
if (cpRmCount > 0) {
|
||||
log("");
|
||||
log(
|
||||
`(${cpRmCount}/${moves.length} moves used cp+rm fallback due to WSL2 rename block.)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEmptyDir(absPath: string): boolean {
|
||||
// Returns true if the directory was removed.
|
||||
if (!existsSync(absPath) || !statSync(absPath).isDirectory()) return false;
|
||||
const entries = readdirSync(absPath);
|
||||
let allRemoved = true;
|
||||
for (const entry of entries) {
|
||||
const child = join(absPath, entry);
|
||||
if (statSync(child).isDirectory()) {
|
||||
if (!pruneEmptyDir(child)) allRemoved = false;
|
||||
} else {
|
||||
allRemoved = false;
|
||||
}
|
||||
}
|
||||
if (allRemoved && readdirSync(absPath).length === 0) {
|
||||
rmdirSync(absPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pruneEmptyLegacyDirs(): void {
|
||||
for (const root of LEGACY_ROOTS) {
|
||||
const rootPath = join(repoRoot, root);
|
||||
if (pruneEmptyDir(rootPath)) {
|
||||
log(` rmdir ${root}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarise(
|
||||
moves: PlannedMove[],
|
||||
orphans: string[],
|
||||
retiredOnDisk: string[],
|
||||
assignment: Assignment,
|
||||
onDisk: Map<string, string>,
|
||||
mode: Mode
|
||||
): void {
|
||||
const pluginCounts = new Map<string, number>();
|
||||
for (const m of moves)
|
||||
pluginCounts.set(m.plugin, (pluginCounts.get(m.plugin) ?? 0) + 1);
|
||||
const pluginKeys = Object.keys(assignment).filter(
|
||||
(k) => k !== "__retired__"
|
||||
);
|
||||
const totalAssigned = pluginKeys.reduce(
|
||||
(n, k) => n + (assignment[k]?.length ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
log("");
|
||||
log(`Mode: ${mode}`);
|
||||
log(`Plugins defined: ${pluginKeys.length}`);
|
||||
log(`Packages assigned: ${totalAssigned}`);
|
||||
log(`Packages retired: ${assignment.__retired__?.length ?? 0}`);
|
||||
log(`Packages on disk: ${onDisk.size}`);
|
||||
log(`Packages to migrate: ${moves.length}`);
|
||||
log(`Packages on disk (retired, skipped): ${retiredOnDisk.length}`);
|
||||
log(`Packages future-only: ${totalAssigned - moves.length}`);
|
||||
log("");
|
||||
log("Per-plugin migration counts:");
|
||||
for (const [plugin, count] of [...pluginCounts.entries()].sort()) {
|
||||
log(` ${plugin.padEnd(24)} ${count}`);
|
||||
}
|
||||
if (retiredOnDisk.length > 0) {
|
||||
log("");
|
||||
log("Retired packages on disk (NOT migrated; remove via tasks.md §8):");
|
||||
for (const p of retiredOnDisk)
|
||||
log(` ${p} (${relative(repoRoot, onDisk.get(p) ?? "")})`);
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
log("");
|
||||
warn("On-disk packages not assigned to any plugin:");
|
||||
for (const p of orphans) warn(` ${p}`);
|
||||
fail(
|
||||
"Refusing to proceed — every on-disk package must appear in assignment.yml (or in __retired__)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const mode = parseMode(process.argv.slice(2));
|
||||
log(`folder-reorg/migrate-folders.ts — mode: ${mode}`);
|
||||
|
||||
const assignment = loadAssignment();
|
||||
const parsed = validateUniqueness(assignment);
|
||||
|
||||
if (mode === "check") {
|
||||
const pluginCount = Object.keys(assignment).filter(
|
||||
(k) => k !== "__retired__"
|
||||
).length;
|
||||
log(
|
||||
`✓ Manifest valid: ${parsed.pkgToPlugin.size} unique packages across ${pluginCount} plugins, ${parsed.retired.size} retired.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const onDisk = discoverOnDiskPackages();
|
||||
const { moves, orphans, retiredOnDisk } = planMoves(parsed, onDisk);
|
||||
|
||||
summarise(moves, orphans, retiredOnDisk, assignment, onDisk, mode);
|
||||
|
||||
if (mode === "dry-run") {
|
||||
log("");
|
||||
log("Planned moves (first 10):");
|
||||
for (const m of moves.slice(0, 10)) {
|
||||
log(
|
||||
` ${relative(repoRoot, m.from)} → ${relative(repoRoot, m.to)}`
|
||||
);
|
||||
}
|
||||
if (moves.length > 10) log(` ...and ${moves.length - 10} more`);
|
||||
log("");
|
||||
log("✓ Dry run complete. Re-run without --dry-run to execute.");
|
||||
return;
|
||||
}
|
||||
|
||||
checkGitClean();
|
||||
ensureDestinationsClear(moves);
|
||||
|
||||
log("");
|
||||
log("Executing moves:");
|
||||
executeMoves(moves);
|
||||
pruneEmptyLegacyDirs();
|
||||
|
||||
log("");
|
||||
log(`✓ Migrated ${moves.length} packages.`);
|
||||
log("");
|
||||
log("Next steps:");
|
||||
log(' 1. Update root package.json workspaces to: "packages/*/*"');
|
||||
log(" 2. Run: rm -rf node_modules bun.lockb && bun install --force");
|
||||
log(" 3. Run: bunx turbo lint typecheck test");
|
||||
log(
|
||||
" 4. Commit: chore(structure): move all packages to domain-first layout"
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user