/** * Consolidate scattered projects into the one canonical workspace home. * * Historically `vclaw` scattered projects across `~/.videoclaw-*` roots * (wherever it happened to run). PR #204 made `~/videoclaw` * (`CANONICAL_WORKSPACE_ROOT`) the single home. This module productizes the * throwaway migration script that moves each scattered project into the home: * `mv projects/` into `/projects/` and leave a symlink * at the old path, so any old absolute path still resolves. * * The plan/execute split keeps it dry-run-by-default, collision-safe (a slug * already present in the home — or claimed by an earlier move in the same plan — * gets a `-2`/`-3`/… suffix), and idempotent (a project dir that is already a * symlink was migrated on a prior run and is skipped). */ import { existsSync } from 'node:fs'; import { lstat, mkdir, readdir, rename, symlink } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { CANONICAL_WORKSPACE_ROOT } from './workspace-root.js'; import { isProjectSlug, listProjects } from './projects.js'; /** * List project slugs under `/projects/`, INCLUDING ones whose dir is a * symlink. `listProjects` filters to `isDirectory()`, which drops symlinks (a * `withFileTypes` entry for a symlink reports `isSymbolicLink()`, not * `isDirectory()`) — but a previously-migrated project's old path IS a symlink, * and migrate-home needs to SEE those to count them as already migrated. So we * enumerate directly here, accepting both real dirs and symlinked dirs. */ async function listProjectEntriesIncludingSymlinks(root: string): Promise { const projectsDir = join(resolve(root), 'projects'); if (!existsSync(projectsDir)) return []; const entries = await readdir(projectsDir, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) .filter((entry) => isProjectSlug(entry.name)) .map((entry) => entry.name) .sort(); } /** One scattered project to relocate into the home. */ export interface MigrateMove { /** The scattered workspace root the project currently lives under. */ sourceRoot: string; /** The project's slug at the source root. */ srcSlug: string; /** Absolute path of the source project dir (`/projects/`). */ srcProjectDir: string; /** Collision-safe slug the project takes in the home (may be `-2` etc.). */ targetSlug: string; /** True when `targetSlug !== srcSlug` (a collision forced a rename). */ renamed: boolean; } /** The full migration plan over a set of scattered roots. */ export interface MigrateHomePlan { /** The canonical home every move lands under. */ home: string; /** Projects to relocate, in discovery order. */ moves: MigrateMove[]; /** Count of projects already living in the home (left untouched). */ alreadyInHome: number; /** Count of scattered project dirs that are already symlinks (migrated previously). */ alreadyMigrated: number; } /** * Plan the migration: for every root that is not the home, find each project * and decide where it lands in the home with a collision-safe target slug. * Pure apart from reads (lstat/listProjects/existsSync) — performs no moves. */ export async function planMigrateHome(opts: { home?: string; roots: string[] }): Promise { const home = opts.home ?? CANONICAL_WORKSPACE_ROOT; const homeResolved = resolve(home); const moves: MigrateMove[] = []; const claimed = new Set(); let alreadyMigrated = 0; for (const root of opts.roots) { if (resolve(root) === homeResolved) continue; const slugs = await listProjectEntriesIncludingSymlinks(root); for (const slug of slugs) { const srcProjectDir = join(root, 'projects', slug); const stats = await lstat(srcProjectDir).catch(() => null); if (stats?.isSymbolicLink()) { // Already migrated on a prior run (the old path is now a symlink into the home). alreadyMigrated += 1; continue; } let targetSlug = slug; let suffix = 2; while (existsSync(join(home, 'projects', targetSlug)) || claimed.has(targetSlug)) { targetSlug = `${slug}-${suffix}`; suffix += 1; } claimed.add(targetSlug); moves.push({ sourceRoot: root, srcSlug: slug, srcProjectDir, targetSlug, renamed: targetSlug !== slug, }); } } const alreadyInHome = (await listProjects(home)).length; return { home, moves, alreadyInHome, alreadyMigrated }; } /** * Execute a plan: `mv` each project into `/projects/` and * leave a directory symlink at the old path. Per-move errors are collected into * `failures` (the run continues), so one bad move never blocks the rest. */ export async function executeMigrateHome( plan: MigrateHomePlan, ): Promise<{ moved: number; failures: Array<{ slug: string; error: string }> }> { const failures: Array<{ slug: string; error: string }> = []; let moved = 0; for (const move of plan.moves) { try { const dest = join(plan.home, 'projects', move.targetSlug); await mkdir(join(plan.home, 'projects'), { recursive: true }); await rename(move.srcProjectDir, dest); await symlink(dest, move.srcProjectDir, 'dir'); moved += 1; } catch (err) { failures.push({ slug: move.srcSlug, error: err instanceof Error ? err.message : String(err) }); } } return { moved, failures }; }