#!/usr/bin/env bun /** * Folder reorganisation migration script. * * Reads scripts/folder-reorg/assignment.yml and moves every package currently * under universal/// to packages/// 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; 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; retired: Set; } function validateUniqueness(assignment: Assignment): ParsedAssignment { const pkgToPlugin = new Map(); const retired = new Set(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 { const result = new Map(); 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 ): { 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(); 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, mode: Mode ): void { const pluginCounts = new Map(); 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();