#!/usr/bin/env bun /** * Category realignment migration script. * * Reads scripts/folder-reorg/assignment.yml and moves every package currently * under packages/// to packages/// * via `git mv`. * * Run modes: * bun scripts/folder-reorg/realign-to-categories.ts --dry-run # plan only * bun scripts/folder-reorg/realign-to-categories.ts # execute * bun scripts/folder-reorg/realign-to-categories.ts --check # validate manifest only * * Exits non-zero on: * - duplicate package across categories * - on-disk package not present in any category'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 packagesRoot = join(repoRoot, "packages"); type Mode = "dry-run" | "execute" | "check"; interface Assignment { [category: string]: string[]; } interface PlannedMove { pkg: string; category: 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 [category, pkgs] of Object.entries(a)) { if (!Array.isArray(pkgs)) fail(`Category "${category}" is not a list`); for (const p of pkgs) { if (typeof p !== "string") fail(`Category "${category}" has non-string entry`); } } return a as Assignment; } interface ParsedAssignment { pkgToCategory: Map; retiredPackages: Set; retiredCategories: Set; } function validateUniqueness(assignment: Assignment): ParsedAssignment { const pkgToCategory = new Map(); const retiredPackages = new Set(assignment.__retired__ ?? []); const retiredCategories = new Set( Array.isArray(assignment.__retired_categories__) ? assignment.__retired_categories__ : [] ); const duplicates: string[] = []; for (const [category, pkgs] of Object.entries(assignment)) { if (category === "__retired__" || category === "__retired_categories__") continue; for (const pkg of pkgs) { if (retiredPackages.has(pkg)) { duplicates.push( ` ${pkg}: listed in category "${category}" AND in __retired__` ); continue; } const prior = pkgToCategory.get(pkg); if (prior !== undefined) { duplicates.push(` ${pkg}: in "${prior}" AND "${category}"`); } else { pkgToCategory.set(pkg, category); } } } if (duplicates.length > 0) { fail(`Duplicate package assignments found:\n${duplicates.join("\n")}`); } return { pkgToCategory, retiredPackages, retiredCategories }; } function discoverOnDiskPackages(): Map { const result = new Map(); if (!existsSync(packagesRoot) || !statSync(packagesRoot).isDirectory()) { fail(`Packages root does not exist: ${packagesRoot}`); } for (const category of readdirSync(packagesRoot)) { const categoryPath = join(packagesRoot, category); if (!statSync(categoryPath).isDirectory()) continue; for (const pkg of readdirSync(categoryPath)) { const pkgPath = join(categoryPath, 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.retiredPackages.has(pkg)) { retiredOnDisk.push(pkg); continue; } const category = parsed.pkgToCategory.get(pkg); if (category === undefined) { orphans.push(pkg); continue; } const toAbs = join(packagesRoot, category, pkg); if (toAbs !== fromAbs) { moves.push({ pkg, category, from: fromAbs, to: toAbs }); } } moves.sort((a, b) => a.category === b.category ? a.pkg.localeCompare(b.pkg) : a.category.localeCompare(b.category) ); 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")}`); } } 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", fromRel, toRel]); return "git-mv"; } catch (err) { const msg = (err as Error).message; if (!msg.includes("Permission denied")) throw err; } 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 seenCategoryDirs = new Set(); let cpRmCount = 0; for (const m of moves) { const categoryDir = join(packagesRoot, m.category); if (!seenCategoryDirs.has(categoryDir)) { mkdirSync(categoryDir, { recursive: true }); seenCategoryDirs.add(categoryDir); } 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 { 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 pruneRetiredCategories(retiredCategories: Set): void { for (const category of retiredCategories) { const categoryPath = join(packagesRoot, category); if (pruneEmptyDir(categoryPath)) { log(` rmdir packages/${category}/`); } } } function summarise( moves: PlannedMove[], orphans: string[], retiredOnDisk: string[], assignment: Assignment, onDisk: Map, mode: Mode ): void { const categoryCounts = new Map(); for (const m of moves) categoryCounts.set( m.category, (categoryCounts.get(m.category) ?? 0) + 1 ); const categoryKeys = Object.keys(assignment).filter( (k) => k !== "__retired__" ); const totalAssigned = categoryKeys.reduce( (n, k) => n + (assignment[k]?.length ?? 0), 0 ); log(""); log(`Mode: ${mode}`); log(`Categories defined: ${categoryKeys.length}`); log(`Packages assigned: ${totalAssigned}`); log(`Packages retired: ${assignment.__retired__?.length ?? 0}`); log( `Categories retired: ${assignment.__retired_categories__?.length ?? 0}` ); log(`Packages on disk: ${onDisk.size}`); log(`Packages to migrate: ${moves.length}`); log( `Packages already correct: ${onDisk.size - moves.length - orphans.length - retiredOnDisk.length}` ); log(`Packages on disk (retired, skipped): ${retiredOnDisk.length}`); log(`Packages future-only: ${totalAssigned - onDisk.size}`); log(""); log("Per-category migration counts:"); for (const [category, count] of [...categoryCounts.entries()].sort()) { log(` ${category.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 category:"); 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/realign-to-categories.ts — mode: ${mode}`); const assignment = loadAssignment(); const parsed = validateUniqueness(assignment); if (mode === "check") { const categoryCount = Object.keys(assignment).filter( (k) => k !== "__retired__" && k !== "__retired_categories__" ).length; log( `✓ Manifest valid: ${parsed.pkgToCategory.size} unique packages across ${categoryCount} categories, ${parsed.retiredPackages.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); pruneRetiredCategories(parsed.retiredCategories); log(""); log(`✓ Migrated ${moves.length} packages.`); log(""); log("Next steps:"); log( ' 1. Run: rm -rf node_modules bun.lockb && find . -type d -name "node_modules" -exec rm -rf {} + && bun install --force' ); log(" 2. Run: bunx turbo lint typecheck test"); log(" 3. Run: bun scripts/folder-reorg/generate-manifest.ts"); log( " 4. Commit: chore(structure): realign packages to FOLDERS.md categories" ); } main();