diff --git a/scripts/agent-worktree.test.ts b/scripts/agent-worktree.test.ts index dab3ac049..a36a798c4 100644 --- a/scripts/agent-worktree.test.ts +++ b/scripts/agent-worktree.test.ts @@ -3,7 +3,7 @@ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, realpathSy import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { assetLinks, canLinkDeps, linkAssets, linkDeps, worktreeTarget } from "./agent-worktree"; +import { assetLinks, canLinkDeps, ensureWorktreeReady, linkAssets, linkDeps, mainCheckoutRoot, worktreeTarget } from "./agent-worktree"; describe("worktreeTarget", () => { test("places worktrees under the main checkout's .claude/worktrees", () => { @@ -71,4 +71,86 @@ describe("linking a worktree to the main checkout", () => { expect(realpathSync(join(target, "apps/dev/public/models/pack"))).toBe(realpathSync(join(main, "apps/dev/public/models/pack"))); expect(linkAssets(main, target, links)).toEqual([]); }); + + test("relinks a dangling Games junction instead of leaving the tree unusable", () => { + mkdirSync(join(main, "Games", "beacon"), { recursive: true }); + mkdirSync(join(target, "Games"), { recursive: true }); + rmSync(join(target, "Games"), { recursive: true, force: true }); + symlinkSync(join(main, "gone-checkout"), join(target, "Games"), "junction"); + expect(linkAssets(main, target, ["Games"])).toEqual(["Games"]); + expect(realpathSync(join(target, "Games"))).toBe(realpathSync(join(main, "Games"))); + }); +}); + +describe("readiness for a worktree nobody linked", () => { + let base: string; + let main: string; + let target: string; + const write = (path: string, body = ""): void => { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, body); + }; + + beforeEach(() => { + base = mkdtempSync(join(tmpdir(), "agent-ready-")); + main = join(base, "main"); + target = join(base, "wt"); + for (const root of [main, target]) { + write(join(root, "bun.lock"), "lock-v1"); + write(join(root, "packages", "core", "package.json"), "{}"); + } + write(join(main, "node_modules", ".bin", "tsgo")); + mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true }); + mkdirSync(join(main, "Games", "beacon", "src"), { recursive: true }); + mkdirSync(join(main, "apps", "dev", "public", "models", "pack"), { recursive: true }); + // `git worktree add` writes `.git` as a file; that is the only marker this needs. + write(join(target, ".git"), `gitdir: ${join(main, ".git", "worktrees", "wt")}\n`); + }); + + afterEach(() => { + rmSync(base, { recursive: true, force: true }); + }); + + const ignored = (): string[] => ["apps/dev/public/models/pack/"]; + + test("finds the main checkout from the .git file and only for linked worktrees", () => { + expect(mainCheckoutRoot(target)).toBe(main); + expect(mainCheckoutRoot(main)).toBeNull(); + write(join(base, "plain", "package.json"), "{}"); + expect(mainCheckoutRoot(join(base, "plain"))).toBeNull(); + }); + + test("a bare worktree gets dependencies and the Games link, and a second call changes nothing", () => { + const first = ensureWorktreeReady(target, { ignored }); + expect(first).toMatchObject({ installed: false, unchanged: false, assets: ["Games", "apps/dev/public/models/pack"] }); + expect(existsSync(join(target, "node_modules", ".bin", "tsgo"))).toBe(true); + expect(realpathSync(join(target, "Games"))).toBe(realpathSync(join(main, "Games"))); + expect(realpathSync(join(target, "apps/dev/public/models/pack"))).toBe(realpathSync(join(main, "apps/dev/public/models/pack"))); + + const second = ensureWorktreeReady(target, { ignored: () => { throw new Error("second call must not list ignored files"); } }); + expect(second).toMatchObject({ unchanged: true, deps: [], assets: [] }); + }); + + test("installs only when the locks differ, and links nothing from the stale store", () => { + writeFileSync(join(target, "bun.lock"), "lock-v2"); + mkdirSync(join(target, "node_modules", ".bin"), { recursive: true }); + writeFileSync(join(target, "node_modules", ".bin", "tsgo"), ""); + rmSync(join(target, "node_modules"), { recursive: true, force: true }); + let installed = 0; + const result = ensureWorktreeReady(target, { + ignored, + install: (dir) => { + installed += 1; + expect(dir).toBe(target); + write(join(target, "node_modules", ".bin", "tsgo")); + }, + }); + expect(installed).toBe(1); + expect(result).toMatchObject({ installed: true, deps: [] }); + expect(realpathSync(join(target, "Games"))).toBe(realpathSync(join(main, "Games"))); + }); + + test("the main checkout itself is left alone", () => { + expect(ensureWorktreeReady(main)).toBeNull(); + }); }); diff --git a/scripts/agent-worktree.ts b/scripts/agent-worktree.ts index f72104372..ff4e40e51 100644 --- a/scripts/agent-worktree.ts +++ b/scripts/agent-worktree.ts @@ -10,8 +10,12 @@ * (otherwise `bun install --frozen-lockfile`), `Games` and the dev runner's * gitignored public assets linked, and a clean `git status`. No build is needed: * tests and type-checks resolve `@jgengine/*` to source (scripts/sourcePaths.ts). + * + * `claude --worktree` and a bare `git worktree add` skip all of that, so + * `ensureWorktreeReady` is the same repair path exposed to any tree through + * `bun run ensure-ready` (which every test/build/smoke script runs). */ -import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, symlinkSync, unlinkSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; export const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/; @@ -108,7 +112,8 @@ export function linkAssets(mainRoot: string, target: string, links: string[]): s const made: string[] = []; for (const rel of links) { const to = join(target, rel); - if (exists(to)) continue; + if (existsSync(to)) continue; + if (exists(to)) unlinkSync(to); // dangling junction from a moved main checkout mkdirSync(dirname(to), { recursive: true }); symlinkSync(realpathSync(join(mainRoot, rel)), to, "junction"); made.push(rel); @@ -116,6 +121,102 @@ export function linkAssets(mainRoot: string, target: string, links: string[]): s return made; } +/** + * Main checkout that owns `cwd`, or null when `cwd` *is* the main checkout. + * A linked worktree has `.git` as a file pointing at `
/.git/worktrees/`, + * so this is one read instead of a git process on every test and build. + */ +export function mainCheckoutRoot(cwd: string): string | null { + const dotGit = join(cwd, ".git"); + let content: string; + try { + if (!lstatSync(dotGit).isFile()) return null; + content = readFileSync(dotGit, "utf8").trim(); + } catch { + return null; + } + const match = /^gitdir:\s*(.+)$/m.exec(content); + if (!match) return null; + const gitdir = resolve(cwd, match[1]!.trim()); + const root = dirname(dirname(dirname(gitdir))); + return existsSync(join(root, ".git")) ? root : null; +} + +export interface WorktreeReadiness { + mainRoot: string; + /** Workspace-relative node_modules dirs linked by this call. */ + deps: string[]; + /** Workspace-relative asset links created by this call. */ + assets: string[]; + /** True when this call ran `bun install` because the locks differ. */ + installed: boolean; + /** True when the tree already had everything: nothing was touched. */ + unchanged: boolean; +} + +export type WorktreeReadyOptions = { + /** Called when dependencies cannot be linked; must install or throw. */ + install?: (target: string) => void; + /** Ignored-file listing used to find dev-runner assets. Defaults to `git ls-files`. */ + ignored?: (mainRoot: string) => string[]; +}; + +function gitIgnored(mainRoot: string): string[] { + const result = Bun.spawnSync( + ["git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "apps"], + { cwd: mainRoot, stdout: "pipe", stderr: "pipe", timeout: 60_000 }, + ); + if (result.exitCode !== 0) return []; + return (result.stdout?.toString() ?? "").split("\n").map((line) => line.trim()).filter(Boolean); +} + +function installed(target: string): boolean { + return ( + existsSync(join(target, "node_modules", ".bin", "tsgo")) || + existsSync(join(target, "node_modules", ".bin", "tsgo.exe")) + ); +} + +/** + * Make any tree usable: dependencies (linked from the main checkout when the + * locks match, else installed) plus the `Games` and dev-runner asset links. + * + * Idempotent and cheap when repeated: the already-ready path costs a few + * `existsSync` calls and no process spawn, so `ensure-ready` can call this on + * every test run. Returns null when `cwd` is the main checkout itself. + */ +export function ensureWorktreeReady(cwd: string, options: WorktreeReadyOptions = {}): WorktreeReadiness | null { + const mainRoot = mainCheckoutRoot(cwd); + if (mainRoot === null || realpathSync(mainRoot) === realpathSync(cwd)) return null; + + const needsDeps = !installed(cwd); + const needsGames = existsSync(join(mainRoot, "Games")) && !existsSync(join(cwd, "Games")); + if (!needsDeps && !needsGames) return { mainRoot, deps: [], assets: [], installed: false, unchanged: true }; + + const result: WorktreeReadiness = { mainRoot, deps: [], assets: [], installed: false, unchanged: false }; + if (needsDeps) { + if (canLinkDeps(mainRoot, cwd)) { + result.deps = linkDeps(mainRoot, cwd); + } else { + (options.install ?? ((target: string) => { + const install = Bun.spawnSync(["bun", "install", "--frozen-lockfile"], { + cwd: target, + stdout: "inherit", + stderr: "inherit", + timeout: 600_000, + }); + if (install.exitCode !== 0) { + throw new Error(`bun install --frozen-lockfile failed (exit ${install.exitCode ?? 1})`); + } + }))(cwd); + result.installed = true; + } + } + const ignored = (options.ignored ?? gitIgnored)(mainRoot); + result.assets = linkAssets(mainRoot, cwd, assetLinks(mainRoot, ignored)); + return result; +} + function git(cwd: string, args: string[], timeoutMs = 120_000): string { const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", timeout: timeoutMs }); const out = (result.stdout?.toString() ?? "") + (result.stderr?.toString() ?? ""); @@ -127,6 +228,7 @@ function git(cwd: string, args: string[], timeoutMs = 120_000): string { } if (import.meta.main) { + const startedAt = Date.now(); const args = process.argv.slice(2).filter((a) => a !== "--"); const name = args[0] ?? ""; const branch = args[1] ?? `agent/${name}`; @@ -145,7 +247,7 @@ if (import.meta.main) { mkdirSync(dirname(target), { recursive: true }); if (existsSync(target)) { - console.log(`agent-worktree: already exists at ${target} — relinking`); + console.log(`agent-worktree: already exists at ${target} — verifying`); } else { const branchExists = Bun.spawnSync(["git", "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: mainRoot }).exitCode === 0; @@ -153,41 +255,25 @@ if (import.meta.main) { git(mainRoot, branchExists ? ["worktree", "add", target, branch] : ["worktree", "add", "-b", branch, target, "origin/beta"], 180_000); } - if (canLinkDeps(mainRoot, target)) { - const linked = linkDeps(mainRoot, target); - console.log(`agent-worktree: linked ${linked.length} node_modules from ${mainRoot}`); - } else { - console.log("agent-worktree: bun.lock differs from the main checkout (or it has no install) — installing"); - const install = Bun.spawnSync(["bun", "install", "--frozen-lockfile"], { - cwd: target, - stdout: "inherit", - stderr: "inherit", - timeout: 600_000, - }); - if (install.exitCode !== 0) { - console.error("agent-worktree: bun install failed — fix, then `cd` there and re-run bun run agent:bootstrap"); - process.exit(install.exitCode ?? 1); - } + const readiness = ensureWorktreeReady(target); + if (readiness === null) { + console.error("agent-worktree: could not resolve the main checkout for this worktree"); + process.exit(1); } - const ignored = git(mainRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "apps"]) - .split("\n") - .filter(Boolean); - const assets = linkAssets(mainRoot, target, assetLinks(mainRoot, ignored)); - if (assets.length > 0) console.log(`agent-worktree: linked ${assets.join(", ")}`); - const dirty = git(target, ["status", "--porcelain"]); if (dirty !== "") { console.error(`agent-worktree: new worktree is not clean — a link is not gitignored:\n${dirty}`); process.exit(1); } - console.log(` -agent-worktree: ready - path: ${target} - branch: ${branch} - -Tests and type-checks run from source with no build. Work with cwd = that path; -do not create another worktree from inside it. -`); + const seconds = ((Date.now() - startedAt) / 1000).toFixed(1); + const did: string[] = []; + if (readiness.installed) did.push("installed dependencies (bun install --frozen-lockfile)"); + if (readiness.deps.length > 0) did.push(`linked ${readiness.deps.length} node_modules from the main checkout`); + if (readiness.assets.length > 0) did.push(`linked ${readiness.assets.join(", ")}`); + console.log( + `agent-worktree: ready in ${seconds}s — ${did.length > 0 ? did.join("; ") : "already set up (deps, Games, assets present; nothing changed)"}`, + ); + console.log(`agent-worktree: work in ${target} on ${branch}; tests run from source, no build needed`); } diff --git a/scripts/ensure-ready.test.ts b/scripts/ensure-ready.test.ts new file mode 100644 index 000000000..9306a9d25 --- /dev/null +++ b/scripts/ensure-ready.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const script = join(import.meta.dir, "ensure-ready.ts"); +const scratch = join(import.meta.dir, "..", ".scratch"); + +function write(path: string, body = ""): void { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, body); +} + +describe("ensure-ready in a worktree nobody linked", () => { + test("links dependencies and Games the way agent:worktree would", () => { + const base = join(scratch, "ensure-ready-fixture"); + rmSync(base, { recursive: true, force: true }); + const main = join(base, "main"); + const worktree = join(base, "wt"); + try { + for (const root of [main, worktree]) { + write(join(root, "bun.lock"), "lock-v1"); + write(join(root, "package.json"), JSON.stringify({ workspaces: ["packages/*"] })); + } + write(join(main, "node_modules", ".bin", "tsgo")); write(join(main, "node_modules", ".bin", "tsgo.exe")); + mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true }); + mkdirSync(join(main, "Games", "beacon", "src"), { recursive: true }); + write(join(worktree, ".git"), `gitdir: ${join(main, ".git", "worktrees", "wt")}\n`); + + const run = Bun.spawnSync([process.execPath, script, "--install-only"], { + cwd: worktree, + stdout: "pipe", + stderr: "pipe", + timeout: 120_000, + }); + expect(run.stderr.toString()).toBe(""); + expect(run.exitCode).toBe(0); + expect(run.stdout.toString()).toContain("Games"); + expect(existsSync(join(worktree, "node_modules", ".bin", "tsgo"))).toBe(true); + expect(realpathSync(join(worktree, "Games"))).toBe(realpathSync(join(main, "Games"))); + + const again = Bun.spawnSync([process.execPath, script, "--install-only"], { + cwd: worktree, + stdout: "pipe", + stderr: "pipe", + timeout: 120_000, + }); + expect(again.exitCode).toBe(0); + expect(again.stdout.toString()).toBe(""); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + test("stays quiet in the main checkout, which needs no links", () => { + const base = join(scratch, "ensure-ready-main-fixture"); + rmSync(base, { recursive: true, force: true }); + try { + write(join(base, "bun.lock"), "lock-v1"); + write(join(base, "package.json"), JSON.stringify({ workspaces: ["packages/*"] })); + mkdirSync(join(base, "node_modules", ".bin"), { recursive: true }); + write(join(base, "node_modules", ".bin", "tsgo")); write(join(base, "node_modules", ".bin", "tsgo.exe")); + const run = Bun.spawnSync([process.execPath, script, "--install-only"], { + cwd: base, + stdout: "pipe", + stderr: "pipe", + timeout: 120_000, + }); + expect(run.exitCode).toBe(0); + expect(run.stdout.toString()).toBe(""); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/ensure-ready.ts b/scripts/ensure-ready.ts index 6dede8523..7fbcb16b4 100644 --- a/scripts/ensure-ready.ts +++ b/scripts/ensure-ready.ts @@ -1,5 +1,6 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { ensureWorktreeReady } from "./agent-worktree"; import { scanStalePackages } from "./distStaleness"; function run(label: string, cmd: string[], timeoutMs: number): void { @@ -19,6 +20,21 @@ function run(label: string, cmd: string[], timeoutMs: number): void { } } +// A tree created by `claude --worktree` or a bare `git worktree add` has no +// node_modules and no Games link; agent:worktree is the only thing that made +// them, so every test/build/smoke run in such a tree failed on missing deps or +// an empty game list. Link from the main checkout (instant) before installing. +const readiness = ensureWorktreeReady(process.cwd(), { + install: () => run("dependencies cannot be linked (bun.lock differs from the main checkout) — running bun install", ["bun", "install"], 300_000), +}); +if (readiness !== null && !readiness.unchanged) { + const did: string[] = []; + if (readiness.installed) did.push("installed dependencies"); + if (readiness.deps.length > 0) did.push(`linked ${readiness.deps.length} node_modules`); + if (readiness.assets.length > 0) did.push(`linked ${readiness.assets.join(", ")}`); + console.log(`ensure-ready: ${did.join("; ")}`); +} + const tsgo = join(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "tsgo.exe" : "tsgo"); if (!existsSync(tsgo)) { run("node_modules incomplete (tsgo missing) — running bun install", ["bun", "install"], 300_000); diff --git a/scripts/shoot-daemon.test.ts b/scripts/shoot-daemon.test.ts index 92b3f8b02..6a292b6c6 100644 --- a/scripts/shoot-daemon.test.ts +++ b/scripts/shoot-daemon.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import { localOk } from "./localhostHttp"; import type { AddressInfo } from "node:net"; @@ -12,13 +12,16 @@ import { daemonDiedUnder, daemonRevisionDrift, daemonStatePath, + describeWorldChange, ensureDaemonTarget, + gamesWorld, isDaemonArgv, readDaemonState, reapStaleDaemonState, stopDaemon, startDaemon, waitForDaemonLive, + worldChanged, writeDaemonState, type ShootDaemonState, } from "./shoot-daemon"; @@ -185,6 +188,87 @@ describe("shoot daemon state file", () => { }); }); +describe("shoot daemon games world", () => { + const fixture = (): { cwd: string; games: (names: string[]) => void } => { + const cwd = mkdtempSync(join(tmpdir(), "jg-shoot-world-")); + const games = (names: string[]): void => { + for (const name of names) mkdirSync(join(cwd, "Games", name, "src"), { recursive: true }); + for (const name of names) writeFileSync(join(cwd, "Games", name, "src", "index.tsx"), ""); + }; + return { cwd, games }; + }; + + test("reports no world when there is no Games checkout", () => { + const { cwd } = fixture(); + try { + expect(gamesWorld(cwd)).toEqual({ target: null, linkMtimeMs: null, head: null, games: [] }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("reports a repointed link and an added game as a change", () => { + const first = fixture(); + const second = fixture(); + try { + first.games(["beacon"]); + second.games(["beacon", "harbor-heat"]); + const before = gamesWorld(first.cwd); + expect(before.games).toEqual(["beacon"]); + expect(worldChanged(undefined, before)).toBe(false); + expect(worldChanged(before, gamesWorld(first.cwd))).toBe(false); + + const after = gamesWorld(second.cwd); + expect(worldChanged(before, after)).toBe(true); + expect(describeWorldChange(before, after)).toContain("+harbor-heat"); + } finally { + rmSync(first.cwd, { recursive: true, force: true }); + rmSync(second.cwd, { recursive: true, force: true }); + } + }); + + test("retires the dev server when its game list went stale, and reuses a fresh one", async () => { + const { cwd, games } = fixture(); + try { + games(["beacon"]); + writeDaemonState( + { + identity: checkoutIdentity(cwd), + chromePort: 9223, + devPort: 4517, + devBase: "http://127.0.0.1:4517", + devWorld: gamesWorld(cwd), + startedAt: new Date().toISOString(), + }, + cwd, + ); + games(["beacon", "harbor-heat"]); + + let sawRetired = false; + await ensureDaemonTarget(readDaemonState(cwd)!, "dev", cwd, async () => { + // The stale server must be gone before anything boots a replacement. + sawRetired = readDaemonState(cwd)?.devBase === undefined; + return { child: null, port: 4517, base: "http://127.0.0.1:4517" }; + }); + expect(sawRetired).toBe(true); + expect(readDaemonState(cwd)?.devWorld?.games).toEqual(["beacon", "harbor-heat"]); + + const recorded = readDaemonState(cwd)!; + const reused = await ensureDaemonTarget(recorded, "dev", cwd, async () => ({ + child: null, + port: 4517, + base: "http://127.0.0.1:4517", + })); + expect(reused.base).toBe("http://127.0.0.1:4517"); + expect(readDaemonState(cwd)?.devBase).toBe("http://127.0.0.1:4517"); + expect(readDaemonState(cwd)?.devWorld).toEqual(gamesWorld(cwd)); + } finally { + clearDaemonState(cwd); + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); + describe("shoot daemon readiness", () => { test("rejects an occupied unowned debugger port without launching or claiming Chrome", async () => { const cwd = mkdtempSync(join(tmpdir(), "jg-shoot-occupied-")); diff --git a/scripts/shoot-daemon.ts b/scripts/shoot-daemon.ts index 44e4979f2..9dd398bd7 100644 --- a/scripts/shoot-daemon.ts +++ b/scripts/shoot-daemon.ts @@ -21,7 +21,9 @@ import { closeSync, existsSync, openSync, + readdirSync, readFileSync, + realpathSync, renameSync, statSync, unlinkSync, @@ -63,9 +65,90 @@ export interface ShootDaemonState { webPort?: number; webBase?: string; webPid?: number; + /** Games world the dev server was booted against; a mismatch means its game list is stale. */ + devWorld?: GamesWorld; + /** Games world the website server was booted against. */ + webWorld?: GamesWorld; startedAt: string; } +/** + * The part of the world a running Vite server froze at boot: its `@games/` + * aliases and game glob are computed once, so a `Games` link created or + * repointed after boot is invisible to it. + */ +export interface GamesWorld { + /** Resolved path of the games checkout, or null when there is no `Games`. */ + target: string | null; + /** `Games` entry mtime: changes when the link is created or repointed. */ + linkMtimeMs: number | null; + /** Games checkout HEAD, read from its `.git/HEAD` file rather than a git process. */ + head: string | null; + /** Game ids the runner can resolve. */ + games: string[]; +} + +function readHead(checkout: string): string | null { + try { + const head = readFileSync(join(checkout, ".git", "HEAD"), "utf8").trim(); + if (!head.startsWith("ref:")) return head; + return readFileSync(join(checkout, ".git", head.slice(4).trim()), "utf8").trim(); + } catch { + return null; + } +} + +/** + * Cheap fingerprint of the games world: one realpath, one lstat, one readdir and + * one HEAD read (no git process, no game source scan), so it can run on every + * shot without costing a rescan. + */ +export function gamesWorld(cwd = process.cwd()): GamesWorld { + const entry = join(cwd, "Games"); + let linkMtimeMs: number | null = null; + try { + linkMtimeMs = statSync(entry).mtimeMs; + } catch { + return { target: null, linkMtimeMs: null, head: null, games: [] }; + } + let target: string; + try { + target = realpathSync(entry); + } catch { + return { target: null, linkMtimeMs, head: null, games: [] }; + } + const games: string[] = []; + try { + for (const item of readdirSync(target, { withFileTypes: true })) { + if (item.isDirectory() && existsSync(join(target, item.name, "src", "index.tsx"))) games.push(item.name); + } + } catch { + /* unreadable checkout: an empty list is the honest answer */ + } + games.sort(); + return { target, linkMtimeMs, head: readHead(target), games }; +} + +export function worldChanged(previous: GamesWorld | undefined, current: GamesWorld): boolean { + if (previous === undefined) return false; + if (previous.target !== current.target) return true; + if (previous.head !== current.head) return true; + if (previous.games.length !== current.games.length) return true; + return previous.games.some((game, index) => game !== current.games[index]); +} + +/** One line naming what moved, so a restart is never a mystery. */ +export function describeWorldChange(previous: GamesWorld, current: GamesWorld): string { + const parts: string[] = []; + if (previous.target !== current.target) parts.push(`Games ${previous.target ?? "(none)"} → ${current.target ?? "(none)"}`); + if (previous.head !== current.head) parts.push(`games HEAD ${previous.head?.slice(0, 8) ?? "(none)"} → ${current.head?.slice(0, 8) ?? "(none)"}`); + const added = current.games.filter((game) => !previous.games.includes(game)); + const removed = previous.games.filter((game) => !current.games.includes(game)); + if (added.length > 0) parts.push(`+${added.join(", +")}`); + if (removed.length > 0) parts.push(`-${removed.join(", -")}`); + return parts.length > 0 ? parts.join("; ") : "game list changed"; +} + type DaemonStartWaitOptions = { pid?: number; timeoutMs?: number; @@ -298,6 +381,33 @@ export async function ensureDaemonTarget( cwd = process.cwd(), ensure: (cwd: string) => Promise = target === "web" ? ensureWebServer : ensureDevServer, ): Promise { + // A warm Vite server froze its `@games/` aliases and game glob at boot, so + // a `Games` link created or repointed afterwards leaves it rejecting a game + // that exists on disk. Retire that server before the call, not on a timer and + // not after a failed capture. + const observed = gamesWorld(cwd); + await withDaemonStateLock(cwd, () => { + const latest = readDaemonState(cwd); + if (latest === null) throw new Error("shoot daemon: stopped while starting Vite target"); + if (latest.identity !== state.identity) throw new Error(`shoot daemon: state belongs to ${latest.identity}`); + Object.assign(state, latest); + const previous = target === "web" ? state.webWorld : state.devWorld; + if (!worldChanged(previous, observed)) return; + const pid = target === "web" ? state.webPid : state.devPid; + if (pid !== undefined && pidAlive(pid)) killPid(pid, true); + if (target === "web") { + state.webPort = undefined; + state.webBase = undefined; + state.webPid = undefined; + } else { + state.devPort = undefined; + state.devBase = undefined; + state.devPid = undefined; + } + writeDaemonState(state, cwd); + console.error(`shoot daemon: ${describeWorldChange(previous!, observed)} — restarting the ${target} server against it`); + }); + const server = await ensure(cwd); try { await withDaemonStateLock(cwd, () => { @@ -309,10 +419,12 @@ export async function ensureDaemonTarget( state.webPort = server.port; state.webBase = server.base; state.webPid = server.pid ?? server.child?.pid ?? state.webPid; + state.webWorld = observed; } else { state.devPort = server.port; state.devBase = server.base; state.devPid = server.pid ?? server.child?.pid ?? state.devPid; + state.devWorld = observed; } writeDaemonState(state, cwd); });