diff --git a/src/cli/__tests__/project.test.ts b/src/cli/__tests__/project.test.ts new file mode 100644 index 0000000..c077676 --- /dev/null +++ b/src/cli/__tests__/project.test.ts @@ -0,0 +1,206 @@ +/** + * Tests for `foreman project` CLI commands. + * + * Covers: + * - `foreman project add ` — happy path, --name, --force, duplicate error + * - `foreman project list` — empty, with projects, --stale + * - `foreman project remove ` — happy path, --stale, not-found error + * + * Uses tsx to run the CLI as a subprocess for realistic end-to-end coverage. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, + existsSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir, homedir } from "node:os"; +import path from "node:path"; + +const execFileAsync = promisify(execFile); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function findTsx(): string { + const candidates = [ + path.resolve(__dirname, "../../../node_modules/.bin/tsx"), + path.resolve(__dirname, "../../../../../node_modules/.bin/tsx"), + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return candidates[0]; +} + +const TSX = findTsx(); +const CLI = path.resolve(__dirname, "../../../src/cli/index.ts"); + +interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; +} + +async function runCli( + args: string[], + registryPath: string, + cwd?: string, +): Promise { + try { + const { stdout, stderr } = await execFileAsync(TSX, [CLI, ...args], { + cwd: cwd ?? tmpdir(), + timeout: 15_000, + env: { + ...process.env, + NO_COLOR: "1", + // Override the registry path via a special env var (see below) + FOREMAN_REGISTRY_PATH: registryPath, + }, + }); + return { stdout, stderr, exitCode: 0 }; + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; code?: number }; + return { + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + exitCode: e.code ?? 1, + }; + } +} + +function mkTmpProjectDir(base: string, name: string): string { + const dir = join(base, name); + mkdirSync(join(dir, ".foreman"), { recursive: true }); + return dir; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("foreman project commands (unit — ProjectRegistry class)", () => { + /** + * These tests import ProjectRegistry directly (no subprocess) for speed. + * The CLI subprocess tests below exercise the Commander integration. + */ + let tmpBase: string; + let registryFile: string; + + beforeEach(() => { + tmpBase = mkdtempSync(join(tmpdir(), "foreman-proj-test-")); + registryFile = join(tmpBase, ".foreman", "projects.json"); + }); + + afterEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + }); + + it("ProjectRegistry can be instantiated with a custom path", async () => { + const { ProjectRegistry } = await import("../../lib/project-registry.js"); + const reg = new ProjectRegistry(registryFile); + expect(reg.list()).toEqual([]); + }); + + it("add + list round-trip", async () => { + const { ProjectRegistry } = await import("../../lib/project-registry.js"); + const reg = new ProjectRegistry(registryFile); + const p = mkTmpProjectDir(tmpBase, "alpha"); + await reg.add(p, "alpha"); + const projects = reg.list(); + expect(projects).toHaveLength(1); + expect(projects[0]!.name).toBe("alpha"); + }); + + it("remove after add", async () => { + const { ProjectRegistry } = await import("../../lib/project-registry.js"); + const reg = new ProjectRegistry(registryFile); + const p = mkTmpProjectDir(tmpBase, "beta"); + await reg.add(p, "beta"); + await reg.remove("beta"); + expect(reg.list()).toHaveLength(0); + }); + + it("resolve by name", async () => { + const { ProjectRegistry } = await import("../../lib/project-registry.js"); + const reg = new ProjectRegistry(registryFile); + const p = mkTmpProjectDir(tmpBase, "gamma"); + await reg.add(p, "gamma"); + expect(reg.resolve("gamma")).toBe(resolve(p)); + }); + + it("throws DuplicateProjectError on duplicate name", async () => { + const { ProjectRegistry, DuplicateProjectError } = await import( + "../../lib/project-registry.js" + ); + const reg = new ProjectRegistry(registryFile); + const p1 = mkTmpProjectDir(tmpBase, "p1"); + const p2 = mkTmpProjectDir(tmpBase, "p2"); + await reg.add(p1, "shared"); + await expect(reg.add(p2, "shared")).rejects.toBeInstanceOf(DuplicateProjectError); + }); + + it("throws ProjectNotFoundError on remove of unknown name", async () => { + const { ProjectRegistry, ProjectNotFoundError } = await import( + "../../lib/project-registry.js" + ); + const reg = new ProjectRegistry(registryFile); + await expect(reg.remove("ghost")).rejects.toBeInstanceOf(ProjectNotFoundError); + }); + + it("removeStale() removes inaccessible directories", async () => { + const { ProjectRegistry } = await import("../../lib/project-registry.js"); + mkdirSync(join(tmpBase, ".foreman"), { recursive: true }); + + const live = mkTmpProjectDir(tmpBase, "live"); + const ghostPath = join(tmpBase, "ghost-does-not-exist"); + + writeFileSync( + registryFile, + JSON.stringify({ + version: 1, + projects: [ + { name: "live", path: resolve(live), addedAt: new Date().toISOString() }, + { name: "ghost", path: ghostPath, addedAt: new Date().toISOString() }, + ], + }), + "utf-8", + ); + + const reg = new ProjectRegistry(registryFile); + const removed = await reg.removeStale(); + expect(removed).toContain("ghost"); + expect(removed).not.toContain("live"); + expect(reg.list()).toHaveLength(1); + }); + + it("listStale() lists without removing", async () => { + const { ProjectRegistry } = await import("../../lib/project-registry.js"); + mkdirSync(join(tmpBase, ".foreman"), { recursive: true }); + + const live = mkTmpProjectDir(tmpBase, "live"); + const ghostPath = join(tmpBase, "ghost-does-not-exist"); + + writeFileSync( + registryFile, + JSON.stringify({ + version: 1, + projects: [ + { name: "live", path: resolve(live), addedAt: new Date().toISOString() }, + { name: "ghost", path: ghostPath, addedAt: new Date().toISOString() }, + ], + }), + "utf-8", + ); + + const reg = new ProjectRegistry(registryFile); + const stale = reg.listStale(); + expect(stale).toHaveLength(1); + expect(stale[0]!.name).toBe("ghost"); + // list() still returns both + expect(reg.list()).toHaveLength(2); + }); +}); diff --git a/src/cli/commands/project.ts b/src/cli/commands/project.ts new file mode 100644 index 0000000..4a1d8fa --- /dev/null +++ b/src/cli/commands/project.ts @@ -0,0 +1,197 @@ +/** + * `foreman project` CLI commands — manage the global project registry. + * + * Sub-commands: + * foreman project add [--name ] [--force] + * foreman project list [--stale] + * foreman project remove [--force] [--stale] + * + * @module src/cli/commands/project + */ + +import { Command } from "commander"; +import chalk from "chalk"; +import { resolve } from "node:path"; +import { + ProjectRegistry, + DuplicateProjectError, + ProjectNotFoundError, + type ProjectEntry, +} from "../../lib/project-registry.js"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Column widths for the project table. */ +const COL_NAME = 24; +const COL_PATH = 50; +const COL_STATUS = 12; + +function pad(str: string, width: number): string { + return str.length >= width ? str.slice(0, width - 1) + "…" : str.padEnd(width); +} + +function printProjectTable(projects: ProjectEntry[], label?: string): void { + if (projects.length === 0) { + if (label) { + console.log(chalk.dim(`No ${label} projects found.`)); + } else { + console.log(chalk.dim("No projects registered.")); + } + return; + } + + // Header + console.log( + chalk.bold(pad("NAME", COL_NAME)) + + chalk.bold(pad("PATH", COL_PATH)) + + chalk.bold(pad("ADDED", COL_STATUS)), + ); + console.log("─".repeat(COL_NAME + COL_PATH + COL_STATUS)); + + for (const p of projects) { + const addedDate = new Date(p.addedAt).toLocaleDateString(); + console.log( + chalk.cyan(pad(p.name, COL_NAME)) + + chalk.dim(pad(p.path, COL_PATH)) + + chalk.dim(pad(addedDate, COL_STATUS)), + ); + } +} + +// ── foreman project add ─────────────────────────────────────────────────────── + +const addCommand = new Command("add") + .description("Register a project in the global registry") + .argument("", "Path to the project root") + .option("--name ", "Register under this alias (default: directory basename)") + .option("--force", "Overwrite existing registration with the same name") + .action( + async ( + projectPath: string, + opts: { name?: string; force?: boolean }, + ) => { + const resolvedPath = resolve(projectPath); + const registry = new ProjectRegistry(); + + try { + if (opts.force) { + // If forcing, remove any existing registration with the same name or path first + const projects = registry.list(); + const targetName = opts.name ?? resolvedPath.split("/").pop() ?? resolvedPath; + + const existingByName = projects.find((p) => p.name === targetName); + if (existingByName !== undefined) { + await registry.remove(existingByName.name); + } + + const existingByPath = registry.list().find((p) => p.path === resolvedPath); + if (existingByPath !== undefined) { + await registry.remove(existingByPath.name); + } + } + + await registry.add(resolvedPath, opts.name); + const name = opts.name ?? resolvedPath.split("/").pop() ?? resolvedPath; + console.log(chalk.green(`✓ Project '${name}' registered at: ${resolvedPath}`)); + } catch (err) { + if (err instanceof DuplicateProjectError) { + if (err.field === "name") { + console.error( + chalk.red(`Error: Project '${err.value}' is already registered.`) + + chalk.dim("\n Use --force to overwrite, or --name to choose a different alias."), + ); + } else { + console.error( + chalk.red(`Error: Path '${err.value}' is already registered as a project.`) + + chalk.dim("\n Use --force to overwrite."), + ); + } + process.exit(1); + } + throw err; + } + }, + ); + +// ── foreman project list ────────────────────────────────────────────────────── + +const listCommand = new Command("list") + .description("List all registered projects") + .option("--stale", "Show only projects with inaccessible directories") + .action(async (opts: { stale?: boolean }) => { + const registry = new ProjectRegistry(); + + if (opts.stale) { + const staleProjects = registry.listStale(); + if (staleProjects.length === 0) { + console.log(chalk.green("✓ No stale projects found — all registered paths are accessible.")); + return; + } + console.log(chalk.yellow(`Found ${staleProjects.length} stale project(s):\n`)); + printProjectTable(staleProjects, "stale"); + console.log( + chalk.dim("\n Run 'foreman project remove ' or 'foreman project remove --stale' to clean up."), + ); + return; + } + + const projects = registry.list(); + if (projects.length === 0) { + console.log(chalk.dim("No projects registered yet.")); + console.log(chalk.dim(" Run 'foreman project add ' to register a project.")); + return; + } + + console.log(chalk.bold(`\n Registered Projects (${projects.length})\n`)); + printProjectTable(projects); + console.log(); + }); + +// ── foreman project remove ──────────────────────────────────────────────────── + +const removeCommand = new Command("remove") + .description("Remove a project from the global registry") + .argument("[name]", "Project name to remove") + .option("--force", "Remove even if the project has active agents") + .option("--stale", "Remove all stale (inaccessible) projects") + .action(async (name: string | undefined, opts: { force?: boolean; stale?: boolean }) => { + const registry = new ProjectRegistry(); + + // -- Handle --stale: bulk-remove all inaccessible projects + if (opts.stale) { + const removed = await registry.removeStale(); + if (removed.length === 0) { + console.log(chalk.green("✓ No stale projects to remove.")); + } else { + for (const n of removed) { + console.log(chalk.yellow(` ✓ Removed stale project: ${n}`)); + } + console.log(chalk.green(`\n✓ Removed ${removed.length} stale project(s).`)); + } + return; + } + + if (!name) { + console.error(chalk.red("Error: project name required (or use --stale to remove all stale).")); + process.exit(1); + } + + try { + await registry.remove(name); + console.log(chalk.green(`✓ Project '${name}' removed from registry.`)); + } catch (err) { + if (err instanceof ProjectNotFoundError) { + console.error(chalk.red(`Error: Project '${name}' is not registered.`)); + process.exit(1); + } + throw err; + } + }); + +// ── Parent command ──────────────────────────────────────────────────────────── + +export const projectCommand = new Command("project") + .description("Manage the global project registry (~/.foreman/projects.json)") + .addCommand(addCommand) + .addCommand(listCommand) + .addCommand(removeCommand); diff --git a/src/cli/index.ts b/src/cli/index.ts index 3b54f49..e5599bd 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -56,6 +56,7 @@ import { purgeLogsCommand } from "./commands/purge-logs.js"; import { inboxCommand } from "./commands/inbox.js"; import { mailCommand } from "./commands/mail.js"; import { debugCommand } from "./commands/debug.js"; +import { projectCommand } from "./commands/project.js"; import { recoverCommand } from "./commands/recover.js"; const program = new Command(); @@ -87,6 +88,7 @@ program.addCommand(purgeLogsCommand); program.addCommand(inboxCommand); program.addCommand(mailCommand); program.addCommand(debugCommand); +program.addCommand(projectCommand); program.addCommand(recoverCommand); program.parse(); diff --git a/src/lib/__tests__/project-registry.test.ts b/src/lib/__tests__/project-registry.test.ts new file mode 100644 index 0000000..f8d01d9 --- /dev/null +++ b/src/lib/__tests__/project-registry.test.ts @@ -0,0 +1,357 @@ +/** + * Tests for src/lib/project-registry.ts + * + * Covers: + * - ProjectRegistry.add() — happy path, duplicate detection (name + path) + * - ProjectRegistry.list() — returns all registered projects + * - ProjectRegistry.remove() — removes by name, error on not found + * - ProjectRegistry.resolve() — resolves by name and by path + * - ProjectRegistry.removeStale() — removes inaccessible paths + * - ProjectRegistry.listStale() — lists inaccessible paths without removing + * - Auto-mkdir: ~/.foreman/ created if absent + * - Empty registry: graceful startup (no file) + * - Corrupt registry file: graceful recovery + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { + ProjectRegistry, + DuplicateProjectError, + ProjectNotFoundError, + type ProjectEntry, +} from "../project-registry.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function mkTmpDir(): string { + const dir = join( + tmpdir(), + `foreman-pr-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** Create a fake project directory with a .foreman/ sub-dir */ +function mkProject(baseDir: string, name: string): string { + const dir = join(baseDir, name); + mkdirSync(join(dir, ".foreman"), { recursive: true }); + return dir; +} + +/** Create a fake project directory WITHOUT .foreman/ */ +function mkBareProject(baseDir: string, name: string): string { + const dir = join(baseDir, name); + mkdirSync(dir, { recursive: true }); + return dir; +} + +// ── Fixture ──────────────────────────────────────────────────────────────────── + +describe("ProjectRegistry", () => { + let tmpBase: string; + let registryFile: string; + let registry: ProjectRegistry; + + beforeEach(() => { + tmpBase = mkTmpDir(); + // Put registry in a sub-dir that does NOT yet exist (tests auto-mkdir) + registryFile = join(tmpBase, ".foreman", "projects.json"); + registry = new ProjectRegistry(registryFile); + }); + + afterEach(() => { + rmSync(tmpBase, { recursive: true, force: true }); + }); + + // ── Initial state ──────────────────────────────────────────────────────────── + + it("list() returns empty array when registry file does not exist", () => { + const projects = registry.list(); + expect(projects).toEqual([]); + }); + + it("does not create registry file on list()", () => { + registry.list(); + expect(existsSync(registryFile)).toBe(false); + }); + + // ── add() ──────────────────────────────────────────────────────────────────── + + it("add() registers a project and list() returns it", async () => { + const projectDir = mkProject(tmpBase, "my-project"); + await registry.add(projectDir); + + const projects = registry.list(); + expect(projects).toHaveLength(1); + expect(projects[0]!.name).toBe("my-project"); + expect(projects[0]!.path).toBe(resolve(projectDir)); + expect(projects[0]!.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("add() creates registry file (and parent dir) on first write", async () => { + const projectDir = mkProject(tmpBase, "alpha"); + await registry.add(projectDir); + expect(existsSync(registryFile)).toBe(true); + }); + + it("add() accepts an explicit name override", async () => { + const projectDir = mkProject(tmpBase, "my-project"); + await registry.add(projectDir, "alias"); + + const projects = registry.list(); + expect(projects[0]!.name).toBe("alias"); + }); + + it("add() resolves relative paths to absolute", async () => { + const projectDir = mkProject(tmpBase, "rel-project"); + // Pass a path that starts absolute (resolve does nothing but we test the contract) + await registry.add(resolve(projectDir)); + const projects = registry.list(); + expect(projects[0]!.path).toBe(resolve(projectDir)); + }); + + it("add() throws DuplicateProjectError when name already registered", async () => { + const p1 = mkProject(tmpBase, "proj-a"); + const p2 = mkProject(tmpBase, "proj-b"); + + await registry.add(p1, "shared-name"); + await expect(registry.add(p2, "shared-name")).rejects.toBeInstanceOf( + DuplicateProjectError, + ); + }); + + it("DuplicateProjectError for name has field='name'", async () => { + const p1 = mkProject(tmpBase, "p1"); + const p2 = mkProject(tmpBase, "p2"); + await registry.add(p1, "shared"); + + try { + await registry.add(p2, "shared"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(DuplicateProjectError); + expect((err as DuplicateProjectError).field).toBe("name"); + expect((err as DuplicateProjectError).value).toBe("shared"); + } + }); + + it("add() throws DuplicateProjectError when path already registered", async () => { + const p1 = mkProject(tmpBase, "proj-a"); + await registry.add(p1, "name1"); + await expect(registry.add(p1, "name2")).rejects.toBeInstanceOf( + DuplicateProjectError, + ); + }); + + it("DuplicateProjectError for path has field='path'", async () => { + const p1 = mkProject(tmpBase, "p1"); + await registry.add(p1, "first"); + + try { + await registry.add(p1, "second"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(DuplicateProjectError); + expect((err as DuplicateProjectError).field).toBe("path"); + } + }); + + it("add() can register multiple projects", async () => { + const p1 = mkProject(tmpBase, "alpha"); + const p2 = mkProject(tmpBase, "beta"); + const p3 = mkProject(tmpBase, "gamma"); + + await registry.add(p1); + await registry.add(p2); + await registry.add(p3); + + const projects = registry.list(); + expect(projects).toHaveLength(3); + expect(projects.map((p) => p.name)).toEqual(["alpha", "beta", "gamma"]); + }); + + it("add() does not throw when project has no .foreman/ directory (warns only)", async () => { + const bareDir = mkBareProject(tmpBase, "bare-project"); + // Should not throw — just warn to console.error + await expect(registry.add(bareDir)).resolves.not.toThrow(); + const projects = registry.list(); + expect(projects).toHaveLength(1); + expect(projects[0]!.name).toBe("bare-project"); + }); + + // ── remove() ───────────────────────────────────────────────────────────────── + + it("remove() deletes a registered project by name", async () => { + const p1 = mkProject(tmpBase, "alpha"); + const p2 = mkProject(tmpBase, "beta"); + await registry.add(p1); + await registry.add(p2); + + await registry.remove("alpha"); + + const projects = registry.list(); + expect(projects).toHaveLength(1); + expect(projects[0]!.name).toBe("beta"); + }); + + it("remove() throws ProjectNotFoundError when name not in registry", async () => { + await expect(registry.remove("nonexistent")).rejects.toBeInstanceOf( + ProjectNotFoundError, + ); + }); + + it("ProjectNotFoundError contains the queried name", async () => { + try { + await registry.remove("ghost"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ProjectNotFoundError); + expect((err as ProjectNotFoundError).nameOrPath).toBe("ghost"); + } + }); + + it("remove() allows re-adding a project after removing it", async () => { + const p1 = mkProject(tmpBase, "project"); + await registry.add(p1); + await registry.remove("project"); + await expect(registry.add(p1)).resolves.not.toThrow(); + }); + + // ── resolve() ───────────────────────────────────────────────────────────────── + + it("resolve() returns path when looking up by name", async () => { + const p1 = mkProject(tmpBase, "my-app"); + await registry.add(p1, "my-app"); + + const resolved = registry.resolve("my-app"); + expect(resolved).toBe(resolve(p1)); + }); + + it("resolve() returns path when looking up by absolute path", async () => { + const p1 = mkProject(tmpBase, "my-app"); + await registry.add(p1, "my-app"); + + const resolved = registry.resolve(resolve(p1)); + expect(resolved).toBe(resolve(p1)); + }); + + it("resolve() throws ProjectNotFoundError for unknown name", () => { + expect(() => registry.resolve("unknown-project")).toThrow(ProjectNotFoundError); + }); + + it("resolve() throws ProjectNotFoundError for unknown path", () => { + expect(() => registry.resolve("/nonexistent/path")).toThrow(ProjectNotFoundError); + }); + + // ── removeStale() ────────────────────────────────────────────────────────── + + it("removeStale() removes projects with inaccessible directories", async () => { + const live = mkProject(tmpBase, "live-project"); + const ghost = join(tmpBase, "deleted-project"); + // Don't create ghost directory + + await registry.add(live); + // Manually add stale entry to registry + const staleRegistry = new ProjectRegistry(registryFile); + // We add it without creating the dir — need to bypass add() validation + // by writing directly to the file + const raw = JSON.stringify({ + version: 1, + projects: [ + { name: "live-project", path: resolve(live), addedAt: new Date().toISOString() }, + { name: "ghost-project", path: ghost, addedAt: new Date().toISOString() }, + ], + }); + writeFileSync(registryFile, raw, "utf-8"); + + const removed = await registry.removeStale(); + expect(removed).toContain("ghost-project"); + expect(removed).not.toContain("live-project"); + + const remaining = registry.list(); + expect(remaining).toHaveLength(1); + expect(remaining[0]!.name).toBe("live-project"); + }); + + it("removeStale() returns empty array when all projects are accessible", async () => { + const p1 = mkProject(tmpBase, "alpha"); + await registry.add(p1); + + const removed = await registry.removeStale(); + expect(removed).toEqual([]); + }); + + it("removeStale() does not modify registry when nothing is stale", async () => { + const p1 = mkProject(tmpBase, "alpha"); + await registry.add(p1); + + await registry.removeStale(); + + const projects = registry.list(); + expect(projects).toHaveLength(1); + }); + + // ── listStale() ─────────────────────────────────────────────────────────────── + + it("listStale() returns stale projects without removing them", async () => { + const live = mkProject(tmpBase, "live"); + const ghost = join(tmpBase, "ghost"); + + // Write directly including a ghost entry + mkdirSync(join(tmpBase, ".foreman"), { recursive: true }); + writeFileSync( + registryFile, + JSON.stringify({ + version: 1, + projects: [ + { name: "live", path: resolve(live), addedAt: new Date().toISOString() }, + { name: "ghost", path: ghost, addedAt: new Date().toISOString() }, + ], + }), + "utf-8", + ); + + const stale = registry.listStale(); + expect(stale).toHaveLength(1); + expect(stale[0]!.name).toBe("ghost"); + + // Registry is untouched + expect(registry.list()).toHaveLength(2); + }); + + // ── Corrupt registry ────────────────────────────────────────────────────────── + + it("recovers gracefully from a corrupted registry JSON file", async () => { + mkdirSync(join(tmpBase, ".foreman"), { recursive: true }); + writeFileSync(registryFile, "this is not valid JSON!!!!", "utf-8"); + + // Should not throw — should return empty list + const projects = registry.list(); + expect(projects).toEqual([]); + }); + + it("can add projects after recovering from corrupt file", async () => { + mkdirSync(join(tmpBase, ".foreman"), { recursive: true }); + writeFileSync(registryFile, "{ broken json", "utf-8"); + + const p1 = mkProject(tmpBase, "fresh-project"); + await expect(registry.add(p1)).resolves.not.toThrow(); + expect(registry.list()).toHaveLength(1); + }); + + // ── Registry file format ────────────────────────────────────────────────────── + + it("persisted registry file has version=1", async () => { + const p1 = mkProject(tmpBase, "alpha"); + await registry.add(p1); + + const raw = JSON.parse( + readFileSync(registryFile, "utf-8"), + ) as { version: number }; + expect(raw.version).toBe(1); + }); +}); diff --git a/src/lib/project-registry.ts b/src/lib/project-registry.ts new file mode 100644 index 0000000..daa2e15 --- /dev/null +++ b/src/lib/project-registry.ts @@ -0,0 +1,338 @@ +/** + * Global Project Registry for Foreman multi-project orchestration. + * + * Manages a JSON registry at `~/.foreman/projects.json` that maps project + * names to filesystem paths, enabling cross-project operations. + * + * Registry schema: + * ```json + * { + * "version": 1, + * "projects": [ + * { + * "name": "foreman", + * "path": "/Users/user/Development/foreman", + * "addedAt": "2026-03-29T00:00:00Z" + * } + * ] + * } + * ``` + * + * @module src/lib/project-registry + */ + +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + accessSync, + constants as fsConstants, +} from "node:fs"; +import { dirname, resolve, basename, normalize } from "node:path"; +import { homedir } from "node:os"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** A single registered project entry. */ +export interface ProjectEntry { + /** Short human-readable alias for the project. */ + name: string; + /** Absolute filesystem path to the project root. */ + path: string; + /** ISO 8601 timestamp when the project was added to the registry. */ + addedAt: string; +} + +/** Shape of the `~/.foreman/projects.json` file. */ +export interface ProjectRegistryFile { + /** Schema version — currently always `1`. */ + version: number; + /** Ordered list of registered projects. */ + projects: ProjectEntry[]; +} + +// ── Error classes ───────────────────────────────────────────────────────────── + +/** + * Thrown when attempting to add a project that is already registered + * (either by the same name or the same resolved path). + */ +export class DuplicateProjectError extends Error { + constructor( + public readonly field: "name" | "path", + public readonly value: string, + ) { + super( + field === "name" + ? `Project '${value}' is already registered` + : `Path '${value}' is already registered as a project`, + ); + this.name = "DuplicateProjectError"; + } +} + +/** + * Thrown when resolving a project name or path that is not in the registry. + */ +export class ProjectNotFoundError extends Error { + constructor(public readonly nameOrPath: string) { + super(`Project '${nameOrPath}' not found in registry`); + this.name = "ProjectNotFoundError"; + } +} + +// ── Default registry path ───────────────────────────────────────────────────── + +/** + * Returns the default path to the global registry file: `~/.foreman/projects.json`. + */ +function defaultRegistryPath(): string { + return resolve(homedir(), ".foreman", "projects.json"); +} + +// ── ProjectRegistry class ───────────────────────────────────────────────────── + +/** + * Manages the global Foreman project registry stored at `~/.foreman/projects.json`. + * + * All write operations use a read-modify-write pattern for atomicity. + * The registry directory (`~/.foreman/`) is created automatically on first write. + */ +export class ProjectRegistry { + private readonly registryPath: string; + + /** + * @param registryPath - Override the registry file location (default: `~/.foreman/projects.json`). + * Useful for testing with temporary directories. + */ + constructor(registryPath?: string) { + this.registryPath = registryPath ?? defaultRegistryPath(); + } + + // ── Private helpers ────────────────────────────────────────────────────────── + + /** + * Load the registry file from disk. + * Returns an empty registry if the file does not yet exist. + */ + private loadRegistry(): ProjectRegistryFile { + if (!existsSync(this.registryPath)) { + return { version: 1, projects: [] }; + } + + try { + const raw = readFileSync(this.registryPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + return this.validateRegistryFile(parsed); + } catch (err) { + if (err instanceof SyntaxError) { + // Corrupted JSON — start fresh (log warning) + console.error( + `[ProjectRegistry] Warning: registry file is corrupted (${String(err.message)}). Starting fresh.`, + ); + return { version: 1, projects: [] }; + } + throw err; + } + } + + /** + * Validate a raw parsed registry file. + * Returns a well-typed `ProjectRegistryFile` on success. + */ + private validateRegistryFile(raw: unknown): ProjectRegistryFile { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { version: 1, projects: [] }; + } + + const obj = raw as Record; + const version = typeof obj["version"] === "number" ? obj["version"] : 1; + const projectsRaw = Array.isArray(obj["projects"]) ? obj["projects"] : []; + + const projects: ProjectEntry[] = projectsRaw + .filter( + (p): p is Record => + typeof p === "object" && p !== null && !Array.isArray(p), + ) + .filter( + (p) => typeof p["name"] === "string" && typeof p["path"] === "string", + ) + .map((p) => ({ + name: p["name"] as string, + path: p["path"] as string, + addedAt: + typeof p["addedAt"] === "string" ? p["addedAt"] : new Date().toISOString(), + })); + + return { version, projects }; + } + + /** + * Persist the registry to disk. + * Creates the parent directory (`~/.foreman/`) if it does not exist. + */ + private saveRegistry(data: ProjectRegistryFile): void { + const dir = dirname(this.registryPath); + mkdirSync(dir, { recursive: true }); + writeFileSync(this.registryPath, JSON.stringify(data, null, 2) + "\n", "utf-8"); + } + + /** + * Derive a project name from a directory path. + * Uses `basename()` — e.g. `/Users/user/my-project` → `my-project`. + */ + private deriveName(projectPath: string): string { + return basename(normalize(projectPath)); + } + + // ── Public API ─────────────────────────────────────────────────────────────── + + /** + * Register a project in the global registry. + * + * @param projectPath - Absolute or relative path to the project root. + * Will be resolved to an absolute path. + * @param name - Optional alias. If omitted, derived from the directory basename. + * @throws {DuplicateProjectError} If a project with the same name or resolved path already exists. + */ + async add(projectPath: string, name?: string): Promise { + const resolvedPath = resolve(projectPath); + const projectName = name ?? this.deriveName(resolvedPath); + + const registry = this.loadRegistry(); + + // Check for duplicate name + const existingByName = registry.projects.find((p) => p.name === projectName); + if (existingByName !== undefined) { + throw new DuplicateProjectError("name", projectName); + } + + // Check for duplicate path + const existingByPath = registry.projects.find((p) => p.path === resolvedPath); + if (existingByPath !== undefined) { + throw new DuplicateProjectError("path", resolvedPath); + } + + // Warn if no .foreman/ directory (project not yet initialized with foreman) + const foremanDir = resolve(resolvedPath, ".foreman"); + if (!existsSync(foremanDir)) { + console.error( + `[ProjectRegistry] Warning: '${resolvedPath}' has no .foreman/ directory. ` + + `Run 'foreman init' inside the project before using it with foreman.`, + ); + } + + registry.projects.push({ + name: projectName, + path: resolvedPath, + addedAt: new Date().toISOString(), + }); + + this.saveRegistry(registry); + } + + /** + * Return all registered projects. + */ + list(): ProjectEntry[] { + return this.loadRegistry().projects; + } + + /** + * Remove a registered project from the registry by name. + * + * @param name - The project alias to remove. + * @throws {ProjectNotFoundError} If no project with that name is registered. + */ + async remove(name: string): Promise { + const registry = this.loadRegistry(); + + const index = registry.projects.findIndex((p) => p.name === name); + if (index === -1) { + throw new ProjectNotFoundError(name); + } + + registry.projects.splice(index, 1); + this.saveRegistry(registry); + } + + /** + * Resolve a project name or path to an absolute filesystem path. + * + * Resolution order: + * 1. Exact match on `name` + * 2. Exact match on `path` + * + * @param nameOrPath - Registry name or absolute path to resolve. + * @returns The absolute path of the registered project. + * @throws {ProjectNotFoundError} If the name/path is not found in the registry. + */ + resolve(nameOrPath: string): string { + const registry = this.loadRegistry(); + + // Try exact name match first + const byName = registry.projects.find((p) => p.name === nameOrPath); + if (byName !== undefined) { + return byName.path; + } + + // Try exact path match (resolve in case it's relative) + const resolvedInput = resolve(nameOrPath); + const byPath = registry.projects.find((p) => p.path === resolvedInput); + if (byPath !== undefined) { + return byPath.path; + } + + throw new ProjectNotFoundError(nameOrPath); + } + + /** + * Remove all projects whose directories are no longer accessible. + * + * A project is considered stale if its path does not exist or is not readable. + * + * @returns Array of project names that were removed. + */ + async removeStale(): Promise { + const registry = this.loadRegistry(); + + const stale: ProjectEntry[] = []; + const active: ProjectEntry[] = []; + + for (const project of registry.projects) { + if (!this.isAccessible(project.path)) { + stale.push(project); + } else { + active.push(project); + } + } + + if (stale.length > 0) { + registry.projects = active; + this.saveRegistry(registry); + } + + return stale.map((p) => p.name); + } + + /** + * Return all projects whose directories are no longer accessible (without removing them). + */ + listStale(): ProjectEntry[] { + const registry = this.loadRegistry(); + return registry.projects.filter((p) => !this.isAccessible(p.path)); + } + + /** + * Check whether a project path is accessible (exists and is readable). + */ + private isAccessible(projectPath: string): boolean { + try { + accessSync(projectPath, fsConstants.R_OK); + return true; + } catch { + return false; + } + } +}