From 5a9035eed696d5e282004a892e8aaef3e026eb48 Mon Sep 17 00:00:00 2001 From: theDakshJaitly Date: Fri, 9 Oct 2026 22:36:30 +0530 Subject: [PATCH 1/8] feat(setup): overhaul terminal setup and failure reporting Add a keyboard-driven setup HUD with native agent handoff, real graph progress, explicit outcomes, and Hub completion. Preserve actionable browser-safe setup errors and bounded local worker diagnostics. Create browser transcripts only when agent output exists, keeping commit-only checks free of empty session panels. Include focused setup, graph, Hub, and real PTY regression coverage and updated setup documentation. --- .mex/ROUTER.md | 30 +- .mex/context/setup.md | 5 +- .mex/patterns/dogfood-mex-setup.md | 47 +- .mex/patterns/secure-local-project-hub.md | 17 +- README.md | 8 +- packages/hub-web/src/app/setup-page.test.tsx | 41 ++ src/cli.ts | 12 +- src/graph/__tests__/candidate-process.test.ts | 102 ++- src/graph/candidate-entry.ts | 20 +- src/graph/candidate-process.ts | 157 ++++- src/hub/__tests__/setup-command.test.ts | 133 ++++ src/hub/command.ts | 37 +- .../setup/__tests__/expected-errors.test.ts | 102 +++ src/hub/setup/__tests__/runner.test.ts | 179 +++++- src/hub/setup/runner.ts | 31 +- src/setup/__tests__/agent-errors.test.ts | 145 +++++ .../__tests__/finalization-errors.test.ts | 71 +++ .../graph-progress-integration.test.ts | 72 +++ src/setup/__tests__/graph-progress.test.ts | 29 + src/setup/__tests__/headless.test.ts | 43 ++ src/setup/__tests__/ignore.test.ts | 162 ++++- .../__tests__/interactive-population.test.ts | 298 +++++++++ .../__tests__/maintenance-errors.test.ts | 153 +++++ src/setup/__tests__/phases-errors.test.ts | 109 ++++ .../__tests__/terminal-ui-lifecycle.test.ts | 77 +++ src/setup/__tests__/terminal-ui.test.ts | 383 ++++++++++++ src/setup/__tests__/terminal.test.ts | 70 +++ src/setup/__tests__/wiki-finalize.test.ts | 11 + src/setup/agent-errors.ts | 42 ++ src/setup/errors.ts | 54 ++ src/setup/graph-progress.ts | 28 + src/setup/headless.ts | 81 ++- src/setup/ignore.ts | 48 +- src/setup/index.ts | 590 ++---------------- src/setup/interactive-population.ts | 273 ++++++++ src/setup/maintenance-errors.ts | 157 +++++ src/setup/phases.ts | 48 +- src/setup/population.ts | 2 +- src/setup/terminal-ui.ts | 448 +++++++++++++ src/setup/terminal.ts | 300 +++++++++ src/setup/wiki-finalize.ts | 14 +- test/helpers/setup-pty.py | 158 +++++ test/hub-e2e/setup-commit.spec.ts | 55 +- test/setup-cli-routing.test.ts | 64 +- test/setup-terminal-pty.test.ts | 401 ++++++++++++ 45 files changed, 4644 insertions(+), 663 deletions(-) create mode 100644 src/hub/setup/__tests__/expected-errors.test.ts create mode 100644 src/setup/__tests__/agent-errors.test.ts create mode 100644 src/setup/__tests__/finalization-errors.test.ts create mode 100644 src/setup/__tests__/graph-progress-integration.test.ts create mode 100644 src/setup/__tests__/graph-progress.test.ts create mode 100644 src/setup/__tests__/interactive-population.test.ts create mode 100644 src/setup/__tests__/maintenance-errors.test.ts create mode 100644 src/setup/__tests__/phases-errors.test.ts create mode 100644 src/setup/__tests__/terminal-ui-lifecycle.test.ts create mode 100644 src/setup/__tests__/terminal-ui.test.ts create mode 100644 src/setup/__tests__/terminal.test.ts create mode 100644 src/setup/agent-errors.ts create mode 100644 src/setup/errors.ts create mode 100644 src/setup/graph-progress.ts create mode 100644 src/setup/interactive-population.ts create mode 100644 src/setup/maintenance-errors.ts create mode 100644 src/setup/terminal-ui.ts create mode 100644 src/setup/terminal.ts create mode 100644 test/helpers/setup-pty.py create mode 100644 test/setup-terminal-pty.test.ts diff --git a/.mex/ROUTER.md b/.mex/ROUTER.md index ae9a1bb3..903b2e1d 100644 --- a/.mex/ROUTER.md +++ b/.mex/ROUTER.md @@ -18,7 +18,7 @@ edges: condition: when refreshing the release README, badges, community links, or architecture illustrations - target: patterns/hub-first-run-onboarding.md condition: when adding or changing the Hub first-run tour or its checkout-local completion state -last_updated: 2026-10-08 +last_updated: 2026-10-09 --- # Session Bootstrap @@ -30,6 +30,24 @@ Then read this file fully before doing anything else in this session. ## Current Project State **Working:** +- Local terminal setup work replaces the old prompts with an ASCII MEX HUD, + integration selection before writes, native Claude/Codex terminal handoff, + and explicit complete/paused/failed/cancelled outcomes. Enter selects the + highlighted tool and starts; Space optionally toggles extra integrations, + and a separate Continue row supports the current selection or no agent. + It reuses the shared + setup engine and opens the setup-enabled Hub after validation for commit + review and optional completion details. Scripts and CI remain plain and + never launch an interactive agent or long-lived Hub. Long graph steps now + forward actual phase/file counts into an animated HUD with phase/quiet timers; + failures retain bounded local worker diagnostics and authored recovery advice. + This is working-tree + implementation, not a published release. +- Local setup error-reporting work preserves authored recovery instructions + for expected config, ignore-protection, integration, Graph, grounding, and + Wiki failures in the browser. Raw diagnostics remain terminal-only, and + unexpected exceptions retain the generic fallback. This working-tree change + leaves the setup completion rules and private response schema unchanged. - Local work on `fix/knowledge-drift-precision` adds `mex wiki link-sections`: preview or explicitly apply missing section-to-parent `refines` relationships without changing claim text or groundings. Setup/GROW guidance preserves those @@ -375,6 +393,16 @@ Then read this file fully before doing anything else in this session. - Public package-root exports for the provisional team contracts. **Known Issues:** +- A disposable MEX copy with installed dependencies reproduced Node heap + exhaustion during graph construction (924 sources, last event 865/924, + about 101 seconds, explicit V8 heap-limit stderr). The source-only copy + completed; a tiny fresh repository also passes. A compiler-only probe also + exhausted the heap during signature rendering, before graph persistence; + this does not establish a persistent leak or one uniquely causal declaration. + Setup now exposes that cause, + but the underlying compiler memory pressure is not fixed by HUD animation + or process isolation. Investigate it without changing canonical signatures + or increasing production heap/resource budgets merely to pass the example. - Graph construction still rebuilds the full eligible corpus after source changes and has no peak-memory quota. The branch's actual Hub probe peaked at about 1,963 MiB combined RSS and retained multi-second pauses around initial diff --git a/.mex/context/setup.md b/.mex/context/setup.md index c5f65212..c6f243f2 100644 --- a/.mex/context/setup.md +++ b/.mex/context/setup.md @@ -20,7 +20,7 @@ edges: # Ground only setup behavior implemented by specific code symbols. # Entry shape: { node: "function:", fingerprint: "mh2:64:" } grounds_to: [] -last_updated: "2026-10-08" +last_updated: "2026-10-09" mex: id: mx_01M1M0CJGBPPFPWHY980PMTS2T type: guide @@ -77,7 +77,7 @@ relations: 2. `npm run typecheck` 3. `npm test` 4. `npm run build` -5. For exact-checkout browser setup, run `node dist/cli.js setup`. Use `node dist/cli.js setup --cli` for terminal dogfooding; rerun that terminal command after manual population to finalize grounding and Wiki readiness. +5. For exact-checkout browser setup, run `node dist/cli.js setup`. Use `node dist/cli.js setup --cli` for the terminal HUD. It hands the terminal to a selected native Claude/Codex session, restores the HUD, checks population, then finalizes grounding and Wiki. Manual population offers Check populated files or a resumable exit. `--yes`, pipes, and CI use plain output with no agent or Hub launch; exit 2 means population is still required. 6. Review the canonical scaffold and selected agent files in setup, then explicitly commit them through the Hub action or your Git client. Keep generated databases and `.mex/local/` ignored. The completion guide precedes **Open Hub** and offers optional version-pinned global install and contact details. 7. Bare `node dist/cli.js` opens the Hub or setup; `node dist/cli.js hub` remains supported. Verify any installed global CLI matches the checkout. `mex tui` retains the terminal dashboard. @@ -135,6 +135,7 @@ relations: - **`node:sqlite` is unavailable:** confirm `node --version` is at least 22.5; older Node releases cannot run MEX 0.8.x. - **Graph reports migration/rebuild required after an upgrade:** run the exact checkout CLI with `node dist/cli.js graph rebuild`, or rerun setup; do not edit SQLite files manually. +- **Graph setup appears idle:** the terminal HUD shows real worker phases/file counts, phase elapsed time, and time since the last event. Its spinner shows the interface is active, not that the worker has reported more progress. Press `d` for details; worker failures retain bounded terminal diagnostics, including confirmed Node heap exhaustion. - **Wiki reports migration required:** finish scaffold population and continue browser setup or rerun `node dist/cli.js setup --cli` so migration, index rebuild, and validation happen in the supported order. - **Tests become flaky while a build runs:** do not run the full test suite concurrently with tsup/Vite cleanup of generated output. - **Graph status is degraded:** inspect parse health and narrow graph queries; partial structural parsing is usable evidence, not permission to invent missing facts. diff --git a/.mex/patterns/dogfood-mex-setup.md b/.mex/patterns/dogfood-mex-setup.md index 02bca748..e0045140 100644 --- a/.mex/patterns/dogfood-mex-setup.md +++ b/.mex/patterns/dogfood-mex-setup.md @@ -13,7 +13,7 @@ edges: condition: "when distinguishing canonical artifacts from local projections" - target: "patterns/release-performance-gate.md" condition: "when the change also affects packed-install or release gates" -last_updated: 2026-09-13 +last_updated: 2026-10-09 mex: id: mx_01M1M0CJJD2AQZ6XKHV4VKYTGJ type: pattern @@ -66,11 +66,46 @@ global `mex`. - From 0.8.2, unflagged `setup` opens the browser. Terminal drivers and scripts must pass `--cli`; `--dry-run` remains a read-only terminal preview. +- Interactive terminal setup uses the shared ordered engine through a HUD. + Enter on a tool selects it and starts setup atomically; that focused tool + precedes other checked tools when selecting a native agent. Space optionally + toggles extra integrations. A separate Continue row preserves the checked + choices, including the deliberate no-agent path. Test fresh setup without + injected `--tool` flags so highlighted-but-unchecked choices are exercised. + Suspend and completely unmount the HUD before inherited-stdio population, + then restore it and inspect the files. Agent exit zero alone is not readiness. + Keep signals and descendant cleanup separate from headless process groups: + native agents share MEX's foreground terminal and must not be detached. + Ink unmount restores cooked mode and unrefs stdin but does not pause its + underlying reader: explicitly pause stdin before handing it to the child, + and ref it before remounting Ink or opening the manual readline question. + Otherwise the parent can consume the child's input or exit while awaiting + a manual answer. Real PTY tests cover this boundary. +- Forward graph maintenance updates separately from ordered setup phases: + repeated parse events must not mark the graph complete or restart its timer. + Show real phase/count updates, animate only during active work, and distinguish + elapsed time from time since the last worker event. Stop animation on unmount + so the native agent retains the terminal without stray HUD redraws. +- Preserve bounded worker stderr locally; discarded stderr hides Node heap + exhaustion behind a generic worker failure. Retain exit/signal, elapsed time, + and last progress; project only authored recovery text to Hub. A signal alone + is not evidence of memory exhaustion. Fresh tiny fixtures verify the path but + do not represent MEX with its installed dependency graph. +- After terminal validation, start the setup-enabled Hub in the foreground for + commit review and completion. `--no-open` suppresses browser launch only. + Scripted/CI/`--yes` runs never start an agent or long-lived Hub; exit 2 is the + resumable population checkpoint, 1 failure, and 130/143 cancellation. - Test completion without a real global installation or contact submission. Isolate `MEX_HOME` in disposable fixtures and inject the npm/form transport. Successful contact delivery stores only a submitted marker; skipping stores only a skipped marker. Neither contains contact data or analytics identifiers. - Build before browser testing, and do not rebuild a running Hub's asset tree. + The same applies to a running setup CLI, including time spent in its native + agent session: tsup cleans old hashed chunks, and the late Hub import can + fail when that session returns. Exit the old process completely before + rebuilding. If this happened after finalization, start the current CLI with + `setup` (without `--cli`) to open the finishing page; Retry in the old HUD + cannot replace its cached module imports. `test/cli.test.ts` also builds production assets in its setup hook; run the packaged browser scenarios after that suite so manifests cannot outlive files. @@ -78,13 +113,17 @@ global `mex`. use the built checkout when validating uncommitted setup changes. - Setup copies missing scaffold files but does not overwrite substantive files. Population prompts must merge at section granularity and preserve managed blocks. -- A non-interactive run without Claude Code or Codex may pause after printing the - population prompt. That is a resumable checkpoint, not a failed setup. +- A non-interactive run pauses after printing the population prompt when + placeholders remain, even if an agent CLI is installed. It never waits on an + interactive session. Test the shipped interactive CLI with the POSIX PTY + harness and fake agents; the Python harness is skipped on Windows. - Drift staleness uses committed Git history. Correct uncommitted content can remain stale until the canonical scaffold is committed. - A Graph with partial parses can support scoped evidence while exact reads abstain. Do not weaken freshness or provenance checks to force an answer. -- MEX does not stage, commit, pull, or push repository changes. +- Terminal setup does not stage or commit. The setup Hub may create a local + commit only after exact diff review and explicit user action, preserving + unrelated staged work. MEX never pulls or pushes. ## Verify diff --git a/.mex/patterns/secure-local-project-hub.md b/.mex/patterns/secure-local-project-hub.md index b2f4412c..12cb5de4 100644 --- a/.mex/patterns/secure-local-project-hub.md +++ b/.mex/patterns/secure-local-project-hub.md @@ -11,12 +11,12 @@ edges: condition: "when persisting a Hub job or migrating team.db" - target: "context/architecture.md" condition: "when wiring a real Graph or Wiki adapter" -last_updated: 2026-09-11 +last_updated: 2026-10-09 mex: id: mx_01M1M0CJQ2BSV71G1C7TXZD9RH type: pattern status: promoted - revision: 10 + revision: 11 title: secure-local-project-hub grounds_to: - node: function:188820af31f0d74c6518f6926f559877 @@ -76,6 +76,13 @@ preview/apply services. ## Gotchas +- Setup failures need a browser-safe explanation at the producer. Use the + internal `SetupError.userMessage` contract for expected failures; retain raw + filesystem, Git, worker, and database diagnostics in `message`/`cause` only. + Classify maintenance codes and use registry-owned Wiki remediation rather + than forwarding exception text. Keep both run error fields within 512 + characters, preserve cancellation precedence, and test the real runner's + failure and retry path as well as mocked error projection. - WHATWG URL construction normalizes traversal and backslashes; native request targets need their own pre-normalization gate. - A HEAD request may be routed through a GET handler. Reject it before reserving @@ -154,6 +161,12 @@ preview/apply services. carry no output backlog. Never put the transcript into repeated run snapshots. Browser retention is independently bounded, with a limited text/row window, literal selectable text, stable scrollback, and explicit Follow latest. +- Allocate a setup transcript identity only after the first retained agent + output, then announce it on the run snapshot. Starting or checking setup can + skip the agent entirely; commit checks, manual population, and finalization + must not fabricate an empty session. Show real startup activity until output + arrives, and retain actual session history through finalization and commit + review. Verify both the absent-session path and replay after a page refresh. - Cap individual provider records, tool correlation, and SSE cadence; discard malformed/oversized records and require recognized terminal success. Claude partial/final text, Codex cumulative assistant text, and tool lifecycle diff --git a/README.md b/README.md index 82031eca..248e1f1d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,12 @@ Prefer terminal setup or working over SSH? npx mex-agent@0.8.3 setup --cli ``` +Terminal setup opens a keyboard-driven HUD with an ASCII MEX banner. Highlight a tool with the arrow keys and press Enter to select it and begin. Space optionally selects additional integrations; the Continue row starts with your checked choices or without an agent. Long steps show an animated activity indicator, elapsed time, and the latest reported work; graph parsing also shows real file counts. When a selected Claude Code or Codex CLI is available, its normal interactive session takes over the terminal; exit that session to return to MEX. MEX checks the scaffold before continuing. Incomplete work can be reopened, populated manually, or left for later. + +After validation, the terminal starts the setup Hub for commit review and the optional completion steps. Keep that terminal running while using the Hub; press `q` or Ctrl+C to stop it. Use `--cli --no-open` to show the local link without opening a browser, and `--port ` to choose its port. Press `d` in the HUD to read longer status or error details. + +For scripts, use `--yes` with repeatable `--tool ` flags (or `--tool none`). Piped input/output and CI also use plain output. These runs reuse saved choices, default to no integrations when none are saved, and never launch an agent or browser or wait for input. Exit codes are `0` for completed preparation or a successful dry run, `2` when population is still required, `1` for failure, and `130`/`143` for interruption. After a population checkpoint, rerun `mex setup --cli` to continue. + `mex setup --dry-run` remains a terminal preview without changes. Once installed, bare `mex` opens the Hub (or setup for an incomplete project), while `mex tui` opens the terminal dashboard. ![Three steps to a ready project: run setup, populate memory, then review and commit the checkpoint before opening Hub.](docs/diagrams/readme/setup.svg) @@ -148,7 +154,7 @@ mex setup The npm package is named `mex-agent`; the installed command is `mex`. Complete the review and commit checkpoint above before running `mex hub`. -Both the Hub and terminal completion flows offer an optional global installation pinned to the running MEX version. If it fails, setup remains complete and you can retry the command above. Open a new terminal afterward and run `mex --version`. +The Hub completion page, also opened after interactive terminal setup, offers an optional global installation pinned to the running MEX version. If it fails, setup remains complete and you can retry the command above. Open a new terminal afterward and run `mex --version`. diff --git a/packages/hub-web/src/app/setup-page.test.tsx b/packages/hub-web/src/app/setup-page.test.tsx index c1d976bc..5b8da333 100644 --- a/packages/hub-web/src/app/setup-page.test.tsx +++ b/packages/hub-web/src/app/setup-page.test.tsx @@ -280,6 +280,47 @@ describe("Hub setup wizard", () => { expect(screen.getByRole("button", { name: "Resume setup" })).toBeEnabled(); }); + it("switches from startup activity to the first real transcript and keeps it through the commit checkpoint and refresh", async () => { + const at = new Date().toISOString(); + const transcriptId = "a944e8d9-7e02-4d04-9a62-d8b347b8e7dc"; + const harness = setupHarness({ hasScaffold: true }, { + status: "running", populationTool: "codex", selectedTools: ["codex"], + progress: { step: "population", label: "Populating MEX" }, + populationActivity: { tool: "codex", startedAt: at, lastActivityAt: null, totalEvents: 1, + events: [{ id: 1, at, kind: "starting", state: "running" }] }, + }); + const view = renderSetup(harness.api); + expect(await screen.findByRole("list", { name: "Recent agent activity" })).toHaveTextContent("Starting the background session"); + expect(screen.queryByRole("region", { name: "Background session output" })).toBeNull(); + expect(harness.api.subscribeToSetupTranscript).not.toHaveBeenCalled(); + await waitForSubscription(harness, 1); + + act(() => harness.snapshot({ transcriptId })); + await waitFor(() => expect(harness.api.subscribeToSetupTranscript).toHaveBeenCalledOnce()); + const batch: SetupTranscriptBatch = { + runId: transcriptId, cursor: 1, firstId: 1, truncated: false, done: false, + entries: [{ id: 1, at, kind: "assistant", text: "I have updated the project memory.", truncated: false }], + }; + act(() => harness.sendTranscript(batch)); + expect(screen.getByText("I have updated the project memory.")).toBeVisible(); + expect(screen.queryByRole("list", { name: "Recent agent activity" })).toBeNull(); + + act(() => harness.snapshot({ progress: { step: "finalize", label: "Finalize MEX" } })); + await waitFor(() => expect(screen.getByText("I have updated the project memory.")).toBeVisible()); + act(() => harness.sendTranscript({ ...batch, done: true })); + act(() => harness.complete({ status: "paused", stage: "needs_commit", populated: true, commitCommands }, { + stage: "needs_commit", populated: true, commitCommands, + })); + expect(await screen.findByRole("button", { name: "Check commit and continue" })).toBeVisible(); + expect(screen.getByText("I have updated the project memory.")).toBeVisible(); + expect(screen.getByText("Session ended")).toBeVisible(); + + view.unmount(); + renderSetup(harness.api); + await waitFor(() => expect(screen.getByText("I have updated the project memory.")).toBeVisible()); + expect(screen.queryByText("This session produced no output.")).toBeNull(); + }); + it.each(["claude", "codex"] as const)("restores %s population activity on refresh and accepts real SSE updates without a percentage", async (tool) => { // Keep async bootstrap/render duration out of the elapsed-time assertion. vi.spyOn(Date, "now").mockReturnValue(Date.now()); diff --git a/src/cli.ts b/src/cli.ts index c71e235b..8bb28814 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -310,25 +310,29 @@ program .option("--dry-run", "Show what would happen without making changes") .option("--port ", "Bind a specific loopback port", parsePortArg) .option("--no-open", "Print the setup link without opening a browser") - .option("--tool ", "Terminal setup: use this AI tool instead of asking (repeatable; none for no tool)", (value: string, previous: string[] = []) => [...previous, value]) + .option("--tool ", "Terminal setup: preselect an AI tool (repeatable; none for no tool; --yes skips review)", (value: string, previous: string[] = []) => [...previous, value]) .option("--yes", "Terminal setup: never wait on a question (for scripts and CI)") .action(async (_opts, command: Command) => { const opts = command.optsWithGlobals<{ cli?: boolean; dryRun?: boolean; mode?: string; port?: number; open: boolean; tool?: string[]; yes?: boolean }>(); try { if (opts.cli || opts.dryRun || opts.tool !== undefined || opts.yes) { - if (opts.port !== undefined || opts.open === false) throw new Error("--port and --no-open apply to browser setup. Omit them with --cli or --dry-run."); const setup = await import("./setup/index.js"); - await setup.runSetup({ + const result = await setup.runSetup({ dryRun: opts.dryRun, mode: opts.mode, + port: opts.port, + openBrowser: opts.open, ...(opts.tool === undefined ? {} : { tools: setup.parseSetupTools(opts.tool) }), ...(opts.yes ? { yes: true } : {}), }); + process.exitCode = result.exitCode; } else { await runBrowserCommand({ ...opts, setup: true }); } } catch (err) { - console.error((err as Error).message); + const { SetupError } = await import("./setup/errors.js"); + console.error(err instanceof SetupError ? err.userMessage : (err as Error).message); + if (err instanceof SetupError && err.message !== err.userMessage) console.error(`Diagnostics: ${err.message}`); process.exitCode = 1; return; } diff --git a/src/graph/__tests__/candidate-process.test.ts b/src/graph/__tests__/candidate-process.test.ts index 5125182a..a348f2e1 100644 --- a/src/graph/__tests__/candidate-process.test.ts +++ b/src/graph/__tests__/candidate-process.test.ts @@ -8,7 +8,7 @@ import { build } from "esbuild"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { boundedCandidateMessage } from "../candidate-protocol.js"; import { createGraphCandidateProgressSender } from "../candidate-progress.js"; -import { runGraphCandidateProcess } from "../candidate-process.js"; +import { GRAPH_CANDIDATE_DIAGNOSTIC_BYTES, GraphCandidateProcessError, runGraphCandidateProcess } from "../candidate-process.js"; import { GRAPH_CORPUS_LIMITS } from "../corpus-policy.js"; import { createGraphEngine } from "../engine-impl.js"; import { rebuildGraph, refreshGraph, type GraphMaintenanceExecutionOptions } from "../maintenance.js"; @@ -24,6 +24,12 @@ let crashEntry: string; let lateEntry: string; let disconnectedEntry: string; let parentEntry: string; +let startupCrashEntry: string; +let noReadyEntry: string; +let malformedEntry: string; +let diagnosticEntry: string; +let heapLimitEntry: string; +let reportedHeapTextEntry: string; function root(): string { const path = mkdtempSync(join(tmpdir(), "mex-candidate-process-test-")); @@ -93,6 +99,25 @@ beforeAll(async () => { __internal: { entrypoint: process.argv[4], onSpawn: (pid, workspace) => process.send({ type: "spawned", pid, workspace }) }, }); `); + startupCrashEntry = await bundle("startup-crash", ` + process.stderr.write("Error: missing private runtime dependency\\n"); + process.exit(7); + `); + noReadyEntry = await bundle("no-ready", 'setInterval(() => {}, 1000);'); + malformedEntry = await bundle("malformed", 'process.send({ type: "progress", progress: { phase: "parse", source: "private" } }); setInterval(() => {}, 1000);'); + diagnosticEntry = await bundle("diagnostic", prefix + ` + process.send({ type: "progress", progress: { phase: "parse", completed: 1, total: 2 } }); + process.stderr.write("\\x1b[31mPRIVATE_START\\x1b[0m\\x1b]0;INJECTED_TITLE\\x07\\n" + "x".repeat(256 * 1024) + "\\nPRIVATE_END\\n", () => { + process.send({ type: "failed", category: "failed" }); + }); + ` + suffix); + heapLimitEntry = await bundle("heap-limit", prefix + ` + process.send({ type: "progress", progress: { phase: "parse", completed: 1, total: 2 } }); + process.stderr.write("x".repeat(10 * 1024) + "\\nFATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\\n" + "y".repeat(10 * 1024), () => process.exit(134)); + ` + suffix); + reportedHeapTextEntry = await bundle("reported-heap-text", prefix + ` + process.stderr.write("FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\\n", () => process.send({ type: "failed", category: "failed" })); + ` + suffix); }, 30_000); afterEach(() => { @@ -222,7 +247,7 @@ describe("isolated graph candidate construction", () => { let workspace = ""; await expect(refreshGraph(path, isolated(crashEntry, { onSpawn: (_pid: number, directory: string) => { workspace = directory; }, - }))).rejects.toMatchObject({ category: "failed" }); + }))).rejects.toMatchObject({ category: "failed", diagnostic: { reason: "exit", signal: "SIGKILL" } }); expect(existsSync(workspace)).toBe(false); expect(hash(database)).toBe(before); expect(artifacts(path)).toEqual([]); @@ -303,7 +328,7 @@ describe("isolated graph candidate construction", () => { await expect(runGraphCandidateProcess({ projectRoot: path, candidatePath: join(path, ".mex", `graph.db.candidate-${"b".repeat(48)}`), operation: "rebuild", __internal: { entrypoint: busyEntry, buildTimeoutMs: 500, onSpawn: (value) => { pid = value; } }, - })).rejects.toMatchObject({ category: "failed" }); + })).rejects.toMatchObject({ category: "failed", diagnostic: { reason: "build-timeout" } }); expect(processAlive(pid)).toBe(false); }); @@ -345,6 +370,77 @@ describe("isolated graph candidate construction", () => { expect(boundedCandidateMessage({ type: "failed", category: "x".repeat(1024 * 1024) })).toBeNull(); }); + it("reports a worker startup crash with its exit code and bounded local stderr", async () => { + const path = root(); + const error = await runGraphCandidateProcess({ + projectRoot: path, candidatePath: join(path, ".mex", `graph.db.candidate-${"d".repeat(48)}`), operation: "rebuild", + __internal: { entrypoint: startupCrashEntry }, + }).catch((error: unknown) => error); + expect(error).toBeInstanceOf(GraphCandidateProcessError); + expect(error).toMatchObject({ category: "failed", diagnostic: { reason: "exit", exitCode: 7, signal: null, + elapsedMs: expect.any(Number), stderr: "Error: missing private runtime dependency" } }); + expect((error as Error).message).toContain("exit code 7"); + expect((error as Error).message).toContain("missing private runtime dependency"); + }); + + it.each([ + ["startup deadline", () => noReadyEntry, "startup-timeout"], + ["invalid protocol", () => malformedEntry, "protocol"], + ] as const)("distinguishes a %s from graph build failure", async (_label, entrypoint, reason) => { + const path = root(); + await expect(runGraphCandidateProcess({ + projectRoot: path, candidatePath: join(path, ".mex", `graph.db.candidate-${"d".repeat(48)}`), operation: "rebuild", + __internal: { entrypoint: entrypoint(), startupTimeoutMs: 100 }, + })).rejects.toMatchObject({ category: "failed", diagnostic: { reason } }); + }); + + it("drains excessive worker diagnostics, keeps useful head/tail, strips terminal controls, and reports last progress", async () => { + const path = root(); + const error = await runGraphCandidateProcess({ + projectRoot: path, candidatePath: join(path, ".mex", `graph.db.candidate-${"d".repeat(48)}`), operation: "rebuild", + __internal: { entrypoint: diagnosticEntry }, + }).catch((error: unknown) => error) as GraphCandidateProcessError; + expect(error.diagnostic).toMatchObject({ reason: "worker-error", progress: { phase: "parse", completed: 1, total: 2 } }); + expect(error.diagnostic!.stderr).toContain("PRIVATE_START"); + expect(error.diagnostic!.stderr).toContain("PRIVATE_END"); + expect(error.diagnostic!.stderr).toContain("truncated"); + expect(error.diagnostic!.stderr).not.toMatch(/\u001b|\u0007|INJECTED_TITLE/u); + expect(Buffer.byteLength(error.diagnostic!.stderr!)).toBeLessThanOrEqual(GRAPH_CANDIDATE_DIAGNOSTIC_BYTES); + expect(error.message).toContain("last progress: parse 1/2 files"); + expect(error.diagnostic!.resourceFailure).toBeUndefined(); + }); + + it("identifies a heap limit only from Node's explicit fatal runtime diagnostic", async () => { + const path = root(); + const error = await runGraphCandidateProcess({ + projectRoot: path, candidatePath: join(path, ".mex", `graph.db.candidate-${"d".repeat(48)}`), operation: "rebuild", + __internal: { entrypoint: heapLimitEntry }, + }).catch((error: unknown) => error) as GraphCandidateProcessError; + expect(error.diagnostic).toMatchObject({ reason: "exit", exitCode: 134, resourceFailure: "heap-limit" }); + expect(error.message).toContain("reached Node's JavaScript heap limit"); + expect(error.diagnostic!.stderr).not.toContain("FATAL ERROR"); + }); + + it("does not treat a caught worker error containing the heap marker as a fatal runtime crash", async () => { + const path = root(); + const error = await runGraphCandidateProcess({ + projectRoot: path, candidatePath: join(path, ".mex", `graph.db.candidate-${"d".repeat(48)}`), operation: "rebuild", + __internal: { entrypoint: reportedHeapTextEntry }, + }).catch((error: unknown) => error) as GraphCandidateProcessError; + expect(error.diagnostic!.reason).toBe("worker-error"); + expect(error.diagnostic!.resourceFailure).toBeUndefined(); + }); + + it("preserves an actual worker exception on stderr without widening the failure IPC schema", async () => { + const path = root(); + const error = await runGraphCandidateProcess({ + projectRoot: path, candidatePath: join(path, ".mex", "invalid-candidate-name"), operation: "rebuild", + __internal: { entrypoint: realEntry }, + }).catch((error: unknown) => error) as GraphCandidateProcessError; + expect(error.diagnostic).toMatchObject({ reason: "worker-error", stderr: "Error: Invalid candidate path." }); + expect(boundedCandidateMessage({ type: "failed", category: "failed", errorMessage: "private" })).toBeNull(); + }); + it("sends throttled counts during synchronous work without requiring completion callbacks to run", () => { let now = 0; let blocked = false; diff --git a/src/graph/candidate-entry.ts b/src/graph/candidate-entry.ts index 4622976c..34537e4c 100644 --- a/src/graph/candidate-entry.ts +++ b/src/graph/candidate-entry.ts @@ -1,4 +1,4 @@ -import { existsSync, lstatSync, realpathSync } from "node:fs"; +import { existsSync, lstatSync, realpathSync, writeSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { createGraphEngine, GraphSourceStagingError } from "./engine-impl.js"; import type { GraphEngine } from "./engine.js"; @@ -58,6 +58,18 @@ function failureCategory(error: unknown): "compatibility" | "staging" | "failed" return "failed"; } +/** Local stderr only. The private IPC schema and GraphPort keep fixed categories. */ +function writeFailureDiagnostic(error: unknown): void { + const name = error instanceof Error ? error.name : "Error"; + const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : ""; + const message = error instanceof Error ? error.message : String(error); + const failures = error instanceof GraphSourceStagingError + ? error.failures.slice(0, 3).map((failure) => `${failure.operation} ${failure.filePath.slice(0, 512)}: ${failure.message.slice(0, 1024)}`).join("\n") + : ""; + const summary = Buffer.from(`${name.slice(0, 128)}${code ? ` (${code.slice(0, 128)})` : ""}: ${message.slice(0, 2048)}\n${failures}`.slice(0, 4096)).subarray(0, 4096); + try { writeSync(2, summary); } catch { /* Diagnostics cannot replace the original failure. */ } +} + async function main(): Promise { if (!process.send || !process.connected) throw new Error("Candidate construction requires its private parent channel."); const stopWatchdog = await startGraphCandidateWatchdog(); @@ -87,6 +99,7 @@ async function main(): Promise { outcome = { type: "complete", result }; if (!boundedCandidateMessage(outcome)) throw new Error("Graph result exceeded its private message bound."); } catch (error) { + writeFailureDiagnostic(error); try { engine?.close(); } catch { /* The supervisor removes the unpublished candidate after close. */ } outcome = { type: "failed", category: failureCategory(error) }; } @@ -95,11 +108,12 @@ async function main(): Promise { await stopWatchdog(); process.exitCode = outcome.type === "complete" ? 0 : 1; process.disconnect(); - } catch { + } catch (error) { + writeFailureDiagnostic(error); process.exit(1); } }); await send({ type: "ready" }); } -main().catch(() => process.exit(1)); +main().catch((error) => { writeFailureDiagnostic(error); process.exit(1); }); diff --git a/src/graph/candidate-process.ts b/src/graph/candidate-process.ts index a83c7a0c..fd07d54f 100644 --- a/src/graph/candidate-process.ts +++ b/src/graph/candidate-process.ts @@ -15,11 +15,29 @@ const STARTUP_TIMEOUT_MS = 15_000; const TERMINATE_GRACE_MS = 500; // A hang guard, not a resource/performance promise for a particular repository. const BUILD_TIMEOUT_MS = 30 * 60 * 1000; +export const GRAPH_CANDIDATE_DIAGNOSTIC_BYTES = 8 * 1024; +const NODE_HEAP_FAILURE = /^FATAL ERROR: (?:Reached heap limit|Ineffective mark-compacts near heap limit) Allocation failed - JavaScript heap out of memory\s*$/mu; + +export interface GraphCandidateProcessDiagnostic { + readonly reason: "entrypoint-missing" | "spawn" | "startup-timeout" | "build-timeout" + | "protocol" | "ipc-disconnected" | "worker-error" | "exit" | "observer" | "cancelled"; + readonly exitCode?: number | null; + readonly signal?: NodeJS.Signals | null; + readonly elapsedMs?: number; + readonly progress?: GraphCandidateProgress; + /** Only set when Node prints its explicit fatal JavaScript heap-limit marker. */ + readonly resourceFailure?: "heap-limit"; + /** Bounded local terminal diagnostics. Never project this field into GraphPort/Hub responses. */ + readonly stderr?: string; +} export class GraphCandidateProcessError extends Error { override readonly name = "GraphCandidateProcessError"; - constructor(readonly category: "cancelled" | "compatibility" | "staging" | "failed" | "unsafe") { - super(category === "cancelled" + constructor( + readonly category: "cancelled" | "compatibility" | "staging" | "failed" | "unsafe", + readonly diagnostic?: GraphCandidateProcessDiagnostic, + ) { + super(diagnostic ? diagnosticMessage(diagnostic) : category === "cancelled" ? "Graph candidate construction was cancelled." : "The isolated graph candidate could not be completed safely."); } @@ -36,6 +54,7 @@ export interface GraphCandidateProcessOptions { entrypoint?: string; onSpawn?: (pid: number, workspace: string) => void; buildTimeoutMs?: number; + startupTimeoutMs?: number; }; } @@ -59,7 +78,7 @@ function candidateEntrypoint(): string { // use the installed build; no TypeScript loader or public CLI is spawned. const candidates = [join(HERE, "graph-candidate.js"), join(HERE, "../../dist/graph-candidate.js")]; const entrypoint = candidates.find((path) => existsSync(path)); - if (!entrypoint) throw new GraphCandidateProcessError("failed"); + if (!entrypoint) throw new GraphCandidateProcessError("failed", { reason: "entrypoint-missing" }); return entrypoint; } @@ -90,6 +109,8 @@ export async function runGraphCandidateProcess(options: GraphCandidateProcessOpt function superviseCandidate(options: GraphCandidateProcessOptions, request: GraphCandidateRequest): Promise { return new Promise((resolve, reject) => { + const startedAt = performance.now(); + const stderr = boundedWorkerDiagnostic(); let child: ChildProcess; try { child = spawn(process.execPath, [options.__internal?.entrypoint ?? candidateEntrypoint()], { @@ -99,64 +120,78 @@ function superviseCandidate(options: GraphCandidateProcessOptions, request: Grap // its main thread is inside synchronous TypeScript or SQLite work. // Overlapped enables asynchronous reads on the inherited Windows // lifeline handle; it is identical to `pipe` on Unix. - stdio: ["ignore", "ignore", "ignore", "ipc", "overlapped"], + stdio: ["ignore", "ignore", "pipe", "ipc", "overlapped"], serialization: "json", windowsHide: true, }); - } catch { - reject(new GraphCandidateProcessError("failed")); + } catch (error) { + reject(error instanceof GraphCandidateProcessError ? error : new GraphCandidateProcessError("failed", { + reason: "spawn", stderr: boundedDiagnosticText(error instanceof Error ? error.message : String(error)), + })); return; } let ready = false; let result: BuildResult | undefined; let failure: GraphCandidateProcessError | undefined; + let lastProgress: GraphCandidateProgress | undefined; + const sentSignals = new Set(); + child.stderr?.on("data", (chunk: Buffer) => stderr.append(chunk)); + const fail = (reason: GraphCandidateProcessDiagnostic["reason"], category: GraphCandidateProcessError["category"] = "failed") => + new GraphCandidateProcessError(category, { reason }); + const kill = (signal: NodeJS.Signals) => { + if (child.kill(signal)) sentSignals.add(signal); + }; let terminateTimer: ReturnType | undefined; const stop = (error: GraphCandidateProcessError) => { if (failure) return; failure = error; - child.kill("SIGTERM"); - terminateTimer = setTimeout(() => child.kill("SIGKILL"), TERMINATE_GRACE_MS); + kill("SIGTERM"); + terminateTimer = setTimeout(() => kill("SIGKILL"), TERMINATE_GRACE_MS); terminateTimer.unref(); }; - const cancelled = () => stop(new GraphCandidateProcessError("cancelled")); + const cancelled = () => stop(fail("cancelled", "cancelled")); const parentExit = () => child.kill("SIGKILL"); - const startupTimer = setTimeout(() => stop(new GraphCandidateProcessError("failed")), STARTUP_TIMEOUT_MS); + const startupTimer = setTimeout(() => stop(fail("startup-timeout")), options.__internal?.startupTimeoutMs ?? STARTUP_TIMEOUT_MS); const buildTimer = setTimeout( - () => stop(new GraphCandidateProcessError("failed")), + () => stop(fail("build-timeout")), options.__internal?.buildTimeoutMs ?? BUILD_TIMEOUT_MS, ); startupTimer.unref(); buildTimer.unref(); process.once("exit", parentExit); options.signal?.addEventListener("abort", cancelled, { once: true }); - child.on("error", () => stop(new GraphCandidateProcessError("failed"))); + child.on("error", (error) => { + stderr.append(Buffer.from(`${error.name}: ${error.message}\n`)); + stop(fail("spawn")); + }); child.on("disconnect", () => { // A live parent keeps FD4 open, so the parent-death watchdog cannot help // when only IPC is lost. A disconnected writer without a terminal result // must be stopped promptly instead of holding the lease until timeout. - if (!result && !failure) stop(new GraphCandidateProcessError("failed")); + if (!result && !failure) stop(fail("ipc-disconnected")); }); child.on("message", (raw: unknown) => { if (failure) return; const message = boundedCandidateMessage(raw); - if (!message || result) return stop(new GraphCandidateProcessError("failed")); + if (!message || result) return stop(fail("protocol")); if (message.type === "ready") { - if (ready) return stop(new GraphCandidateProcessError("failed")); + if (ready) return stop(fail("protocol")); ready = true; clearTimeout(startupTimer); - child.send(request, (error) => { if (error) stop(new GraphCandidateProcessError("failed")); }); + child.send(request, (error) => { if (error) stop(fail("ipc-disconnected")); }); } else if (!ready) { - stop(new GraphCandidateProcessError("failed")); + stop(fail("protocol")); } else if (message.type === "progress") { + lastProgress = message.progress; try { options.onProgress?.(message.progress); } catch { - stop(new GraphCandidateProcessError("failed")); + stop(fail("observer")); } } else if (message.type === "complete") { result = message.result; } else { - stop(new GraphCandidateProcessError(message.category)); + stop(fail("worker-error", message.category)); } }); child.once("close", (code, signal) => { @@ -166,16 +201,90 @@ function superviseCandidate(options: GraphCandidateProcessOptions, request: Grap process.removeListener("exit", parentExit); options.signal?.removeEventListener("abort", cancelled); child.stdio[4]?.destroy(); - if (options.signal?.aborted) reject(new GraphCandidateProcessError("cancelled")); - else if (failure) reject(failure); - else if (code !== 0 || signal || !result) reject(new GraphCandidateProcessError("failed")); - else resolve(result); + if (options.signal?.aborted) failure = fail("cancelled", "cancelled"); + if (failure || code !== 0 || signal || !result) { + let reason = failure?.diagnostic?.reason ?? "exit"; + const diagnosticOutput = stderr.text(); + // An exiting process also disconnects IPC. Preserve the actual crash or + // exit status instead of blaming a channel that closed as a consequence. + if (reason === "ipc-disconnected" && (code !== null || (signal && !sentSignals.has(signal)))) reason = "exit"; + reject(new GraphCandidateProcessError(failure?.category ?? "failed", { + reason, exitCode: code, signal, elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)), + ...(lastProgress ? { progress: lastProgress } : {}), + ...(reason === "exit" && stderr.heapLimit() + ? { resourceFailure: "heap-limit" as const } : {}), + ...(diagnosticOutput ? { stderr: diagnosticOutput } : {}), + })); + } else resolve(result); }); try { if (child.pid) options.__internal?.onSpawn?.(child.pid, request.workspace); if (options.signal?.aborted) cancelled(); } catch { - stop(new GraphCandidateProcessError("failed")); + stop(fail("observer")); } }); } + +function boundedDiagnosticText(text: string): string { + const plain = text.slice(0, GRAPH_CANDIDATE_DIAGNOSTIC_BYTES * 4) + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/gu, "") + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "") + .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, ""); + return Buffer.from(plain).subarray(0, GRAPH_CANDIDATE_DIAGNOSTIC_BYTES).toString("utf8").replace(/\uFFFD$/u, "").trim(); +} + +/** Drain all stderr while retaining a bounded beginning and end, including fatal runtime output. */ +function boundedWorkerDiagnostic(): { append: (chunk: Buffer) => void; text: () => string; heapLimit: () => boolean } { + const marker = "\n[worker diagnostics truncated]\n"; + const headLimit = 2048; + const tailLimit = GRAPH_CANDIDATE_DIAGNOSTIC_BYTES - headLimit - Buffer.byteLength(marker); + let head = Buffer.alloc(0); + let tail = Buffer.alloc(0); + let truncated = false; + let heapLimit = false; + let markerTail = ""; + return { + append(chunk) { + // Keep the fixed fatal hint even if verbose startup/crash output pushes + // its line out of the retained head/tail. No raw content enters the hint. + const markerText = markerTail + chunk.toString("utf8"); + heapLimit ||= NODE_HEAP_FAILURE.test(markerText); + markerTail = markerText.slice(-256); + const headBytes = Math.min(chunk.length, headLimit - head.length); + if (headBytes) head = Buffer.concat([head, chunk.subarray(0, headBytes)]); + const rest = chunk.subarray(headBytes); + if (tail.length + rest.length > tailLimit) truncated = true; + // Slice before concatenating: a single oversized write must not expand retained memory. + tail = Buffer.concat([tail, rest.subarray(Math.max(0, rest.length - tailLimit))]).subarray(-tailLimit); + }, + text: () => boundedDiagnosticText(head.toString("utf8") + (truncated ? marker : "") + tail.toString("utf8")), + heapLimit: () => heapLimit, + }; +} + +function diagnosticMessage(diagnostic: GraphCandidateProcessDiagnostic): string { + const descriptions: Record = { + "entrypoint-missing": "The graph worker executable is missing from this MEX installation", + spawn: "The graph worker could not start", + "startup-timeout": "The graph worker did not become ready before its startup deadline", + "build-timeout": "The graph worker exceeded its graph construction deadline", + protocol: "The graph worker returned an invalid or out-of-order response", + "ipc-disconnected": "The graph worker lost its parent communication channel", + "worker-error": "The graph worker reported an error", + exit: "The graph worker exited without a valid completed result", + observer: "Graph worker supervision could not continue", + cancelled: "Graph candidate construction was cancelled", + }; + const details: string[] = []; + if (diagnostic.exitCode !== undefined && diagnostic.exitCode !== null) details.push(`exit code ${diagnostic.exitCode}`); + if (diagnostic.signal) details.push(`signal ${diagnostic.signal}`); + if (diagnostic.elapsedMs !== undefined) details.push(`${(diagnostic.elapsedMs / 1000).toFixed(1)}s elapsed`); + if (diagnostic.progress) { + const { phase, completed, total } = diagnostic.progress; + details.push(`last progress: ${phase}${completed === undefined ? "" : ` ${completed}${total === undefined ? "" : `/${total}`} files`}`); + } + const description = diagnostic.resourceFailure === "heap-limit" + ? "The graph worker reached Node's JavaScript heap limit" : descriptions[diagnostic.reason]; + return `${description}${details.length ? ` (${details.join("; ")})` : ""}.${diagnostic.stderr ? `\nWorker diagnostics:\n${boundedDiagnosticText(diagnostic.stderr)}` : ""}`; +} diff --git a/src/hub/__tests__/setup-command.test.ts b/src/hub/__tests__/setup-command.test.ts index 7589f15a..440470f3 100644 --- a/src/hub/__tests__/setup-command.test.ts +++ b/src/hub/__tests__/setup-command.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ findConfig: vi.fn(), identity: vi.fn(), createTeam: vi.fn(), + openBrowser: vi.fn(), order: [] as string[], })); @@ -30,6 +31,7 @@ vi.mock("../security/session.js", () => ({ })); vi.mock("../app.js", () => ({ createHubApp: mocks.createApp })); vi.mock("../node-server.js", () => ({ startHubNodeServer: mocks.startServer })); +vi.mock("../browser.js", () => ({ openHubBrowser: mocks.openBrowser })); vi.mock("../setup/services.js", () => ({ createSetupHubServices: mocks.createSetup })); vi.mock("../../setup/headless.js", () => ({ inspectSetupStatus: mocks.inspect })); vi.mock("../setup/readiness.js", () => ({ hasCommittedHubIdentity: mocks.committedIdentity })); @@ -78,6 +80,96 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()); describe("Hub setup process", () => { + it("does not start resources when the finishing lane is already cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + const onListening = vi.fn(); + await runSetupHubCommand({ projectRoot: "/Users/private/project", openBrowser: true, signal: controller.signal, onListening }); + expect(mocks.createSetup).not.toHaveBeenCalled(); + expect(mocks.startServer).not.toHaveBeenCalled(); + expect(mocks.startTelemetry).not.toHaveBeenCalled(); + expect(mocks.openBrowser).not.toHaveBeenCalled(); + expect(onListening).not.toHaveBeenCalled(); + expect(process.stdout.write).not.toHaveBeenCalled(); + }); + + it("closes a listener that finishes starting after cancellation", async () => { + let ready!: (server: { origin: string; close: typeof mocks.closeServer; replaceApp: () => void }) => void; + mocks.startServer.mockReturnValue(new Promise((resolve) => { ready = resolve; })); + const controller = new AbortController(); + const onListening = vi.fn(); + const running = runSetupHubCommand({ projectRoot: "/Users/private/project", openBrowser: true, signal: controller.signal, onListening }); + await vi.waitFor(() => expect(mocks.startServer).toHaveBeenCalledOnce()); + controller.abort(); + ready({ origin: "http://127.0.0.1:48123", close: mocks.closeServer, replaceApp: vi.fn() }); + await running; + expect(mocks.order).toEqual(["http", "setup"]); + expect(mocks.startTelemetry).not.toHaveBeenCalled(); + expect(onListening).not.toHaveBeenCalled(); + expect(mocks.openBrowser).not.toHaveBeenCalled(); + expect(process.stdout.write).not.toHaveBeenCalled(); + }); + + it("hands the bound finishing link to the HUD and removes shutdown listeners on abort", async () => { + const controller = new AbortController(); + const onListening = vi.fn(); + const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener"); + const priorInterrupt = process.listeners("SIGINT"); + const priorTerminate = process.listeners("SIGTERM"); + mocks.startServer.mockResolvedValue({ origin: "http://127.0.0.1:48123", close: mocks.closeServer, replaceApp: vi.fn() }); + const running = runSetupHubCommand({ + projectRoot: "/Users/private/project", openBrowser: true, port: 48123, + initialMode: "agent-memory", signal: controller.signal, onListening, + }); + await vi.waitFor(() => expect(onListening).toHaveBeenCalledOnce()); + expect(onListening).toHaveBeenCalledWith({ origin: "http://127.0.0.1:48123", bootstrapUrl: "http://127.0.0.1:48123/#token=private-bootstrap-token" }); + expect(mocks.createSetup).toHaveBeenCalledWith("/Users/private/project", expect.objectContaining({ initialMode: "agent-memory" })); + expect(mocks.startServer).toHaveBeenCalledWith(expect.objectContaining({ port: 48123 })); + expect(mocks.openBrowser).toHaveBeenCalledWith("http://127.0.0.1:48123/#token=private-bootstrap-token"); + expect(process.stdout.write).not.toHaveBeenCalled(); + controller.abort(); + await running; + expect(mocks.order).toEqual(["http", "setup", "telemetry"]); + expect(process.listeners("SIGINT")).toEqual(priorInterrupt); + expect(process.listeners("SIGTERM")).toEqual(priorTerminate); + expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + it("handles cancellation from onListening before browser launch or shutdown wait", async () => { + const controller = new AbortController(); + const priorInterrupt = process.listeners("SIGINT"); + const priorTerminate = process.listeners("SIGTERM"); + mocks.startServer.mockResolvedValue({ origin: "http://127.0.0.1:48123", close: mocks.closeServer, replaceApp: vi.fn() }); + await runSetupHubCommand({ + projectRoot: "/Users/private/project", openBrowser: true, signal: controller.signal, + onListening: () => controller.abort(), + }); + expect(mocks.openBrowser).not.toHaveBeenCalled(); + expect(process.stdout.write).not.toHaveBeenCalled(); + expect(mocks.order).toEqual(["http", "setup", "telemetry"]); + expect(process.listeners("SIGINT")).toEqual(priorInterrupt); + expect(process.listeners("SIGTERM")).toEqual(priorTerminate); + }); + + it("routes startup output through onMessage when no listener callback is supplied", async () => { + const controller = new AbortController(); + const onMessage = vi.fn(() => controller.abort()); + mocks.startServer.mockResolvedValue({ origin: "http://127.0.0.1:48123", close: mocks.closeServer, replaceApp: vi.fn() }); + await runSetupHubCommand({ projectRoot: "/Users/private/project", openBrowser: false, signal: controller.signal, onMessage }); + expect(onMessage).toHaveBeenCalledWith(expect.stringContaining("http://127.0.0.1:48123/#token=private-bootstrap-token")); + expect(process.stdout.write).not.toHaveBeenCalled(); + expect(mocks.order).toEqual(["http", "setup", "telemetry"]); + }); + + it("cleans up setup and reports a listener startup failure to its caller", async () => { + const onListening = vi.fn(); + mocks.startServer.mockRejectedValue(new Error("Port is occupied.")); + await expect(runSetupHubCommand({ projectRoot: "/Users/private/project", openBrowser: true, onListening })).rejects.toThrow("Port is occupied."); + expect(onListening).not.toHaveBeenCalled(); + expect(mocks.openBrowser).not.toHaveBeenCalled(); + expect(mocks.order).toEqual(["setup"]); + }); + it("starts the setup Hub without jobs when the checkout is not ready", async () => { let ready!: (server: { origin: string; close: typeof mocks.closeServer; replaceApp: () => void }) => void; mocks.startServer.mockReturnValue(new Promise((resolve) => { ready = resolve; })); @@ -154,6 +246,47 @@ describe("Hub setup process", () => { expect(mocks.order).toEqual(["http", "jobs", "telemetry"]); }); + it.each([{ setup: true }, { mode: "code-repo" }])("retains the setup finishing screen for explicit launch options %j", async (options) => { + mocks.inspect.mockReturnValue({ mode: "code-repo", hasScaffold: true, ready: true, stage: "ready" }); + mocks.committedIdentity.mockResolvedValue(true); + mocks.startServer.mockResolvedValue({ origin: "http://127.0.0.1:48123", close: mocks.closeServer, replaceApp: vi.fn() }); + const priorListeners = new Set(process.listeners("SIGTERM")); + const running = launchHub({ openBrowser: false, ...options }); + await vi.waitFor(() => expect(mocks.events).toHaveBeenCalledOnce()); + expect(mocks.createSetup).toHaveBeenCalledWith("/Users/private/project", expect.objectContaining({ initialMode: "mode" in options ? options.mode : undefined })); + expect(mocks.createTeam).not.toHaveBeenCalled(); + stopHub(priorListeners); + await running; + }); + + it.each(["ready", "failed"])("routes a %s promotion notice through the HUD callback", async (outcome) => { + let onReady!: (signal: AbortSignal) => Promise; + const replaceApp = vi.fn(); + mocks.createSetup.mockImplementation((_root, options: { onReady: typeof onReady }) => { + onReady = options.onReady; + return { services: {}, setup: { shutdown: mocks.stopSetup } }; + }); + mocks.startServer.mockResolvedValue({ origin: "http://127.0.0.1:48123", close: mocks.closeServer, replaceApp }); + const controller = new AbortController(); + const onListening = vi.fn(); + const onMessage = vi.fn(); + const running = runSetupHubCommand({ projectRoot: "/Users/private/project", openBrowser: false, signal: controller.signal, onListening, onMessage }); + await vi.waitFor(() => expect(onListening).toHaveBeenCalledOnce()); + if (outcome === "ready") { + await onReady(new AbortController().signal); + expect(replaceApp).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith("Project Hub is ready at http://127.0.0.1:48123"); + } else { + mocks.findConfig.mockImplementation(() => { throw new Error("Config could not be read."); }); + await expect(onReady(new AbortController().signal)).rejects.toThrow("Config could not be read."); + expect(replaceApp).not.toHaveBeenCalled(); + expect(onMessage).toHaveBeenCalledWith("Project Hub could not open after setup: Config could not be read."); + } + expect(process.stdout.write).not.toHaveBeenCalled(); + controller.abort(); + await running; + }); + it("cleans up pending composition after cancellation without replacing the setup app", async () => { const replaceApp = vi.fn(); let onReady!: (signal: AbortSignal) => Promise; diff --git a/src/hub/command.ts b/src/hub/command.ts index e42ec69e..590ce70e 100644 --- a/src/hub/command.ts +++ b/src/hub/command.ts @@ -44,6 +44,9 @@ export interface RunSetupHubCommandOptions { readonly port?: number; readonly openBrowser: boolean; readonly initialMode?: SetupMode; + readonly signal?: AbortSignal; + readonly onListening?: (listener: { origin: string; bootstrapUrl: string }) => void; + readonly onMessage?: (message: string) => void; } /** @@ -135,6 +138,7 @@ export async function runHubCommand(options: RunHubCommandOptions): Promise { + if (options.signal?.aborted) return; const captureEvent = createProjectTelemetryCapture(options.projectRoot); let server: Awaited> | undefined; let stopTelemetry: (() => Promise) | undefined; @@ -143,9 +147,13 @@ export async function runSetupHubCommand(options: RunSetupHubCommandOptions): Pr let assets: HubAssetManifest | undefined; let promoting = false; let closing = false; + const reportMessage = (message: string): void => { + if (options.onMessage) options.onMessage(message); + else if (!options.onListening) process.stdout.write(`\n${message}\n`); + }; const promoteToProjectHub = async (signal: AbortSignal): Promise => { - if (signal.aborted) throw new Error("Setup was cancelled."); + if (signal.aborted || options.signal?.aborted) throw new Error("Setup was cancelled."); const listener = server; const session = security; const manifest = assets; @@ -167,18 +175,16 @@ export async function runSetupHubCommand(options: RunSetupHubCommandOptions): Pr ...(config.wiki?.exclude === undefined ? {} : { wikiExclude: config.wiki.exclude }), ...(config.wiki?.readOnly === undefined ? {} : { wikiReadOnly: config.wiki.readOnly }), }); - if (closing || signal.aborted) { + if (closing || signal.aborted || options.signal?.aborted) { await composed.jobs.shutdown(); - throw new Error(signal.aborted ? "Setup was cancelled." : "The Hub listener is stopping."); + throw new Error(signal.aborted || options.signal?.aborted ? "Setup was cancelled." : "The Hub listener is stopping."); } jobs = composed.jobs; listener.replaceApp(composed.app); - process.stdout.write(`\nProject Hub is ready at ${listener.origin}\n`); + reportMessage(`Project Hub is ready at ${listener.origin}`); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - process.stdout.write( - `\nProject Hub could not open after setup: ${detail}\n`, - ); + reportMessage(`Project Hub could not open after setup: ${detail}`); throw error; } finally { promoting = false; @@ -206,16 +212,18 @@ export async function runSetupHubCommand(options: RunSetupHubCommandOptions): Pr }); server = await startHubNodeServer({ app, port: options.port }); + if (options.signal?.aborted) return; expectedOrigin = server.origin; stopTelemetry = startHubTelemetry(); emitHubTelemetry(captureEvent, "hub.session_started", {}); const bootstrapUrl = `${server.origin}/#token=${encodeURIComponent(bootstrapToken)}`; - process.stdout.write(`\nProject Hub setup wizard running at ${server.origin}\n`); - process.stdout.write(`One-time bootstrap link (valid for 5 minutes):\n${bootstrapUrl}\n`); - process.stdout.write("Press Ctrl+C to stop.\n\n"); - if (options.openBrowser) openHubBrowser(bootstrapUrl); - await waitForShutdownSignal(); + options.onListening?.({ origin: server.origin, bootstrapUrl }); + if (!options.onListening) { + reportMessage(`Project Hub setup wizard running at ${server.origin}\nOne-time bootstrap link (valid for 5 minutes):\n${bootstrapUrl}\nPress Ctrl+C to stop.\n`); + } + if (options.openBrowser && !options.signal?.aborted) openHubBrowser(bootstrapUrl); + await waitForShutdownSignal(options.signal); } finally { closing = true; try { @@ -302,14 +310,17 @@ async function composeProductionHub( } } -function waitForShutdownSignal(): Promise { +function waitForShutdownSignal(signal?: AbortSignal): Promise { return new Promise((resolve) => { + if (signal?.aborted) { resolve(); return; } const complete = () => { process.off("SIGINT", complete); process.off("SIGTERM", complete); + signal?.removeEventListener("abort", complete); resolve(); }; process.once("SIGINT", complete); process.once("SIGTERM", complete); + signal?.addEventListener("abort", complete, { once: true }); }); } diff --git a/src/hub/setup/__tests__/expected-errors.test.ts b/src/hub/setup/__tests__/expected-errors.test.ts new file mode 100644 index 00000000..afbe1c16 --- /dev/null +++ b/src/hub/setup/__tests__/expected-errors.test.ts @@ -0,0 +1,102 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SetupRunSchema, type SetupRun } from "@mex/hub-contracts/setup"; +import { HubSetupRunner } from "../runner.js"; + +const roots: string[] = []; +const runners: HubSetupRunner[] = []; + +afterEach(async () => { + for (const runner of runners.splice(0)) await runner.shutdown(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "mex-setup-expected-error-")); + roots.push(root); + return root; +} + +function observeRunner(root: string) { + const runner = new HubSetupRunner({ projectRoot: root }); + runners.push(runner); + const observed: SetupRun[] = []; + runner.subscribe((run) => observed.push(run)); + return { runner, observed }; +} + +async function expectSafeFailure( + runner: HubSetupRunner, + observed: readonly SetupRun[], + root: string, + message: string, +): Promise { + await vi.waitFor(() => expect(runner.snapshot().status).toBe("failed")); + const snapshot = SetupRunSchema.parse(runner.snapshot()); + expect(snapshot).toMatchObject({ + status: "failed", ready: false, error: message, message, + progress: { step: "scaffold" }, + }); + expect(observed.at(-1)).toEqual(snapshot); + for (const run of observed) expect(SetupRunSchema.safeParse(run).success).toBe(true); + const reconnect = vi.fn(); + const unsubscribe = runner.subscribe(reconnect); + expect(reconnect).toHaveBeenCalledExactlyOnceWith(snapshot); + expect(SetupRunSchema.parse(reconnect.mock.calls[0]![0])).toEqual(snapshot); + unsubscribe(); + const payload = JSON.stringify(observed); + expect(payload).not.toContain(root); + expect(payload).not.toContain("private-config-token"); + expect(payload).not.toContain("Run mex setup --cli in this project for details"); +} + +describe("expected setup failures through the real Hub runner", () => { + it("reports malformed config and accepts a corrected-config retry on the same runner", async () => { + const root = fixture(); + mkdirSync(join(root, ".mex")); + const configPath = join(root, ".mex", "config.json"); + const malformed = '{"private-config-token":'; + writeFileSync(configPath, malformed); + const { runner, observed } = observeRunner(root); + + runner.start({ mode: "agent-memory", tools: [] }); + await expectSafeFailure(runner, observed, root, + "Existing .mex/config.json is not a valid JSON object. Fix it before rerunning setup."); + expect(readFileSync(configPath, "utf8")).toBe(malformed); + + writeFileSync(configPath, "{}\n"); + const retry = runner.start({ mode: "agent-memory", tools: [] }); + expect(retry).toMatchObject({ status: "running", error: null }); + await vi.waitFor(() => expect(runner.snapshot().status).toBe("paused"), { timeout: 10_000 }); + const resumed = SetupRunSchema.parse(runner.snapshot()); + expect(resumed).toMatchObject({ + mode: "agent-memory", status: "paused", stage: "needs_population", + ready: false, error: null, selectedTools: [], populationTool: null, + }); + expect(resumed.prompt?.length).toBeGreaterThan(100); + expect(resumed.message).not.toContain("not a valid JSON"); + for (const run of observed) expect(SetupRunSchema.safeParse(run).success).toBe(true); + expect(existsSync(join(root, ".mex", "graph.db"))).toBe(false); + expect(existsSync(join(root, ".mex", "wiki.db"))).toBe(false); + }); + + it("reports the broad Git ignore rule before graph construction or agent population", async () => { + const root = fixture(); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + writeFileSync(join(root, ".gitignore"), ".mex/\n"); + const { runner, observed } = observeRunner(root); + + runner.start({ mode: "code-repo", tools: [] }); + await expectSafeFailure(runner, observed, root, + "A Git ignore rule hides .mex/config.json. Remove the broad .mex ignore so canonical MEX files can be committed."); + expect(readFileSync(join(root, ".gitignore"), "utf8")).toBe(".mex/\n"); + expect(existsSync(join(root, ".mex", "config.json"))).toBe(false); + expect(existsSync(join(root, ".mex", "graph.db"))).toBe(false); + expect(existsSync(join(root, ".mex", "wiki.db"))).toBe(false); + expect(runner.snapshot().populationTool).toBeNull(); + expect(runner.snapshot().prompt).toBeNull(); + }); +}); diff --git a/src/hub/setup/__tests__/runner.test.ts b/src/hub/setup/__tests__/runner.test.ts index 9c300c0d..acf66b28 100644 --- a/src/hub/setup/__tests__/runner.test.ts +++ b/src/hub/setup/__tests__/runner.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SetupRunSchema } from "@mex/hub-contracts/setup"; +import { SetupRunSchema, SetupTranscriptBatchSchema } from "@mex/hub-contracts/setup"; import type { HeadlessSetupOptions } from "../../../setup/headless.js"; import { SetupPopulationError } from "../../../setup/population.js"; +import { createPopulationTranscriptDecoder, type PopulationTranscriptSignal } from "../../../setup/population-transcript.js"; import { SetupFinalizationError } from "../../../setup/index.js"; +import { SetupError } from "../../../setup/errors.js"; const mocks = vi.hoisted(() => ({ inspect: vi.fn(), execute: vi.fn(), status: vi.fn(), initial: vi.fn() })); vi.mock("../../../setup/headless.js", () => ({ inspectSetupStatus: mocks.inspect, runHeadlessSetup: mocks.execute })); @@ -63,37 +65,115 @@ describe("setup run lifecycle", () => { return new Promise((resolve) => { finish = () => resolve(result); }); }); const runner = new HubSetupRunner({ projectRoot: "/test" }); - const run = runner.start({ mode: "code-repo", tools: ["codex"] }); + const announcedPages: string[][] = []; + const setupListener = vi.fn((snapshot: ReturnType) => { + if (snapshot.transcriptId) announcedPages.push(runner.readTranscript(snapshot.transcriptId, 0).entries.map(entry => entry.text)); + }); + runner.subscribe(setupListener); + expect(runner.start({ mode: "code-repo", tools: ["codex"] }).transcriptId).toBeUndefined(); await Promise.resolve(); const listener = vi.fn(); const unsubscribe = runner.subscribeTranscript(listener); - for (let i = 0; i < 50; i++) report({ tool: "codex", kind: "assistant", text: `Session message ${i}\n` }); - expect(listener).toHaveBeenCalledTimes(1); + const emissionsBeforeOutput = setupListener.mock.calls.length; + report({ tool: "codex", kind: "assistant", text: "Session message 0\n" }); + const first = SetupRunSchema.parse(runner.snapshot()); + expect(first.transcriptId).toBeDefined(); + const firstId = first.transcriptId!; + expect(setupListener).toHaveBeenCalledTimes(emissionsBeforeOutput + 1); + expect(setupListener).toHaveBeenLastCalledWith(first); + expect(announcedPages[0]).toEqual(["Session message 0\n"]); + expect(SetupTranscriptBatchSchema.parse(runner.readTranscript(firstId, 0))).toMatchObject({ + entries: [{ text: "Session message 0\n" }], cursor: 1, done: false, + }); + for (let i = 1; i < 50; i++) report({ tool: "codex", kind: "assistant", text: `Session message ${i}\n` }); + expect(setupListener).toHaveBeenCalledTimes(emissionsBeforeOutput + 1); + expect(listener).toHaveBeenCalledTimes(2); expect(JSON.stringify(runner.snapshot())).not.toContain("Session message"); - expect(runner.readTranscript(run.transcriptId!, 0)).toMatchObject({ cursor: 32, done: false }); + expect(runner.readTranscript(firstId, 0)).toMatchObject({ cursor: 32, done: false }); await vi.advanceTimersByTimeAsync(200); - expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledTimes(3); runner.cancel(); const cancelledReport = report; cancelledReport({ tool: "codex", kind: "assistant", text: "Late private output" }); finish(); await vi.waitFor(() => expect(runner.snapshot().status).toBe("cancelled")); - expect(runner.readTranscript(run.transcriptId!, 32)).toMatchObject({ cursor: 50, done: true }); + expect(runner.readTranscript(firstId, 32)).toMatchObject({ cursor: 50, done: true }); const next = runner.start({ mode: "code-repo", tools: ["claude"] }); await Promise.resolve(); - expect(next.transcriptId).not.toBe(run.transcriptId); - expect(() => runner.readTranscript(run.transcriptId!, 0)).toThrow("Reconnect"); + expect(next.transcriptId).toBeUndefined(); + expect(() => runner.readTranscript(firstId, 0)).toThrow("Reconnect"); cancelledReport({ tool: "codex", kind: "assistant", text: "Prior session" }); - expect(runner.readTranscript(next.transcriptId!, 0).entries).toEqual([]); + expect(runner.snapshot().transcriptId).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); report({ tool: "claude", kind: "command", text: "Ran a command" }); - runner.cancel(); + const nextId = runner.snapshot().transcriptId!; + expect(nextId).toBeDefined(); + expect(nextId).not.toBe(firstId); + expect(() => runner.readTranscript(firstId, 0)).toThrow("Reconnect"); finish(); + await vi.waitFor(() => expect(runner.snapshot().status).toBe("paused")); + report({ tool: "claude", kind: "assistant", text: "Late completed output" }); await runner.shutdown(); - expect(runner.readTranscript(next.transcriptId!, 0).entries.map((entry) => entry.text)).toEqual(["Ran a command"]); + expect(runner.readTranscript(nextId, 0)).toMatchObject({ done: true, entries: [{ text: "Ran a command" }] }); expect(vi.getTimerCount()).toBe(0); unsubscribe(); }); + it.each([ + { name: "manual population", stage: "needs_population", populated: false }, + { name: "already populated commit checkpoint", stage: "needs_commit", populated: true }, + { name: "finalization only", stage: "needs_finalize", populated: true }, + ])("does not create a transcript for $name without agent output", async ({ stage, populated }) => { + mocks.inspect.mockReturnValue({ ...status, stage, populated }); + mocks.status.mockResolvedValue({ ...status, stage: populated ? "needs_commit" : "needs_population", populated }); + mocks.execute.mockImplementation(async (options: HeadlessSetupOptions) => { + options.onProgress?.({ step: populated ? "finalize" : "population", label: "Check existing setup" }); + return { ...result, ready: false, populated, populationTool: null, populationCompleted: false }; + }); + const runner = new HubSetupRunner({ projectRoot: "/test" }); + expect(runner.snapshot().transcriptId).toBeUndefined(); + expect(runner.start({ mode: "code-repo", tools: [], confirmPopulation: true }).transcriptId).toBeUndefined(); + await vi.waitFor(() => expect(runner.snapshot().status).toBe("paused")); + const snapshot = SetupRunSchema.parse(runner.snapshot()); + expect(snapshot.transcriptId).toBeUndefined(); + const reconnect = vi.fn(); + runner.subscribe(reconnect); + expect(reconnect).toHaveBeenCalledExactlyOnceWith(snapshot); + await runner.shutdown(); + }); + + it("keeps activity live without creating a transcript for empty or stripped output", async () => { + vi.useFakeTimers(); + let options!: HeadlessSetupOptions; + let finish!: () => void; + mocks.execute.mockImplementation((input: HeadlessSetupOptions) => { + options = input; + return new Promise(resolve => { finish = () => resolve(result); }); + }); + const runner = new HubSetupRunner({ projectRoot: "/test" }); + runner.start({ mode: "code-repo", tools: ["codex"] }); + await Promise.resolve(); + options.onPopulationActivity?.({ tool: "codex", kind: "starting", state: "running" }); + options.onPopulationTranscript?.({ tool: "codex", kind: "assistant", text: "" }); + const decoded = vi.fn((entry: PopulationTranscriptSignal) => options.onPopulationTranscript?.({ tool: "codex", ...entry })); + const decoder = createPopulationTranscriptDecoder("codex", decoded); + decoder.write(Buffer.from(JSON.stringify({ type: "item.completed", item: { + id: "control-only", type: "agent_message", text: "\u001b[31m\u0000\u0007\u001b[0m", + } }) + "\n")); + decoder.end(); + expect(decoded).not.toHaveBeenCalled(); + expect(runner.snapshot().populationActivity).toMatchObject({ tool: "codex", totalEvents: 1 }); + expect(runner.snapshot().transcriptId).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + runner.cancel(); + options.onPopulationTranscript?.({ tool: "codex", kind: "assistant", text: "Late first output" }); + finish(); + await runner.shutdown(); + expect(SetupRunSchema.parse(runner.snapshot())).toMatchObject({ status: "cancelled" }); + expect(runner.snapshot().transcriptId).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + }); + it.each(["claude", "codex"] as const)("streams bounded %s activity before completion and preserves it for reconnects", async (tool) => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-10T12:00:00.000Z")); @@ -219,15 +299,82 @@ describe("setup run lifecycle", () => { await runner.shutdown(); }); - it("does not serialize an arbitrary internal failure into the browser", async () => { - mocks.execute.mockRejectedValue(new Error("secret token in /private/project/file.md")); + it("publishes only the authored setup message to current and reconnecting subscribers", async () => { + const userMessage = "Existing .mex/config.json is not a valid JSON object. Fix it before rerunning setup."; + mocks.execute.mockImplementation(async (options: HeadlessSetupOptions) => { + options.onProgress?.({ step: "scaffold", label: "Create .mex/ scaffold" }); + throw new SetupError("Private failure in /private/project/config.json", { + userMessage, + cause: new Error("secret token in nested cause"), + }); + }); + const runner = new HubSetupRunner({ projectRoot: "/test" }); + const listener = vi.fn(); + runner.subscribe(listener); + runner.start({ mode: "code-repo", tools: [] }); + await vi.waitFor(() => expect(runner.snapshot().status).toBe("failed")); + + const snapshot = SetupRunSchema.parse(runner.snapshot()); + expect(snapshot).toMatchObject({ + status: "failed", ready: false, message: userMessage, error: userMessage, + progress: { step: "scaffold", label: "Create .mex/ scaffold" }, + }); + expect(snapshot.transcriptId).toBeUndefined(); + expect(listener).toHaveBeenLastCalledWith(snapshot); + const reconnect = vi.fn(); + runner.subscribe(reconnect); + expect(reconnect).toHaveBeenCalledExactlyOnceWith(snapshot); + expect(JSON.stringify([...listener.mock.calls, ...reconnect.mock.calls])).not.toMatch(/Private failure|\/private\/project|secret token|nested cause/); + await runner.shutdown(); + }); + + it("keeps long authored setup failures inside the existing snapshot contract", async () => { + const userMessage = "Safe setup remediation. ".repeat(200); + mocks.execute.mockRejectedValue(new SetupError("Private exception detail", { userMessage })); const runner = new HubSetupRunner({ projectRoot: "/test" }); runner.start({ mode: "code-repo", tools: [] }); await vi.waitFor(() => expect(runner.snapshot().status).toBe("failed")); - expect(JSON.stringify(runner.snapshot())).not.toMatch(/secret token|\/private\/project/); + const snapshot = SetupRunSchema.parse(runner.snapshot()); + expect(snapshot.message).toBe(userMessage.slice(0, 512)); + expect(snapshot.error).toBe(snapshot.message); + expect(JSON.stringify(snapshot)).not.toContain("Private exception detail"); await runner.shutdown(); }); + it.each([ + new Error("secret token in /private/project/file.md", { cause: new Error("private nested cause") }), + { name: "SetupError", message: "secret token", userMessage: "untrusted claimed remediation" }, + ])("does not serialize an arbitrary or lookalike failure into the browser", async (error) => { + mocks.execute.mockRejectedValue(error); + const runner = new HubSetupRunner({ projectRoot: "/test" }); + runner.start({ mode: "code-repo", tools: [] }); + await vi.waitFor(() => expect(runner.snapshot().status).toBe("failed")); + const snapshot = SetupRunSchema.parse(runner.snapshot()); + expect(snapshot.message).toBe("Setup could not finish. Run mex setup --cli in this project for details, then retry."); + expect(snapshot.error).toBe(snapshot.message); + expect(JSON.stringify(snapshot)).not.toMatch(/secret token|\/private\/project|private nested cause|untrusted claimed remediation/); + await runner.shutdown(); + }); + + it("keeps cancellation authoritative when execution rejects with an expected setup error", async () => { + let rejectExecution!: (error: unknown) => void; + mocks.execute.mockImplementation(() => new Promise((_resolve, reject) => { rejectExecution = reject; })); + const runner = new HubSetupRunner({ projectRoot: "/test" }); + runner.start({ mode: "code-repo", tools: [] }); + await vi.waitFor(() => expect(mocks.execute).toHaveBeenCalledOnce()); + runner.cancel(); + rejectExecution(new SetupError("Private cancellation detail", { + userMessage: "Expected setup failure that cancellation supersedes.", + })); + await runner.shutdown(); + const snapshot = SetupRunSchema.parse(runner.snapshot()); + expect(snapshot).toMatchObject({ + status: "cancelled", ready: false, error: null, + message: "Setup was cancelled. You can resume it when ready.", + }); + expect(JSON.stringify(snapshot)).not.toMatch(/Private cancellation detail|Expected setup failure/); + }); + it("stays at a commit checkpoint instead of attempting premature promotion", async () => { mocks.execute.mockResolvedValue(result); mocks.status.mockResolvedValue({ ...status, stage: "needs_commit", populated: true }); @@ -247,12 +394,14 @@ describe("setup run lifecycle", () => { const runner = new HubSetupRunner({ projectRoot: "/test", onReady }); runner.start({ mode: "code-repo", tools: ["codex"], confirmPopulation: true }); await vi.waitFor(() => expect(runner.snapshot().status).toBe("succeeded")); + expect(SetupRunSchema.parse(runner.snapshot()).transcriptId).toBeUndefined(); expect(mocks.execute).not.toHaveBeenCalled(); expect(onReady).not.toHaveBeenCalled(); runner.start({ mode: "code-repo", tools: ["codex"], confirmPopulation: true, openHub: true }); await vi.waitFor(() => expect(onReady).toHaveBeenCalledOnce()); expect(mocks.execute).not.toHaveBeenCalled(); await runner.shutdown(); + expect(SetupRunSchema.parse(runner.snapshot()).transcriptId).toBeUndefined(); }); it("keeps completed Agent memory free of commit commands and Hub promotion", async () => { diff --git a/src/hub/setup/runner.ts b/src/hub/setup/runner.ts index 8246b518..1cf8087e 100644 --- a/src/hub/setup/runner.ts +++ b/src/hub/setup/runner.ts @@ -19,6 +19,7 @@ import { import { HubHttpError } from "../http/errors.js"; import { setupCommitCheckpointCommands, SetupFinalizationError } from "../../setup/index.js"; import { SetupPopulationError } from "../../setup/population.js"; +import { SetupError } from "../../setup/errors.js"; import { initialSetupStatus, projectSetupStatus } from "./readiness.js"; import { SetupTranscriptStore } from "./transcript.js"; import { SetupCommitService } from "./commit.js"; @@ -235,8 +236,9 @@ export class HubSetupRunner { const startedAt = this.now().toISOString(); const controller = new AbortController(); this.controller = controller; - const transcriptId = randomUUID(); - this.transcriptStore = new SetupTranscriptStore(transcriptId, this.now); + if (this.transcriptTimer !== null) clearTimeout(this.transcriptTimer); + this.transcriptTimer = null; + this.transcriptStore = null; this.run = { status: "running", mode: request.mode, @@ -247,7 +249,6 @@ export class HubSetupRunner { prompt: null, populationTool: null, populationCompleted: false, - transcriptId, commitCommands: [], anchorNotes, message: "Starting MEX setup…", @@ -310,7 +311,22 @@ export class HubSetupRunner { onAnchorNotes: (anchorNotes) => { this.run = { ...this.run, anchorNotes: [...anchorNotes] }; }, onPopulationTranscript: (entry) => { if (signal.aborted || this.run.status !== "running" || this.controller?.signal !== signal) return; - this.transcriptStore?.append({ kind: entry.kind, text: entry.text }); + if (entry.text.length === 0) return; + if (this.transcriptStore === null) { + // Population adapters already sanitize visible output. A setup + // check or an agent with no retained output has no transcript. + const transcriptId = randomUUID(); + const store = new SetupTranscriptStore(transcriptId, this.now); + store.append({ kind: entry.kind, text: entry.text }); + if (store.stats().retainedEntries === 0) return; + this.transcriptStore = store; + this.run = { ...this.run, transcriptId }; + // Announce only after the first page is readable, even if no + // subsequent progress event arrives to wake setup subscribers. + this.emit(); + return; + } + this.transcriptStore.append({ kind: entry.kind, text: entry.text }); if (this.transcriptTimer === null) { this.transcriptTimer = setTimeout(() => this.notifyTranscript(), TRANSCRIPT_EMIT_INTERVAL_MS); this.transcriptTimer.unref(); @@ -366,15 +382,16 @@ export class HubSetupRunner { await this.finishFromResult(result, signal, request.openHub === true); } catch (error) { // Child output and arbitrary filesystem exceptions are never a browser payload. - const message = signal.aborted ? "Setup was cancelled. You can resume it when ready." + const message = (signal.aborted ? "Setup was cancelled. You can resume it when ready." + : error instanceof SetupError ? error.userMessage : error instanceof SetupPopulationError || error instanceof SetupFinalizationError || error instanceof HubHttpError ? error.message - : "Setup could not finish. Run mex setup --cli in this project for details, then retry."; + : "Setup could not finish. Run mex setup --cli in this project for details, then retry.").slice(0, 512); this.run = { ...this.run, status: signal.aborted ? "cancelled" : "failed", ready: false, message, - error: signal.aborted ? null : message.slice(0, 512), + error: signal.aborted ? null : message, finishedAt: this.now().toISOString(), progress: this.run.progress, }; diff --git a/src/setup/__tests__/agent-errors.test.ts b/src/setup/__tests__/agent-errors.test.ts new file mode 100644 index 00000000..14edc51e --- /dev/null +++ b/src/setup/__tests__/agent-errors.test.ts @@ -0,0 +1,145 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + AgentAssetsError, + applyAgentAssetsPlan, + planAgentAssets, + type AgentAssetsErrorCode, +} from "../../agent-skills/installer.js"; +import { classifySetupAgentAssetsError } from "../agent-errors.js"; +import { SetupError } from "../errors.js"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("setup agent integration error classification", () => { + it.each([ + ["INVALID_PACKAGED_SKILL", "Reinstall MEX"], + ["INVALID_OPTIONS", "Check the project directory"], + ["IGNORE_CHECK_FAILED", "run git status"], + ["CONCURRENT_MODIFICATION", "review the affected files"], + ["PATH_IDENTITY_CHANGED", "reconcile those backups before rerunning setup"], + ["REPLACEMENT_ACTIVE_BACKUP_RETAINED", "reconcile those backups before rerunning setup"], + ["APPLY_FAILED", "reconcile those backups before rerunning setup"], + ] satisfies Array<[AgentAssetsErrorCode, string]>)("classifies %s by code while retaining private diagnostics", (code, recovery) => { + const underlying = new Error("private child output and secret token"); + const original = new AgentAssetsError(code, "failure at /private/project/secret-path", { cause: underlying }); + + const classified = classifySetupAgentAssetsError(original); + + expect(classified).toBeInstanceOf(SetupError); + expect(classified?.message).toBe(original.message); + expect(classified?.cause).toBe(original); + expect(original.cause).toBe(underlying); + expect(classified?.userMessage).toContain(recovery); + expect(classified?.userMessage).not.toMatch(/private|secret|token/); + expect(classified?.userMessage.length).toBeLessThanOrEqual(512); + const differentText = new AgentAssetsError(code, "unrelated diagnostic"); + expect(classifySetupAgentAssetsError(differentText)?.userMessage).toBe(classified?.userMessage); + }); + + it("preserves rollback diagnostics without recommending an unreviewed retry", () => { + const rollback = new AggregateError([ + new Error("activation failed at /private/project"), + new Error("backup retained at /private/backup"), + ]); + const original = new AgentAssetsError("APPLY_FAILED", "Could not restore /private/backup", { cause: rollback }); + + const classified = classifySetupAgentAssetsError(original); + + expect(classified?.cause).toBe(original); + expect(original.cause).toBe(rollback); + expect(classified?.userMessage).toContain("some files or backups may remain"); + expect(classified?.userMessage).toContain("reconcile those backups before rerunning setup"); + expect(classified?.userMessage).toContain(".mex-backup-"); + expect(classified?.userMessage).toContain(".claude/skills or .agents/skills"); + expect(classified?.userMessage).not.toContain("terminal diagnostics"); + expect(classified?.userMessage).not.toContain("/private/"); + }); + + it.each(["EACCES", "ENOSPC"])("delegates raw %s failures to the filesystem classifier", (code) => { + const original = Object.assign(new Error("private filesystem diagnostic"), { code }); + + const classified = classifySetupAgentAssetsError(original); + + expect(classified?.message).toBe(original.message); + expect(classified?.cause).toBe(original); + expect(classified?.userMessage).toContain("Could not install the selected agent integrations"); + expect(classified?.userMessage).toContain(code === "EACCES" ? "permissions" : "Storage is full"); + expect(classified?.userMessage).not.toContain("private"); + }); + + it.each([ + new Error("INVALID_PACKAGED_SKILL: missing packaged skills"), + Object.assign(new Error("pretended installer failure"), { code: "INVALID_PACKAGED_SKILL" }), + { name: "AgentAssetsError", code: "APPLY_FAILED", message: "pretended installer failure" }, + new AgentAssetsError("UNKNOWN_PLAN", "private internal diagnostic"), + new AgentAssetsError("DRY_RUN_PLAN", "private internal diagnostic"), + new AgentAssetsError("FUTURE_CODE" as AgentAssetsErrorCode, "private future diagnostic"), + null, + ])("leaves unrecognized failures for the generic fallback: %j", (error) => { + expect(classifySetupAgentAssetsError(error)).toBeNull(); + }); + + it.each(["missing", "invalid"])("classifies a real %s packaged-skills failure", (kind) => { + const root = fixture(); + const source = join(root, "packaged-skills"); + if (kind === "invalid") { + mkdirSync(join(source, "mex-inbox"), { recursive: true }); + writeFileSync(join(source, "mex-inbox", "README.md"), "No SKILL.md\n"); + } + const original = captureInstallerError(() => planAgentAssets({ + projectRoot: root, packagedSkillsRoot: source, packageVersion: "1.0.0", + clients: ["claude"], checkIgnored: false, + })); + + const classified = classifySetupAgentAssetsError(original); + + expect(original.code).toBe("INVALID_PACKAGED_SKILL"); + expect(classified?.cause).toBe(original); + expect(classified?.userMessage).toContain("packaged MEX agent skills are missing or invalid"); + expect(classified?.userMessage).not.toContain(root); + }); + + it("classifies a real change between installation preview and apply", () => { + const root = fixture(); + const source = join(root, "packaged-skills"); + for (const skill of ["mex-inbox", "mex-relay"]) { + mkdirSync(join(source, skill), { recursive: true }); + writeFileSync(join(source, skill, "SKILL.md"), "Packaged skill\n"); + } + const plan = planAgentAssets({ + projectRoot: root, packagedSkillsRoot: source, packageVersion: "1.0.0", + clients: ["claude"], checkIgnored: false, + }); + mkdirSync(join(root, ".claude/skills/mex-inbox"), { recursive: true }); + writeFileSync(join(root, ".claude/skills/mex-inbox/SKILL.md"), "Concurrent authored skill\n"); + const original = captureInstallerError(() => applyAgentAssetsPlan(plan)); + + const classified = classifySetupAgentAssetsError(original); + + expect(original.code).toBe("CONCURRENT_MODIFICATION"); + expect(classified?.userMessage).toContain("Finish or stop other edits"); + expect(classified?.cause).toBe(original); + }); +}); + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "mex-setup-agent-errors-")); + roots.push(root); + return root; +} + +function captureInstallerError(run: () => unknown): AgentAssetsError { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(AgentAssetsError); + return error as AgentAssetsError; + } + throw new Error("Expected an agent installer failure."); +} diff --git a/src/setup/__tests__/finalization-errors.test.ts b/src/setup/__tests__/finalization-errors.test.ts new file mode 100644 index 00000000..2f445ea9 --- /dev/null +++ b/src/setup/__tests__/finalization-errors.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SetupWikiFinalizationResult } from "../wiki-finalize.js"; +import { WikiMaintenanceLockedError } from "../../wiki/index/dbfile.js"; +import { SetupError } from "../errors.js"; + +const mocks = vi.hoisted(() => ({ capture: vi.fn(), wiki: vi.fn() })); +vi.mock("../../graph/runtime.js", async (original) => ({ ...await original(), captureGroundingBaselines: mocks.capture })); +vi.mock("../wiki-finalize.js", () => ({ finalizeSetupWiki: mocks.wiki })); +vi.mock("../../config.js", async (original) => ({ ...await original(), findConfig: () => ({ wiki: {} }) })); + +import { finalizeCodeRepoSetup, SetupFinalizationError } from "../index.js"; + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + mocks.capture.mockResolvedValue({ captured: 0, skipped: 0 }); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("setup finalization expected failures", () => { + it("preserves a graph failure locally while reporting actionable grounding advice", async () => { + mocks.capture.mockRejectedValue(Object.assign(new Error("secret diagnostic /private/project"), { code: "GRAPH_MAINTENANCE_LOCKED" })); + const failure = await finalizeCodeRepoSetup("/project", "/project/.mex").catch(error => error); + expect(failure).toBeInstanceOf(SetupError); + expect(failure.message).toContain("secret diagnostic"); + expect(failure.userMessage).toContain("Another graph operation is active"); + expect(failure.userMessage).not.toContain("secret"); + expect(mocks.wiki).not.toHaveBeenCalled(); + }); + + it("keeps an unexpected grounding exception out of the browser allowlist", async () => { + mocks.capture.mockRejectedValue(new Error("secret unexpected exception")); + const failure = await finalizeCodeRepoSetup("/project", "/project/.mex").catch(error => error); + expect(failure).not.toBeInstanceOf(SetupError); + expect(failure.message).toContain("secret unexpected exception"); + }); + + it("projects a thrown Wiki lock refusal", async () => { + mocks.wiki.mockRejectedValue(new WikiMaintenanceLockedError("/private/project/wiki.db.lock")); + const failure = await finalizeCodeRepoSetup("/project", "/project/.mex").catch(error => error); + expect(failure).toBeInstanceOf(SetupError); + expect(failure.userMessage).toContain("Another Wiki operation is active"); + expect(failure.userMessage).not.toContain("/private"); + }); + + it("retains the finalization error type and reports a Wiki diagnostic's authored fix", async () => { + mocks.wiki.mockResolvedValue({ ready: false, stage: "index", reason: "Wiki index rebuild failed validation.", + diagnostics: [{ code: "WIKI_INDEX_FTS5_UNAVAILABLE", severity: "error", message: "private database error", remediation: "private override" }], + } as SetupWikiFinalizationResult); + const failure = await finalizeCodeRepoSetup("/project", "/project/.mex").catch(error => error); + expect(failure).toBeInstanceOf(SetupFinalizationError); + expect(failure).toBeInstanceOf(SetupError); + expect(failure.message).toContain("WIKI_INDEX_FTS5_UNAVAILABLE"); + expect(failure.userMessage).toContain("use a different Node build/version"); + expect(failure.userMessage).toContain("Retry setup"); + expect(failure.userMessage).not.toContain("private"); + expect(failure.userMessage.length).toBeLessThanOrEqual(512); + }); + + it("preserves the existing grounding-remediation contract", async () => { + mocks.capture.mockImplementation(async (_config, options) => { + options.warn("Skipped grounding baseline for unavailable node in .mex/AGENTS.md."); + return { captured: 0, skipped: 1 }; + }); + const failure = await finalizeCodeRepoSetup("/project", "/project/.mex").catch(error => error); + expect(failure).toBeInstanceOf(SetupFinalizationError); + expect(failure.message).toContain(" in .mex/AGENTS.md"); + expect(failure.userMessage).toBe(failure.message); + expect(failure.message.length).toBeLessThanOrEqual(512); + }); +}); diff --git a/src/setup/__tests__/graph-progress-integration.test.ts b/src/setup/__tests__/graph-progress-integration.test.ts new file mode 100644 index 00000000..f1e02b48 --- /dev/null +++ b/src/setup/__tests__/graph-progress-integration.test.ts @@ -0,0 +1,72 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { GraphMaintenanceProgress } from "../../team/contracts/graph.js"; +import { runHeadlessSetup, type SetupProgressStep } from "../headless.js"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("real setup graph progress", () => { + it("forwards isolated worker phases and file counts while the parent remains responsive", async () => { + const root = fixture(); + const setupSteps: SetupProgressStep[] = []; + const updates: GraphMaintenanceProgress[] = []; + let parsing = false; + let beatsDuringParsing = 0; + const heartbeat = setInterval(() => { if (parsing) beatsDuringParsing++; }, 5); + let result: Awaited>; + try { + result = await runHeadlessSetup({ + projectRoot: root, mode: "code-repo", tools: [], confirmPopulation: true, + onProgress: ({ step }) => { setupSteps.push(step); }, + onGraphProgress: (update) => { + expect(setupSteps.at(-1)).toBe("graph"); + updates.push(update); + parsing = update.phase === "parse"; + }, + }); + } finally { + clearInterval(heartbeat); + } + expect(result).toMatchObject({ mode: "code-repo", ready: false, stage: "needs_population" }); + expect(setupSteps.filter(step => step === "graph")).toHaveLength(1); + expect(setupSteps.at(-1)).toBe("population"); + expect(updates.map(update => update.phase)).toEqual(expect.arrayContaining(["discover", "stage", "parse", "resolve", "validate", "publish"])); + const parsed = updates.filter(update => update.phase === "parse"); + expect(parsed[0]).toMatchObject({ completed: 0, total: 3 }); + expect(parsed.at(-1)).toMatchObject({ completed: 3, total: 3 }); + expect(parsed.every((update, index) => index === 0 || update.completed! >= parsed[index - 1].completed!)).toBe(true); + expect(beatsDuringParsing).toBeGreaterThan(0); + expect(JSON.stringify(updates)).not.toContain(root); + expect(JSON.stringify(updates)).not.toContain("private-calculation.ts"); + expect(existsSync(join(root, ".mex/graph.db"))).toBe(true); + expect(existsSync(join(root, ".mex/wiki.db"))).toBe(false); + expect(readdirSync(join(root, ".mex")).filter(name => name.includes("candidate") || name.endsWith(".lock"))).toEqual([]); + expect(() => execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, stdio: "ignore" })).toThrow(); + }); + + it("does not report graph work for agent-memory setup", async () => { + const root = fixture(); + const updates: GraphMaintenanceProgress[] = []; + await runHeadlessSetup({ projectRoot: root, mode: "agent-memory", tools: [], confirmPopulation: true, + onGraphProgress: update => updates.push(update) }); + expect(updates).toEqual([]); + expect(existsSync(join(root, ".mex/graph.db"))).toBe(false); + }); +}); + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "mex-setup-progress-")); + roots.push(root); + execFileSync("git", ["-c", "init.templateDir=", "init", "--quiet"], { cwd: root, stdio: "pipe" }); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/private-calculation.ts"), "export function double(value: number): number { return value * 2; }\n"); + writeFileSync(join(root, "src/message.ts"), "export const prefix = 'Result';\n"); + writeFileSync(join(root, "src/index.ts"), "import { double } from './private-calculation';\nimport { prefix } from './message';\nexport const message = `${prefix}: ${double(21)}`;\n"); + return root; +} diff --git a/src/setup/__tests__/graph-progress.test.ts b/src/setup/__tests__/graph-progress.test.ts new file mode 100644 index 00000000..3f40db25 --- /dev/null +++ b/src/setup/__tests__/graph-progress.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { formatSetupGraphActivity, setupGraphActivity } from "../graph-progress.js"; + +describe("setup graph activity", () => { + it("labels parsed file counts without making a whole-setup percentage claim", () => { + expect(setupGraphActivity({ phase: "parse", completed: 12, total: 40, message: "private source path" })).toEqual({ + detail: "Parsing source files", progress: { completed: 12, total: 40, unit: "files parsed" }, + }); + expect(formatSetupGraphActivity({ phase: "parse", completed: 12, total: 40, message: "private source path" })) + .toBe("Parsing source files: 12 / 40 files parsed"); + }); + + it("clears completed parse counts during resolve, validation, and publication", () => { + for (const phase of ["resolve", "validate", "publish"] as const) { + const activity = setupGraphActivity({ phase, completed: 40, total: 40, message: "private path or command" }); + expect(activity.progress).toBeUndefined(); + expect(activity.detail).not.toContain("private"); + } + }); + + it("does not fabricate counts or a total and refuses invalid measurements", () => { + expect(setupGraphActivity({ phase: "parse", message: "raw" })).toEqual({ detail: "Parsing source files" }); + expect(setupGraphActivity({ phase: "parse", completed: 0, message: "raw" }).progress).toEqual({ completed: 0, unit: "files parsed" }); + for (const completed of [NaN, Infinity, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(setupGraphActivity({ phase: "parse", completed, total: 3, message: "raw" }).progress).toBeUndefined(); + } + expect(setupGraphActivity({ phase: "parse", completed: 10, total: 2, message: "raw" }).progress).toEqual({ completed: 10, unit: "files parsed" }); + }); +}); diff --git a/src/setup/__tests__/headless.test.ts b/src/setup/__tests__/headless.test.ts index 6553e202..d4887182 100644 --- a/src/setup/__tests__/headless.test.ts +++ b/src/setup/__tests__/headless.test.ts @@ -8,6 +8,8 @@ import * as population from "../headless-population.js"; import { runSetup } from "../index.js"; import { MEX_ANCHOR_START } from "../anchor.js"; import { loadConfiguredAiTools, loadConfiguredSetupMode, hasConfiguredAiTools } from "../../config.js"; +import * as config from "../../config.js"; +import { SetupError } from "../errors.js"; const roots: string[] = []; @@ -60,6 +62,47 @@ describe("headless setup status", () => { }); describe("headless setup run", () => { + it("returns an actionable expected error for malformed config without replacing it", async () => { + const root = fixture(); + mkdirSync(join(root, ".mex")); + writeFileSync(join(root, ".mex/config.json"), "{broken"); + await expect(runHeadlessSetup({ projectRoot: root, mode: "agent-memory", tools: [] })).rejects.toMatchObject({ + userMessage: "Existing .mex/config.json is not a valid JSON object. Fix it before rerunning setup.", + }); + expect(readFileSync(join(root, ".mex/config.json"), "utf8")).toBe("{broken"); + }); + + it("identifies official skill conflicts without replacing user-owned files", async () => { + const root = fixture(); + const skill = join(root, ".agents/skills/mex-inbox"); + mkdirSync(skill, { recursive: true }); + writeFileSync(join(skill, "SKILL.md"), "User-owned skill"); + await expect(runHeadlessSetup({ projectRoot: root, mode: "agent-memory", tools: ["codex"] })).rejects.toMatchObject({ + userMessage: expect.stringContaining("Check .agents/skills/mex-inbox"), + }); + expect(readFileSync(join(skill, "SKILL.md"), "utf8")).toBe("User-owned skill"); + }); + + it("explains a config write failure without leaking raw filesystem diagnostics", async () => { + const root = fixture(); + const cause = Object.assign(new Error("private path /secret/config"), { code: "ENOSPC" }); + vi.spyOn(config, "saveAiTools").mockImplementation(() => { throw cause; }); + const error = await runHeadlessSetup({ projectRoot: root, mode: "agent-memory", tools: [] }).catch((error: unknown) => error); + expect(error).toBeInstanceOf(SetupError); + expect(error).toMatchObject({ cause, message: cause.message }); + expect((error as SetupError).userMessage).toContain("Could not save the AI tool selection in .mex/config.json."); + expect((error as SetupError).userMessage).toContain("Free disk space"); + expect((error as SetupError).userMessage).not.toMatch(/private|secret/); + }); + + it("preserves the existing persistence check as an expected error", async () => { + const root = fixture(); + vi.spyOn(config, "readScaffoldId").mockReturnValue(undefined); + await expect(runHeadlessSetup({ projectRoot: root, mode: "agent-memory", tools: [] })).rejects.toMatchObject({ + userMessage: "Could not persist .mex/config.json. Fix its permissions or contents and rerun setup.", + }); + }); + it("delivers the real manual prompt and integration guidance before a population failure", async () => { const root = fixture(); const authoredRules = `My existing agent rules.\n${MEX_ANCHOR_START}\n`; diff --git a/src/setup/__tests__/ignore.test.ts b/src/setup/__tests__/ignore.test.ts index c37a4dd0..5f8bff0e 100644 --- a/src/setup/__tests__/ignore.test.ts +++ b/src/setup/__tests__/ignore.test.ts @@ -1,5 +1,7 @@ import { + appendFileSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -9,8 +11,9 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { execFileSync } from "node:child_process"; -import { afterEach, describe, expect, it } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SetupError } from "../errors.js"; import { ensureSetupIgnoreProtection, renderSetupIgnoreProtection, @@ -18,9 +21,29 @@ import { verifySetupIgnoreProtection, } from "../ignore.js"; +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appendFileSync: vi.fn(actual.appendFileSync), + lstatSync: vi.fn(actual.lstatSync), + mkdirSync: vi.fn(actual.mkdirSync), + readFileSync: vi.fn(actual.readFileSync), + writeFileSync: vi.fn(actual.writeFileSync), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawnSync: vi.fn(actual.spawnSync) }; +}); + const roots: string[] = []; afterEach(() => { + for (const mocked of [appendFileSync, lstatSync, mkdirSync, readFileSync, writeFileSync, spawnSync]) { + vi.mocked(mocked).mockReset(); + } for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -128,9 +151,11 @@ describe("setup local-data ignore protection", () => { writeFileSync(outside, "outside\n"); symlinkSync(outside, join(root, ".mex/.gitignore"), "file"); - expect(() => ensureSetupIgnoreProtection({ projectRoot: root })).toThrow( - SetupIgnoreProtectionError, - ); + const error = ignoreError(() => ensureSetupIgnoreProtection({ projectRoot: root })); + expect(error.message).toContain(root); + expect(error.userMessage).toContain(".mex/.gitignore path must be a regular file"); + expect(error.userMessage).not.toContain(root); + expect(error.userMessage).not.toContain(outside); expect(readFileSync(outside, "utf8")).toBe("outside\n"); }); @@ -143,6 +168,72 @@ describe("setup local-data ignore protection", () => { ); }); + it.each(["file", "symlink"] as const)("explains an unsafe .mex %s without exposing its absolute path", (kind) => { + const root = fixture(); + const outside = fixture(); + if (kind === "file") writeFileSync(join(root, ".mex"), "existing file\n"); + else symlinkSync(outside, join(root, ".mex"), process.platform === "win32" ? "junction" : "dir"); + + const error = ignoreError(() => ensureSetupIgnoreProtection({ projectRoot: root })); + + expect(error.message).toContain(root); + expect(error.userMessage).toBe("The .mex path must be a regular directory, not a symlink. Fix that path, then rerun setup."); + expect(error.userMessage).not.toContain(outside); + expect(existsSync(join(outside, ".gitignore"))).toBe(false); + }); + + it("explains an invalid project root without exposing its absolute path", () => { + const root = join(fixture(), "not-a-directory"); + writeFileSync(root, "existing file\n"); + + const error = ignoreError(() => ensureSetupIgnoreProtection({ projectRoot: root })); + + expect(error.message).toContain(root); + expect(error.userMessage).toContain("Run setup from a valid project directory"); + expect(error.userMessage).not.toContain(root); + expect(readFileSync(root, "utf8")).toBe("existing file\n"); + }); + + it.each(["inspect", "read", "create", "append", "write"] as const)("keeps %s failure diagnostics private and retains their cause", (operation) => { + const root = operation === "read" || operation === "append" ? fixtureWithMex() : fixture(); + if (operation === "read" || operation === "append") writeIgnore(root, "# unchanged\n"); + const cause = Object.assign(new Error(`EACCES private details at ${root}`), { code: "EACCES" }); + const fail = () => { throw cause; }; + if (operation === "inspect") vi.mocked(lstatSync).mockImplementationOnce(fail); + if (operation === "read") vi.mocked(readFileSync).mockImplementationOnce(fail); + if (operation === "create") vi.mocked(mkdirSync).mockImplementationOnce(fail); + if (operation === "append") vi.mocked(appendFileSync).mockImplementationOnce(fail); + if (operation === "write") vi.mocked(writeFileSync).mockImplementationOnce(fail); + + const error = ignoreError(() => ensureSetupIgnoreProtection({ projectRoot: root })); + + expect(error.cause).toBe(cause); + expect(error.message).toContain(root); + if (operation === "read") expect(error.message).toBe(cause.message); + expect(error.userMessage).not.toContain(root); + expect(error.userMessage).not.toContain("private details"); + expect(error.userMessage).toMatch(/permissions.*retry setup/u); + if (operation === "read" || operation === "append") expect(readIgnore(root)).toBe("# unchanged\n"); + }); + + it.each([ + ["ENOSPC", "Storage is full"], + ["EROFS", "filesystem is read-only"], + ])("uses the %s recovery guidance for ignore-file writes", (code, recovery) => { + const root = fixtureWithMex(); + writeIgnore(root, "# unchanged\n"); + const cause = Object.assign(new Error(`private filesystem failure at ${root}`), { code }); + vi.mocked(appendFileSync).mockImplementationOnce(() => { throw cause; }); + + const error = ignoreError(() => ensureSetupIgnoreProtection({ projectRoot: root })); + + expect(error.cause).toBe(cause); + expect(error.userMessage).toContain(recovery); + expect(error.userMessage).not.toContain("permissions"); + expect(error.userMessage).not.toContain(root); + expect(readIgnore(root)).toBe("# unchanged\n"); + }); + it("verifies derived files are ignored and canonical config remains trackable", () => { const root = fixture(); initGit(root); @@ -157,10 +248,69 @@ describe("setup local-data ignore protection", () => { ensureSetupIgnoreProtection({ projectRoot: root }); writeFileSync(join(root, ".gitignore"), ".mex/\n", "utf8"); - expect(() => verifySetupIgnoreProtection(root)).toThrow(/hides \.mex\/config\.json/u); + const error = ignoreError(() => verifySetupIgnoreProtection(root)); + expect(error.message).toMatch(/hides \.mex\/config\.json/u); + expect(error.userMessage).toBe(error.message); + expect(error.userMessage).toContain("Remove the broad .mex ignore"); + expect(error.userMessage).not.toContain(root); + }); + + it("explains how to repair a negation that exposes local MEX data", () => { + const root = fixture(); + initGit(root); + ensureSetupIgnoreProtection({ projectRoot: root }); + writeIgnore(root, "graph.db*\nwiki.db*\nlocal/\n!graph.db\n"); + + const error = ignoreError(() => verifySetupIgnoreProtection(root)); + + expect(error.message).toBe("Local MEX path is not ignored by Git: .mex/graph.db"); + expect(error.userMessage).toContain("Check .mex/.gitignore and conflicting negation rules"); + expect(error.userMessage).not.toContain(root); + }); + + it("reports a Git launch failure with remediation instead of its private cause", () => { + const root = fixture(); + const cause = Object.assign(new Error(`spawn git ENOENT private ${root}`), { code: "ENOENT" }); + vi.mocked(spawnSync).mockReturnValueOnce({ + pid: 0, output: [], stdout: "", stderr: "", status: null, signal: null, error: cause, + }); + + const error = ignoreError(() => verifySetupIgnoreProtection(root)); + + expect(error.message).toBe("Could not run Git to verify local MEX data protection."); + expect(error.cause).toBe(cause); + expect(error.userMessage).toContain("Git is installed and available on PATH"); + expect(error.userMessage).not.toContain(root); + }); + + it("preserves terminal Git stderr while exposing only authored browser guidance", () => { + const root = fixture(); + const stderr = `fatal: private token and path ${root}\n`; + vi.mocked(spawnSync).mockReturnValueOnce({ + pid: 123, output: [], stdout: "", stderr, status: 128, signal: null, + }); + + const error = ignoreError(() => verifySetupIgnoreProtection(root)); + + expect(error.message).toBe(`Git could not verify local MEX data protection: ${stderr.trim()}`); + expect(error.userMessage).toContain("valid Git repository"); + expect(error.userMessage).toContain("git status"); + expect(error.userMessage).not.toContain(root); + expect(error.userMessage).not.toContain("private token"); }); }); +function ignoreError(run: () => unknown): SetupIgnoreProtectionError { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(SetupIgnoreProtectionError); + expect(error).toBeInstanceOf(SetupError); + return error as SetupIgnoreProtectionError; + } + throw new Error("Expected an ignore-protection failure."); +} + function fixture(): string { const root = mkdtempSync(join(tmpdir(), "mex-setup-ignore-")); roots.push(root); diff --git a/src/setup/__tests__/interactive-population.test.ts b/src/setup/__tests__/interactive-population.test.ts new file mode 100644 index 00000000..5b863b71 --- /dev/null +++ b/src/setup/__tests__/interactive-population.test.ts @@ -0,0 +1,298 @@ +import crossSpawn from "cross-spawn"; +import { execFileSync, type ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { launchInteractiveSetupPopulation, type InteractivePopulationOptions } from "../interactive-population.js"; + +const roots: string[] = []; +const emergencyCleanup = new Set(); +// The sandbox may explicitly disallow ps. Keep unit coverage there, and run this +// real ancestry adapter scenario where the OS permits process enumeration. +const canInspectProcesses = (() => { + if (process.platform === "win32") return false; + try { execFileSync("ps", ["-o", "pid=", "-p", String(process.pid)], { stdio: "ignore" }); return true; } + catch { return false; } +})(); + +afterEach(() => { + vi.restoreAllMocks(); + for (const pid of emergencyCleanup) { try { process.kill(pid, "SIGKILL"); } catch { /* Already gone. */ } } + emergencyCleanup.clear(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "mex-native-population-")); + roots.push(root); + mkdirSync(join(root, ".mex")); + return root; +} + +function fakeChild(pid = 912_345): ChildProcess { + return Object.assign(new EventEmitter(), { pid, kill: vi.fn(() => true) }) as unknown as ChildProcess; +} + +function exited(child: ChildProcess, code: number | null, signal: NodeJS.Signals | null = null): void { + child.emit("exit", code, signal); + child.emit("close", code, signal); +} + +function launchOptions(projectRoot: string, child: ChildProcess, afterSpawn?: (args: readonly string[]) => void): InteractivePopulationOptions { + return { + projectRoot, selectedTools: ["codex"], prompt: "Private project context", + __internal: { + isAvailable: async () => true, + snapshot: async () => [], + spawn: vi.fn((_command, args) => { + queueMicrotask(() => afterSpawn?.(args as string[])); + return child; + }) as unknown as typeof crossSpawn, + }, + }; +} + +describe("native interactive setup population", () => { + it("inherits the foreground terminal and keeps the complete private prompt off argv", async () => { + const root = fixture(); + const child = fakeChild(); + const prompt = "Private context\n".repeat(4_096); + const options = launchOptions(root, child, (args) => { + expect(args).toHaveLength(1); + expect(args[0]!.length).toBeLessThan(256); + const pointer = /`([^`]+)`/u.exec(args[0]!)![1]!; + const file = join(root, pointer); + expect(readFileSync(file, "utf8")).toBe(prompt); + if (process.platform !== "win32") expect(statSync(file).mode & 0o777).toBe(0o600); + exited(child, 0); + }); + const result = await launchInteractiveSetupPopulation({ ...options, prompt }); + expect(result).toEqual({ tool: "codex", status: "exited", exitCode: 0, signal: null }); + expect(options.__internal!.spawn).toHaveBeenCalledWith("codex", expect.any(Array), { + cwd: root, stdio: "inherit", detached: false, + }); + expect(readdirSync(join(root, ".mex/local"))).toEqual([]); + }); + + it("respects selection order and never launches unselected CLIs", async () => { + const root = fixture(); + const child = fakeChild(); + const available = vi.fn(async (command: string) => command === "claude"); + const options = launchOptions(root, child, () => exited(child, 0)); + const result = await launchInteractiveSetupPopulation({ ...options, selectedTools: ["cursor", "codex", "claude"], + __internal: { ...options.__internal, isAvailable: available } }); + expect(available.mock.calls).toEqual([["codex"], ["claude"]]); + expect(result.tool).toBe("claude"); + expect(options.__internal!.spawn).toHaveBeenCalledWith("claude", expect.any(Array), expect.any(Object)); + expect(await launchInteractiveSetupPopulation({ ...options, selectedTools: ["cursor"] })).toMatchObject({ tool: null, status: "unavailable" }); + }); + + it("does not create a prompt when no selected CLI is available", async () => { + const root = fixture(); + const options = launchOptions(root, fakeChild()); + expect(await launchInteractiveSetupPopulation({ ...options, + __internal: { ...options.__internal, isAvailable: async () => false } })).toMatchObject({ status: "unavailable", tool: null }); + expect(options.__internal!.spawn).not.toHaveBeenCalled(); + expect(readdirSync(join(root, ".mex"))).toEqual([]); + }); + + it("returns a safe actionable result if CLI discovery throws", async () => { + const root = fixture(); + const options = launchOptions(root, fakeChild()); + const result = await launchInteractiveSetupPopulation({ ...options, __internal: { + ...options.__internal, isAvailable: async () => { throw new Error("Private process diagnostics"); }, + } }); + expect(result).toMatchObject({ status: "failed", tool: null, message: expect.stringContaining("PATH") }); + expect(result.message).not.toContain("Private"); + expect(options.__internal!.spawn).not.toHaveBeenCalled(); + }); + + it.each([ + { code: 7, signal: null, status: "failed" }, + { code: null, signal: "SIGTERM", status: "failed" }, + { code: null, signal: "SIGINT", status: "cancelled" }, + ] as const)("classifies native exit $code / $signal as $status", async ({ code, signal, status }) => { + const root = fixture(); + const child = fakeChild(); + expect(await launchInteractiveSetupPopulation(launchOptions(root, child, () => exited(child, code, signal)))) + .toMatchObject({ tool: "codex", status, exitCode: code, signal }); + expect(readdirSync(join(root, ".mex/local"))).toEqual([]); + }); + + it.each(["throw", "event"] as const)("settles a spawn %s and removes the prompt", async (failure) => { + const root = fixture(); + const child = fakeChild(); + const options = launchOptions(root, child, () => { + child.emit("error", new Error("Private diagnostic")); + exited(child, -2); + }); + const result = await launchInteractiveSetupPopulation(failure === "throw" ? { ...options, __internal: { + ...options.__internal, spawn: vi.fn(() => { throw new Error("Private diagnostic"); }) as unknown as typeof crossSpawn, + } } : options); + expect(result).toMatchObject({ tool: "codex", status: "failed", message: expect.stringContaining("could not start") }); + expect(result.message).not.toContain("Private diagnostic"); + expect(readdirSync(join(root, ".mex/local"))).toEqual([]); + }); + + it.each(["before discovery", "during discovery", "during spawn"] as const)("honors cancellation %s", async (timing) => { + const root = fixture(); + const abort = new AbortController(); + const child = fakeChild(); + vi.mocked(child.kill).mockImplementation((signal) => { queueMicrotask(() => exited(child, null, signal as NodeJS.Signals)); return true; }); + const options = launchOptions(root, child); + if (timing === "before discovery") abort.abort(); + const result = await launchInteractiveSetupPopulation({ ...options, signal: abort.signal, __internal: { + ...options.__internal, + isAvailable: async () => { if (timing === "during discovery") abort.abort(); return true; }, + spawn: ((_command, _args, _options) => { abort.abort(); return child; }) as typeof crossSpawn, + } }); + expect(result.status).toBe("cancelled"); + expect(existsSync(join(root, ".mex/local")) ? readdirSync(join(root, ".mex/local")) : []).toEqual([]); + if (timing !== "during spawn") expect(child.kill).not.toHaveBeenCalled(); + }); + + it("returns an actionable failure when the private prompt cannot be prepared", async () => { + const root = fixture(); + writeFileSync(join(root, ".mex/local"), "User-owned file"); + const options = launchOptions(root, fakeChild()); + expect(await launchInteractiveSetupPopulation(options)).toMatchObject({ status: "failed", message: expect.stringContaining("could not be prepared") }); + expect(options.__internal!.spawn).not.toHaveBeenCalled(); + expect(readFileSync(join(root, ".mex/local"), "utf8")).toBe("User-owned file"); + }); +}); + +describe.skipIf(process.platform === "win32")("interactive process ownership", () => { + it.each([false, true])("cleans observed helpers after a clean agent exit (signal failure: %s)", async (denied) => { + const root = fixture(); + const child = fakeChild(); + let processes = [ + { pid: child.pid!, parentPid: process.pid, started: "root" }, + { pid: child.pid! + 1, parentPid: child.pid!, started: "helper" }, + ]; + const snapshot = vi.fn(async () => processes); + const kill = vi.fn(() => { + if (denied) throw Object.assign(new Error("Private diagnostic"), { code: "EPERM" }); + processes = []; + }); + const options = launchOptions(root, child); + const resultPromise = launchInteractiveSetupPopulation({ ...options, __internal: { ...options.__internal, snapshot, kill } }); + await vi.waitFor(() => expect(snapshot).toHaveBeenCalled()); + processes = [{ ...processes[1]!, parentPid: 1 }]; + exited(child, 0); + const result = await resultPromise; + expect(result.status).toBe(denied ? "failed" : "exited"); + if (denied) expect(result.message).toContain("could not verify"); + expect(kill).toHaveBeenCalledWith(child.pid! + 1, "SIGTERM"); + expect(readdirSync(join(root, ".mex/local"))).toEqual([]); + }); + + it("escalates only matching observed descendants and keeps the prompt until cleanup finishes", async () => { + const root = fixture(); + const abort = new AbortController(); + const child = fakeChild(); + const rootProcess = { pid: child.pid!, parentPid: process.pid, started: "root-start" }; + const descendant = { pid: child.pid! + 1, parentPid: child.pid!, started: "child-start" }; + const grandchild = { pid: child.pid! + 2, parentPid: descendant.pid, started: "grandchild-start" }; + const sibling = { pid: child.pid! + 3, parentPid: process.pid, started: "sibling-start" }; + let processes = [rootProcess, descendant, grandchild, sibling]; + const kill = vi.fn(); + vi.mocked(child.kill).mockImplementation((signal) => { + expect(readdirSync(join(root, ".mex/local"))).toHaveLength(1); + if (signal === "SIGTERM") { + processes = [{ ...descendant, parentPid: 1, started: "reused-pid" }, { ...grandchild, parentPid: 1 }, sibling]; + exited(child, null, "SIGTERM"); + } + return true; + }); + const options = launchOptions(root, child); + const resultPromise = launchInteractiveSetupPopulation({ ...options, signal: abort.signal, __internal: { + ...options.__internal, snapshot: async () => processes, kill, + } }); + await vi.waitFor(() => expect(options.__internal!.spawn).toHaveBeenCalled()); + abort.abort(); + const result = await resultPromise; + expect(result.status).toBe("cancelled"); + expect(kill.mock.calls).toEqual([[grandchild.pid, "SIGTERM"], [descendant.pid, "SIGTERM"], [grandchild.pid, "SIGKILL"]]); + expect(readdirSync(join(root, ".mex/local"))).toEqual([]); + }); + + it("does not signal stale descendant identities if process enumeration fails", async () => { + const root = fixture(); + const child = fakeChild(); + const abort = new AbortController(); + let enumerationFails = false; + const snapshot = vi.fn(async () => enumerationFails ? null : [ + { pid: child.pid!, parentPid: process.pid, started: "root" }, + { pid: child.pid! + 1, parentPid: child.pid!, started: "child" }, + ]); + const kill = vi.fn(); + vi.mocked(child.kill).mockImplementation((signal) => { exited(child, null, signal as NodeJS.Signals); return true; }); + const options = launchOptions(root, child); + const resultPromise = launchInteractiveSetupPopulation({ ...options, signal: abort.signal, + __internal: { ...options.__internal, snapshot, kill } }); + await vi.waitFor(() => expect(snapshot).toHaveBeenCalled()); + enumerationFails = true; + abort.abort(); + expect(await resultPromise).toMatchObject({ status: "cancelled", message: expect.stringContaining("could not verify") }); + expect(kill).not.toHaveBeenCalled(); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("does not report a clean agent exit when descendant cleanup cannot be verified", async () => { + const root = fixture(); + const child = fakeChild(); + const options = launchOptions(root, child, () => exited(child, 0)); + expect(await launchInteractiveSetupPopulation({ ...options, + __internal: { ...options.__internal, snapshot: async () => { throw new Error("Private diagnostic"); } }, + })).toMatchObject({ status: "failed", exitCode: 0, message: expect.stringContaining("could not verify") }); + expect(readdirSync(join(root, ".mex/local"))).toEqual([]); + }); + + it.skipIf(!canInspectProcesses)("stops a real resistant agent and its grandchild without signalling an unrelated sibling", async () => { + const root = fixture(); + const abort = new AbortController(); + const workerFile = join(root, "worker.cjs"); + writeFileSync(workerFile, "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"); + const agentFile = join(root, "agent.cjs"); + const marker = join(root, "child-pid"); + writeFileSync(agentFile, `const {spawn}=require('node:child_process'); const fs=require('node:fs'); + process.on('SIGTERM', () => {}); + const worker=spawn(process.execPath, [${JSON.stringify(workerFile)}], {stdio:'ignore'}); + fs.writeFileSync(${JSON.stringify(marker)}, String(worker.pid)); setInterval(() => {}, 1000);`); + const sibling = crossSpawn(process.execPath, [workerFile], { stdio: "ignore" }); + emergencyCleanup.add(sibling.pid!); + let agent: ChildProcess | undefined; + const kill = vi.fn((pid: number, signal: NodeJS.Signals) => { process.kill(pid, signal); }); + const options: InteractivePopulationOptions = { + projectRoot: root, selectedTools: ["codex"], prompt: "Private setup", signal: abort.signal, + __internal: { isAvailable: async () => true, kill, spawn: ((_command, _args, spawnOptions) => { + agent = crossSpawn(process.execPath, [agentFile], spawnOptions); + emergencyCleanup.add(agent.pid!); + return agent; + }) as typeof crossSpawn }, + }; + const resultPromise = launchInteractiveSetupPopulation(options); + await vi.waitFor(() => expect(existsSync(marker)).toBe(true)); + const workerPid = Number(readFileSync(marker, "utf8")); + emergencyCleanup.add(workerPid); + // Allow a heartbeat to observe the worker before cancellation reparents it. + await new Promise((resolve) => setTimeout(resolve, 200)); + abort.abort(); + expect(await resultPromise).toMatchObject({ status: "cancelled", signal: "SIGKILL" }); + expect(agent!.exitCode).toBeNull(); + expect(agent!.signalCode).toBe("SIGKILL"); + expect(kill).toHaveBeenCalledWith(workerPid, "SIGTERM"); + expect(kill).toHaveBeenCalledWith(workerPid, "SIGKILL"); + // Some process supervisors defer reaping an orphaned zombie. It cannot run + // or write files, so distinguish it from a surviving worker. + await vi.waitFor(() => { + let status = ""; + try { status = execFileSync("ps", ["-o", "stat=", "-p", String(workerPid)], { encoding: "utf8" }).trim(); } catch { /* Reaped. */ } + expect(status === "" || status.startsWith("Z")).toBe(true); + }); + expect(() => process.kill(sibling.pid!, 0)).not.toThrow(); + expect(readdirSync(join(root, ".mex/local"))).toEqual([]); + }); +}); diff --git a/src/setup/__tests__/maintenance-errors.test.ts b/src/setup/__tests__/maintenance-errors.test.ts new file mode 100644 index 00000000..65bdfaab --- /dev/null +++ b/src/setup/__tests__/maintenance-errors.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { GraphCandidateProcessError } from "../../graph/candidate-process.js"; +import { IndexInUseError, IndexPathError, IndexPublishRecoveryError, WikiMaintenanceLockedError } from "../../wiki/index/dbfile.js"; +import { WikiCorpusLimitError } from "../../wiki/index/corpus-policy.js"; +import { WikiMaintenanceInterruptedError } from "../../wiki/index/maintenance.js"; +import type { WikiDiagnostic } from "../../wiki/model/diagnostic.js"; +import { SetupError } from "../errors.js"; +import { classifySetupMaintenanceError, setupWikiFailureMessage } from "../maintenance-errors.js"; + +const PRIVATE = "private-token /private/project/secret.db"; + +describe("safe setup maintenance failures", () => { + it.each([ + ["GRAPH_MAINTENANCE_LOCKED", "Wait for it to finish"], + ["GRAPH_MAINTENANCE_GATE_STALE", "Verify no MEX process is running"], + ["GRAPH_MAINTENANCE_RACE", "Wait for other writers"], + ["GRAPH_PUBLICATION_FAILED", "recovery files"], + ["GRAPH_INDEX_NOT_REFRESHABLE", "mex graph rebuild"], + ["GRAPH_SOURCE_STAGING_FAILED", "read or parsed safely"], + ])("projects %s by code while preserving its local diagnostic", (code, advice) => { + const original = Object.assign(new Error(PRIVATE), { code, recoveryPath: PRIVATE, diagnostics: [{ message: PRIVATE }] }); + const result = classifySetupMaintenanceError(original, "graph"); + expect(result).toBeInstanceOf(SetupError); + expect(result?.message).toContain(PRIVATE); + expect(result?.cause).toBe(original); + expect(result?.userMessage).toContain(advice); + expect(result?.userMessage).not.toContain(PRIVATE); + expect(result!.userMessage.length).toBeLessThanOrEqual(512); + }); + + it.each(["compatibility", "staging", "failed", "unsafe", "cancelled"] as const)("explains a graph worker %s without its raw diagnostics", (category) => { + const error = new GraphCandidateProcessError(category); + error.message = PRIVATE; + const result = classifySetupMaintenanceError(error, "graph"); + expect(result?.userMessage).toContain("Code graph setup could not finish."); + expect(result?.userMessage).toContain("setup"); + expect(result?.userMessage).not.toContain(PRIVATE); + expect(result?.message).toContain(PRIVATE); + }); + + it.each([ + ["entrypoint-missing", "Rebuild MEX"], + ["spawn", "execution permissions"], + ["startup-timeout", "startup deadline"], + ["build-timeout", "build time limit"], + ["protocol", "invalid response"], + ["ipc-disconnected", "lost communication"], + ["observer", "could not process"], + ["worker-error", "worker diagnostics"], + ] as const)("explains the worker lifecycle failure %s without exposing stderr", (reason, advice) => { + const result = classifySetupMaintenanceError(new GraphCandidateProcessError("failed", { + reason, stderr: PRIVATE, + }), "graph"); + expect(result?.userMessage).toContain(advice); + expect(result?.userMessage).not.toContain(PRIVATE); + expect(result?.message).toContain(PRIVATE); + expect(result!.userMessage.length).toBeLessThanOrEqual(512); + }); + + it("explains confirmed heap exhaustion but does not infer it from a signal", () => { + const heap = classifySetupMaintenanceError(new GraphCandidateProcessError("failed", { + reason: "exit", signal: "SIGABRT", resourceFailure: "heap-limit", stderr: PRIVATE, + }), "graph"); + expect(heap?.userMessage).toContain("JavaScript heap limit"); + expect(heap?.userMessage).toContain("NODE_OPTIONS"); + expect(heap?.userMessage).not.toContain(PRIVATE); + for (const signal of ["SIGABRT", "SIGKILL"] as const) { + const terminated = classifySetupMaintenanceError(new GraphCandidateProcessError("failed", { + reason: "exit", signal, stderr: PRIVATE, + }), "graph"); + expect(terminated?.userMessage).toContain("terminated by a signal"); + expect(terminated?.userMessage).not.toContain("heap"); + } + expect(classifySetupMaintenanceError(new GraphCandidateProcessError("failed", { + reason: "exit", exitCode: 7, + }), "graph")?.userMessage).toContain("exited with status 7"); + }); + + it.each([ + [new WikiMaintenanceLockedError(PRIVATE), "Another Wiki operation"], + [new WikiCorpusLimitError("maxFileBytes"), "wiki.exclude"], + [new WikiMaintenanceInterruptedError("publish"), "Wait for other writers"], + [new IndexPublishRecoveryError(PRIVATE, PRIVATE, new Error(PRIVATE), new Error(PRIVATE)), "recovery files were retained"], + [new IndexInUseError(PRIVATE, new Error(PRIVATE)), "Close other processes"], + [new IndexPathError(PRIVATE), "redirected, or conflicting paths"], + ])("projects the expected Wiki failure %s", (error, advice) => { + const result = classifySetupMaintenanceError(error, "wiki"); + expect(result?.userMessage).toContain(advice); + expect(result?.userMessage).not.toContain(PRIVATE); + }); + + it.each([ + [{ code: "EACCES" }, "permissions"], + [{ code: "ENOSPC" }, "Free disk space"], + [{ code: "SQLITE_BUSY" }, "database is busy"], + [{ code: "ERR_SQLITE_ERROR", errcode: 262 }, "database is busy"], + [{ code: "ERR_SQLITE_ERROR", errcode: 13 }, "ran out of storage"], + [{ code: "ERR_SQLITE_ERROR", errcode: 8 }, "database is read-only"], + ])("projects filesystem/SQLite codes without inspecting their text: %j", (fields, advice) => { + const result = classifySetupMaintenanceError(Object.assign(new Error(PRIVATE), fields), "grounding"); + expect(result?.userMessage).toContain(advice); + expect(result?.userMessage).not.toContain(PRIVATE); + }); + + it("keeps unrelated exceptions and SQLite failures unclassified", () => { + for (const error of [new Error(PRIVATE), Object.assign(new Error(PRIVATE), { code: "UNKNOWN" }), + Object.assign(new Error(PRIVATE), { code: "ERR_SQLITE_ERROR", errcode: 1 }), + { code: "GRAPH_MAINTENANCE_LOCKED", message: PRIVATE }]) { + expect(classifySetupMaintenanceError(error, "wiki")).toBeNull(); + } + }); +}); + +describe("Wiki setup remediation", () => { + it("uses the registry's blocking remediation and a safe relative location", () => { + const message = setupWikiFailureMessage("validation", [ + { code: "ORPHANED_ENTITY", severity: "info", message: PRIVATE }, + { code: "INVALID_ENTITY_ID", severity: "error", message: PRIVATE, remediation: PRIVATE, file: "context/setup.md" }, + ]); + expect(message).toContain("INVALID_ENTITY_ID in .mex/context/setup.md"); + expect(message).toContain("Do not hand-edit them"); + expect(message).toContain("Retry setup"); + expect(message).not.toContain(PRIVATE); + }); + + it("explains the runtime fix for missing FTS5 instead of prescribing a CLI setup rerun", () => { + const message = setupWikiFailureMessage("index", [{ code: "WIKI_INDEX_FTS5_UNAVAILABLE", severity: "error", message: PRIVATE }]); + expect(message).toContain("use a different Node build/version"); + expect(message).toContain("Rebuilding cannot fix this"); + expect(message).not.toContain("setup --cli"); + }); + + it.each(["/private/project/secret.md", "../outside.md", "C:\\private\\secret.md", "context/secret\nname.md"])("omits unsafe location %s", (file) => { + const message = setupWikiFailureMessage("plan", [{ code: "WIKI_PARSE_ERROR", severity: "error", message: PRIVATE, file }]); + expect(message).not.toContain(file); + expect(message).not.toContain(" in "); + }); + + it("bounds long locations while retaining the retry action and avoids false no-write promises", () => { + const message = setupWikiFailureMessage("migration", [{ code: "WRITE_SCOPE_VIOLATION", severity: "error", message: PRIVATE, + file: `context/${"x".repeat(135)}.md` }]); + expect(message.length).toBeLessThanOrEqual(512); + expect(message).toContain("Retry setup"); + expect(message).toContain("wiki.readOnly"); + expect(message).not.toContain("Nothing was written"); + }); + + it("does not echo unknown diagnostic codes or prose", () => { + const message = setupWikiFailureMessage("migration", [{ code: PRIVATE, severity: "error", message: PRIVATE } as unknown as WikiDiagnostic]); + expect(message).not.toContain(PRIVATE); + expect(message).toContain("retry setup"); + }); +}); diff --git a/src/setup/__tests__/phases-errors.test.ts b/src/setup/__tests__/phases-errors.test.ts new file mode 100644 index 00000000..cb6c9a55 --- /dev/null +++ b/src/setup/__tests__/phases-errors.test.ts @@ -0,0 +1,109 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { classifySetupFileSystemError, SetupError } from "../errors.js"; +import { buildSetupGraph, createSetupScaffold, verifyExistingSetupConfig } from "../phases.js"; +import { GraphMaintenanceError } from "../../graph/maintenance.js"; +import * as maintenance from "../../graph/maintenance.js"; + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, readFileSync: vi.fn(original.readFileSync) }; +}); + +const roots: string[] = []; +afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(readFileSync).mockReset(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), "mex-setup-errors-")); + roots.push(root); + return root; +} + +describe("expected setup preparation errors", () => { + it.each(["{broken", "[]", "null"])("explains invalid config %s without replacing it", async (contents) => { + const root = fixture(); + writeFileSync(join(root, "config.json"), contents); + expect(() => verifyExistingSetupConfig(root)).toThrow(SetupError); + expect(() => verifyExistingSetupConfig(root)).toThrow("not a valid JSON object"); + const fs = await vi.importActual("node:fs"); + expect(fs.readFileSync(join(root, "config.json"), "utf8")).toBe(contents); + }); + + it("distinguishes an unreadable config from invalid JSON", () => { + const root = fixture(); + writeFileSync(join(root, "config.json"), "{}"); + const cause = Object.assign(new Error("EACCES /private/config.json secret"), { code: "EACCES" }); + vi.mocked(readFileSync).mockImplementationOnce(() => { throw cause; }); + try { + verifyExistingSetupConfig(root); + expect.fail("Expected unreadable config to fail"); + } catch (error) { + expect(error).toBeInstanceOf(SetupError); + expect(error).toMatchObject({ cause, userMessage: expect.stringContaining("Could not read .mex/config.json. Access was denied") }); + expect((error as SetupError).userMessage).not.toMatch(/private|secret|valid JSON/); + } + }); + + it("identifies a conflicting scaffold path and leaves it intact", () => { + const root = fixture(); + mkdirSync(join(root, ".mex")); + writeFileSync(join(root, ".mex/context"), "keep me"); + try { + createSetupScaffold({ projectRoot: root, mode: "agent-memory" }); + expect.fail("Expected the path conflict to fail"); + } catch (error) { + expect(error).toBeInstanceOf(SetupError); + expect((error as SetupError).userMessage).toContain("Could not create .mex/context/architecture.md."); + expect((error as SetupError).userMessage).toContain("conflicting file"); + expect((error as SetupError).userMessage).not.toContain(root); + } + }); + + it.each([ + ["EACCES", "permissions"], ["EPERM", "permissions"], ["EROFS", "read-only"], + ["ENOSPC", "Free disk space"], ["EDQUOT", "quota"], ["EBUSY", "busy"], + ["ENOENT", "missing"], ["EMFILE", "too many open files"], + ])("projects %s without copying exception text", (code, remedy) => { + const cause = Object.assign(new Error("private diagnostic /secret/path"), { code }); + const error = classifySetupFileSystemError(cause, "Could not save .mex/config.json."); + expect(error).toMatchObject({ message: cause.message, cause }); + expect(error?.userMessage).toContain(remedy); + expect(error?.userMessage).not.toMatch(/private|secret/); + }); + + it("does not classify unexpected exceptions by their message", () => { + expect(classifySetupFileSystemError(new Error("EACCES"), "Save config.")).toBeNull(); + expect(classifySetupFileSystemError(Object.assign(new Error("secret"), { code: "UNKNOWN" }), "Save config.")).toBeNull(); + }); +}); + +describe("setup Graph error boundary", () => { + it("preserves a known maintenance failure's safe recovery instructions", async () => { + vi.spyOn(maintenance, "rebuildGraph").mockRejectedValue(new GraphMaintenanceError("GRAPH_MAINTENANCE_LOCKED", "private lock details")); + await expect(buildSetupGraph("/unused")).rejects.toMatchObject({ + userMessage: expect.stringMatching(/another|running|progress/i), + }); + }); + + it("keeps unknown graph failures outside the browser-safe contract", async () => { + vi.spyOn(maintenance, "rebuildGraph").mockRejectedValue(new Error("private compiler crash")); + const error = await buildSetupGraph("/unused").catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(SetupError); + }); + + it("checks cancellation before classifying a concurrent graph failure", async () => { + const controller = new AbortController(); + vi.spyOn(maintenance, "rebuildGraph").mockImplementation(async () => { + controller.abort(); + throw new GraphMaintenanceError("GRAPH_MAINTENANCE_LOCKED", "private lock details"); + }); + await expect(buildSetupGraph("/unused", { signal: controller.signal })).rejects.toThrow("Setup was cancelled"); + }); +}); diff --git a/src/setup/__tests__/terminal-ui-lifecycle.test.ts b/src/setup/__tests__/terminal-ui-lifecycle.test.ts new file mode 100644 index 00000000..e6f9e137 --- /dev/null +++ b/src/setup/__tests__/terminal-ui-lifecycle.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTerminalSetupUI, type TerminalSetupView } from "../terminal-ui.js"; + +const { render } = vi.hoisted(() => ({ render: vi.fn() })); +vi.mock("ink", async importOriginal => ({ ...await importOriginal(), render })); + +const view: TerminalSetupView = { + projectName: "example", mode: "code-repo", screen: "progress", tools: [], + completedSteps: [], detail: "Preparing", startedAt: 0, +}; + +function renderer() { + let flush!: () => void; + let fail!: (error: Error) => void; + const exited = new Promise((resolve, reject) => { flush = resolve; fail = reject; }); + const instance = { rerender: vi.fn(), unmount: vi.fn(), waitUntilExit: vi.fn(() => exited), cleanup: vi.fn() }; + return { instance, flush, fail }; +} + +beforeEach(() => { render.mockReset(); }); + +describe("terminal setup renderer ownership", () => { + it("waits for terminal restoration before handing control back and creates a fresh renderer on resume", async () => { + const first = renderer(); + const second = renderer(); + render.mockReturnValueOnce(first.instance).mockReturnValueOnce(second.instance); + const ui = createTerminalSetupUI(view, vi.fn()); + expect(render.mock.calls[0][1]).toEqual({ alternateScreen: true, patchConsole: false, exitOnCtrlC: false }); + let suspended = false; + const handoff = ui.suspend().then(() => { suspended = true; }); + expect(first.instance.unmount).toHaveBeenCalledOnce(); + await Promise.resolve(); + expect(suspended).toBe(false); + expect(first.instance.cleanup).not.toHaveBeenCalled(); + ui.update({ ...view, detail: "Native agent owns the terminal" }); + expect(first.instance.rerender).not.toHaveBeenCalled(); + first.flush(); + await handoff; + expect(first.instance.cleanup).toHaveBeenCalledOnce(); + ui.resume({ ...view, screen: "population", detail: "Welcome back" }); + expect(render).toHaveBeenCalledTimes(2); + expect(render.mock.calls[1][0].props.view.detail).toBe("Welcome back"); + const closing = ui.close(); + second.flush(); + await closing; + expect(second.instance.cleanup).toHaveBeenCalledOnce(); + ui.resume(view); + expect(render).toHaveBeenCalledTimes(2); + }); + + it("coalesces repeated teardown and never mounts a queued resume after close", async () => { + const current = renderer(); + render.mockReturnValue(current.instance); + const ui = createTerminalSetupUI(view, vi.fn()); + const first = ui.suspend(); + const second = ui.suspend(); + ui.resume(view); + const closing = ui.close(); + current.flush(); + await Promise.all([first, second, closing]); + expect(render).toHaveBeenCalledOnce(); + expect(current.instance.cleanup).toHaveBeenCalledOnce(); + await ui.close(); + expect(current.instance.unmount).toHaveBeenCalledOnce(); + }); + + it("still cleans up the Ink instance when its output flush fails", async () => { + const current = renderer(); + render.mockReturnValue(current.instance); + const ui = createTerminalSetupUI(view, vi.fn()); + const closing = ui.close(); + const error = new Error("Terminal output closed"); + current.fail(error); + await expect(closing).rejects.toBe(error); + expect(current.instance.cleanup).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/setup/__tests__/terminal-ui.test.ts b/src/setup/__tests__/terminal-ui.test.ts new file mode 100644 index 00000000..d7b49908 --- /dev/null +++ b/src/setup/__tests__/terminal-ui.test.ts @@ -0,0 +1,383 @@ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "ink-testing-library"; +import { TerminalSetupHUD, type TerminalSetupView } from "../terminal-ui.js"; + +const h = React.createElement; +const settle = () => new Promise(resolve => setTimeout(resolve, 25)); +const initial: TerminalSetupView = { + projectName: "example-project", mode: "code-repo", screen: "configure", + tools: [ + { id: "claude", name: "Claude Code", selected: true, cliAvailable: true }, + { id: "codex", name: "Codex", selected: false, cliAvailable: true }, + { id: "cursor", name: "Cursor", selected: false, cliAvailable: false }, + { id: "windsurf", name: "Windsurf", selected: false, cliAvailable: false }, + { id: "copilot", name: "Copilot", selected: false, cliAvailable: false }, + { id: "opencode", name: "OpenCode", selected: false, cliAvailable: true }, + ], + completedSteps: [], detail: "Choose the tools you use.", startedAt: Date.now(), +}; + +async function show(changes: Partial = {}, columns = 80, rows = 24) { + const onAction = vi.fn(); + const view = { ...initial, ...changes }; + const app = render(h(TerminalSetupHUD, { view, onAction })); + Object.defineProperty(app.stdout, "columns", { configurable: true, value: columns }); + Object.defineProperty(app.stdout, "rows", { configurable: true, value: rows }); + await settle(); + app.stdout.emit("resize"); + await settle(); + return { ...app, view, onAction }; +} + +function expectFits(frame: string | undefined, columns: number, rows: number) { + const lines = (frame ?? "").split("\n"); + expect(lines.length).toBeLessThanOrEqual(rows - 1); + for (const line of lines) expect(Array.from(line).length).toBeLessThanOrEqual(columns); +} + +function expectAlignedBorders(frame: string | undefined, columns: number) { + const lines = (frame ?? "").split("\n").filter(line => line.includes("│")); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + const characters = Array.from(line); + expect(characters.indexOf("│")).toBe(1); + expect(characters.lastIndexOf("│")).toBe(columns - 2); + } +} + +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +describe("terminal setup HUD", () => { + it("presents the ASCII identity and tool selection within 80 by 24", async () => { + const app = await show(); + expectFits(app.lastFrame(), 80, 24); + expect(app.lastFrame()).toContain("|_| |_|_____|/_/\\_\\"); + expect(app.lastFrame()).toContain("example-project / code-repo"); + expect(app.lastFrame()).toContain("> [x] Claude Code CLI ready"); + expect(app.lastFrame()).toContain("[ ] Cursor"); + expect(app.lastFrame()).toContain("Enter select/start"); + expect(app.lastFrame()).toContain("Space multi-select"); + expect(app.lastFrame()).toContain("Continue with selected tools"); + expectAlignedBorders(app.lastFrame(), 80); + }); + + it("distinguishes a missing population CLI from tools that only receive an integration", async () => { + const app = await show({ tools: initial.tools.map(tool => ({ ...tool, cliAvailable: tool.id === "opencode" })) }); + expect(app.lastFrame()).toContain("Claude Code CLI not found"); + expect(app.lastFrame()).toContain("Codex CLI not found"); + expect(app.lastFrame()).toContain("Cursor integration only"); + expect(app.lastFrame()).toContain("OpenCode integration only"); + expect(app.lastFrame()).not.toContain("CLI ready"); + expectFits(app.lastFrame(), 80, 24); + }); + + it("selects the highlighted unchecked tool and starts with one atomic Enter action", async () => { + const app = await show({ tools: initial.tools.map(tool => ({ ...tool, selected: false })) }); + expect(app.lastFrame()).toContain("> [ ] Claude Code"); + expect(app.lastFrame()).toContain("Enter selects this tool"); + expect(app.lastFrame()).toContain("and starts setup."); + app.stdin.write("\r"); + await settle(); + expect(app.onAction.mock.calls).toEqual([[{ type: "start", tool: "claude" }]]); + expect(app.view.tools.every(tool => !tool.selected)).toBe(true); + }); + + it("starts with a checked tool without toggling it off", async () => { + const app = await show(); + app.stdin.write("\r"); + await settle(); + expect(app.onAction.mock.calls).toEqual([[{ type: "start", tool: "claude" }]]); + expect(app.view.tools[0].selected).toBe(true); + }); + + it("starts with the moved-to unchecked tool without requiring Space", async () => { + const app = await show({ tools: initial.tools.map(tool => ({ ...tool, selected: false })) }); + app.stdin.write("\u001b[B"); + await settle(); + expect(app.lastFrame()).toContain("> [ ] Codex"); + app.stdin.write("\r"); + await settle(); + expect(app.onAction.mock.calls).toEqual([[{ type: "start", tool: "codex" }]]); + }); + + it("uses the requested initial tool focus without reordering choices", async () => { + const app = await show({ initialTool: "codex" }); + expect(app.lastFrame()).toContain("> [ ] Codex"); + expect(app.lastFrame()!.indexOf("Claude Code")).toBeLessThan(app.lastFrame()!.indexOf("> [ ] Codex")); + app.stdin.write("\r"); + await settle(); + expect(app.onAction.mock.calls).toEqual([[{ type: "start", tool: "codex" }]]); + }); + + it.each([true, false])("continues with the current selection without adding a tool (selected: %s)", async hasSelectedTool => { + const app = await show({ initialTool: "continue", tools: initial.tools.map(tool => ({ ...tool, selected: hasSelectedTool && tool.id === "claude" })) }); + expect(app.lastFrame()).toContain(hasSelectedTool ? "> Continue with selected tools" : "> Continue without an agent"); + app.stdin.write(" "); + await settle(); + expect(app.onAction).not.toHaveBeenCalled(); + app.stdin.write("\r"); + await settle(); + expect(app.onAction.mock.calls).toEqual([[{ type: "start" }]]); + expect(app.view.tools.filter(tool => tool.selected).map(tool => tool.id)).toEqual(hasSelectedTool ? ["claude"] : []); + expectFits(app.lastFrame(), 80, 24); + }); + + it("moves tool focus, optionally toggles multiple tools with Space, and starts on Enter", async () => { + const app = await show(); + app.stdin.write("\u001b[B"); + await settle(); + app.stdin.write(" "); + await settle(); + expect(app.onAction).toHaveBeenLastCalledWith({ type: "toggle", tool: "codex" }); + expect(app.lastFrame()).toContain("> [ ] Codex"); + app.rerender(h(TerminalSetupHUD, { + view: { ...app.view, tools: app.view.tools.map(tool => ({ ...tool, selected: tool.id === "codex" || tool.selected })) }, + onAction: app.onAction, + })); + await settle(); + expect(app.lastFrame()).toContain("> [x] Codex"); + app.stdin.write("\r"); + await settle(); + expect(app.onAction.mock.calls).toEqual([[{ type: "toggle", tool: "codex" }], [{ type: "start", tool: "codex" }]]); + }); + + it("keeps a focused tool visible when a small terminal needs a scrolling list", async () => { + const app = await show({}, 32, 9); + app.stdin.write("\u001b[A"); + await settle(); + app.stdin.write("\u001b[A"); + await settle(); + expectFits(app.lastFrame(), 32, 9); + expect(app.lastFrame()).toContain("> [ ] OpenCode"); + expect(app.lastFrame()).toContain("Enter"); + app.stdin.write(" "); + await settle(); + expect(app.onAction).toHaveBeenLastCalledWith({ type: "toggle", tool: "opencode" }); + }); + + it.each([true, false])("keeps the complete Continue choice usable in a 32 by 9 terminal (selected: %s)", async hasSelectedTool => { + const app = await show({ tools: initial.tools.map(tool => ({ ...tool, selected: hasSelectedTool && tool.id === "claude" })) }, 32, 9); + app.stdin.write("\u001b[A"); + await settle(); + const text = app.lastFrame()?.replace(/│/g, " ").replace(/\s+/g, " "); + expect(text).toContain(hasSelectedTool ? "> Continue with selected tools" : "> Continue without an agent"); + expect(app.lastFrame()).toContain("Enter start Space multi"); + expectFits(app.lastFrame(), 32, 9); + expectAlignedBorders(app.lastFrame(), 32); + app.stdin.write("\r"); + await settle(); + expect(app.onAction.mock.calls).toEqual([[{ type: "start" }]]); + }); + + it("shows only reported completion and the active phase, without synthetic progress", async () => { + const app = await show({ screen: "progress", completedSteps: ["detect", "scaffold"], activeStep: "graph", detail: "Indexing local source files." }); + expectFits(app.lastFrame(), 80, 24); + expect(app.lastFrame()).toContain("[done] Project"); + expect(app.lastFrame()).toContain("[done] Scaffold"); + expect(app.lastFrame()).toContain("[now ] Code graph"); + expect(app.lastFrame()).toContain("[wait] Project knowledge"); + expect(app.lastFrame()).not.toContain("%"); + expect(app.lastFrame()).toContain("01 [done] Project"); + expect(app.lastFrame()).toContain("WORKFLOW"); + expect(app.lastFrame()).toContain("LATEST EVENT"); + expect(app.lastFrame()).toContain("╭─ MEX / SETUP"); + }); + + it("animates pending work and advances phase and quiet clocks without inventing progress", async () => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(100_000); + const app = await show({ screen: "progress", activeStep: "graph", startedAt: 50_000, phaseStartedAt: 90_000, lastActivityAt: 95_000, detail: "Parsing source files" }); + expect(app.lastFrame()).toContain("Phase 10s"); + expect(app.lastFrame()).toContain("No new events for 5s"); + expectAlignedBorders(app.lastFrame(), 80); + const first = app.lastFrame(); + vi.advanceTimersByTime(120); + await settle(); + expect(app.lastFrame()).not.toBe(first); + expect(app.lastFrame()).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Code graph/); + expectAlignedBorders(app.lastFrame(), 80); + vi.advanceTimersByTime(5_880); + await settle(); + expect(app.lastFrame()).toContain("Phase 16s"); + expect(app.lastFrame()).toContain("No new events for 11s"); + expect(app.lastFrame()).toContain("[now ] Code graph"); + expect(app.lastFrame()).not.toContain("[done] Code graph"); + expect(app.lastFrame()).not.toContain("%"); + app.rerender(h(TerminalSetupHUD, { view: { ...app.view, lastActivityAt: 106_000, detail: "Writing graph candidate" }, onAction: app.onAction })); + await settle(); + expect(app.lastFrame()).toContain("Phase 16s"); + expect(app.lastFrame()).toContain("No new events for 0s"); + app.unmount(); + await settle(); + const frames = app.frames.length; + vi.advanceTimersByTime(2_000); + await settle(); + expect(app.frames).toHaveLength(frames); + }); + + it("does not animate configuration, manual choices, errors, Hub, or stopping work", async () => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(100_000); + const app = await show({ startedAt: 50_000 }); + for (const screen of ["configure", "population", "error", "hub", "progress"] as const) { + app.rerender(h(TerminalSetupHUD, { view: { ...app.view, screen, stopping: screen === "progress" }, onAction: app.onAction })); + await settle(); + const frame = app.lastFrame(); + vi.advanceTimersByTime(1_500); + await settle(); + expect(app.lastFrame()).toBe(frame); + expect(app.lastFrame()).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); + } + }); + + it("shows only supplied finite counts and removes stale counts on the next view", async () => { + const app = await show({ screen: "progress", activeStep: "graph", detail: "Parsing source files", progress: { completed: 12, total: 200, unit: "files parsed" } }); + expect(app.lastFrame()).toContain("12 / 200 files parsed"); + expect(app.lastFrame()).not.toContain("%"); + app.rerender(h(TerminalSetupHUD, { view: { ...app.view, progress: { completed: Infinity, total: 200, unit: "files parsed" } }, onAction: app.onAction })); + await settle(); + expect(app.lastFrame()).not.toContain("files parsed"); + app.rerender(h(TerminalSetupHUD, { view: { ...app.view, progress: undefined }, onAction: app.onAction })); + await settle(); + expect(app.lastFrame()).not.toContain("files parsed"); + }); + + it("prioritizes the real count and phase clocks in a 32 by 12 terminal", async () => { + const app = await show({ screen: "progress", activeStep: "graph", phaseStartedAt: Date.now() - 18_000, lastActivityAt: Date.now() - 3_000, detail: "Parsing source files with the TypeScript compiler.", progress: { completed: 324, total: 1000, unit: "files parsed" } }, 32, 12); + expect(app.lastFrame()).toContain("324 / 1,000 files parsed"); + expect(app.lastFrame()).toContain("Phase 18s"); + expect(app.lastFrame()).toContain("Quiet 3s"); + expectFits(app.lastFrame(), 32, 12); + expectAlignedBorders(app.lastFrame(), 32); + }); + + it("omits phases that agent-memory mode and no selected integrations do not run", async () => { + const app = await show({ screen: "progress", mode: "agent-memory", tools: [], activeStep: "population" }); + expect(app.lastFrame()).not.toContain("Code graph"); + expect(app.lastFrame()).not.toContain("Codebase scan"); + expect(app.lastFrame()).not.toContain("Grounding + Wiki"); + expect(app.lastFrame()).not.toContain("Agent skills"); + expect(app.lastFrame()).toContain("[now ] Project knowledge"); + }); + + it("does not promise agent skills for an editor-only integration", async () => { + const app = await show({ screen: "progress", tools: initial.tools.map(tool => ({ ...tool, selected: tool.id === "cursor" })) }); + expect(app.lastFrame()).not.toContain("Agent skills"); + }); + + it.each([ + ["population", "[wait] Project knowledge"], + ["error", "[fail] Project knowledge"], + ["hub", "[skip] Project knowledge"], + ] as const)("keeps %s status distinct from active work", async (screen, status) => { + const app = await show({ screen, activeStep: "population", completedSteps: ["scaffold"] }); + expect(app.lastFrame()).toContain(status); + expect(app.lastFrame()).toContain("[done] Scaffold"); + expect(app.lastFrame()).not.toContain("|_| |_|_____|/_/\\_\\"); + }); + + it.each(["configure", "progress", "population", "error", "hub"] as const)("cancels %s on Ctrl+C and raw-mode EOF", async screen => { + const app = await show({ screen }); + app.stdin.write("\u0003"); + await settle(); + app.stdin.write("\u0004"); + await settle(); + expect(app.onAction.mock.calls).toEqual([[{ type: "cancel" }], [{ type: "cancel" }]]); + }); + + it("offers native agent handoff, prompt, check, and explicit pause separately", async () => { + const app = await show({ screen: "population", populationAgent: "Codex" }); + app.stdin.write("\r"); + await settle(); + expect(app.onAction).toHaveBeenLastCalledWith({ type: "agent" }); + for (const type of ["prompt", "check", "exit"]) { + app.stdin.write("\u001b[B"); + await settle(); + app.stdin.write("\r"); + await settle(); + expect(app.onAction).toHaveBeenLastCalledWith({ type }); + } + }); + + it("does not offer an unavailable agent and resets focus for an error screen", async () => { + const app = await show({ screen: "population", populationAgent: null }); + expect(app.lastFrame()).not.toContain("Open Codex"); + app.stdin.write("\r"); + await settle(); + expect(app.onAction).toHaveBeenLastCalledWith({ type: "prompt" }); + app.stdin.write("\u001b[B"); + await settle(); + app.rerender(h(TerminalSetupHUD, { view: { ...app.view, screen: "error", error: "Resolve the conflict, then retry." }, onAction: app.onAction })); + await settle(); + app.stdin.write("\r"); + await settle(); + expect(app.onAction).toHaveBeenLastCalledWith({ type: "retry" }); + app.stdin.write("\u001b[B"); + await settle(); + app.stdin.write("\r"); + await settle(); + expect(app.onAction).toHaveBeenLastCalledWith({ type: "exit" }); + }); + + it("lets users scroll all bounded remediation without invoking menu actions", async () => { + const error = "Inspect retained files before retrying. ".repeat(35) + "FINAL REMEDIATION: reconcile the backup first."; + const app = await show({ screen: "error", error }, 40, 12); + expectFits(app.lastFrame(), 40, 12); + app.stdin.write("d"); + await settle(); + expect(app.lastFrame()).toContain("Details 1-"); + for (let index = 0; index < 20; index++) { app.stdin.write("\u001b[6~"); await settle(); } + expect(app.lastFrame()?.replace(/│/g, "").replace(/\s+/g, " ")).toContain("backup first."); + expectFits(app.lastFrame(), 40, 12); + app.stdin.write("\r"); + await settle(); + expect(app.onAction).not.toHaveBeenCalled(); + app.stdin.write("\u001b"); + await settle(); + expect(app.lastFrame()).toContain("Retry setup"); + }); + + it("wraps the Hub link and retains the complete URL in scrollable details", async () => { + const hubUrl = "http://127.0.0.1:43123/setup#token=" + "abcdef0123456789".repeat(4); + const app = await show({ screen: "hub", detail: "Review setup in the browser.", hubUrl }); + const frame = app.lastFrame() ?? ""; + expect(frame).toContain("http://127.0.0.1:43123/"); + expect(frame).not.toContain("..."); + expectFits(frame, 80, 24); + app.stdin.write("q"); + await settle(); + expect(app.onAction).toHaveBeenLastCalledWith({ type: "cancel" }); + }); + + it("keeps the end of a long Hub URL accessible after shrinking the terminal", async () => { + const hubUrl = "http://127.0.0.1:43123/setup#token=" + "abcdef0123456789".repeat(6) + "TOKENEND"; + const app = await show({ screen: "hub", detail: "Review setup in your browser.", hubUrl }, 32, 10); + expectFits(app.lastFrame(), 32, 10); + app.stdin.write("d"); + await settle(); + for (let index = 0; index < 4; index++) { app.stdin.write("\u001b[6~"); await settle(); } + expect(app.lastFrame()).toContain("TOKENEND"); + expectFits(app.lastFrame(), 32, 10); + }); + + it("bounds very large unsafe details and suppresses input while stopping", async () => { + const app = await show({ screen: "progress", detail: "\u001b[2J\u001b[31mBAD\n".repeat(10_000), stopping: true }, 40, 12); + expectFits(app.lastFrame(), 40, 12); + expect(app.lastFrame()).not.toContain("\u001b"); + expect(app.lastFrame()).toContain("Stopping safely"); + app.stdin.write("\u0003"); + await settle(); + expect(app.onAction).not.toHaveBeenCalled(); + }); + + it("exposes a bounded error diagnostic preview and a discoverable expansion key", async () => { + const app = await show({ screen: "error", activeStep: "graph", error: "The graph worker exceeded its Node.js heap limit. Increase its memory allowance and retry setup.", detail: "Diagnostics: Worker stopped with SIGABRT while parsing 865 / 924 files. V8 heap allocation failed." }); + expect(app.lastFrame()).toContain("DIAGNOSTICS [d] expand"); + expect(app.lastFrame()).toContain("SIGABRT"); + expect(app.lastFrame()).toContain("865 / 924"); + expect(app.lastFrame()).toContain("d diagnostics"); + expect(app.lastFrame()).toContain("> Retry setup"); + expectFits(app.lastFrame(), 80, 24); + }); +}); diff --git a/src/setup/__tests__/terminal.test.ts b/src/setup/__tests__/terminal.test.ts new file mode 100644 index 00000000..0349962e --- /dev/null +++ b/src/setup/__tests__/terminal.test.ts @@ -0,0 +1,70 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runSetup } from "../index.js"; +import * as population from "../headless-population.js"; +import { runHeadlessSetup } from "../headless.js"; + +const roots: string[] = []; +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); }); +function project() { + const root = mkdtempSync(join(tmpdir(), "mex-terminal-")); roots.push(root); + vi.spyOn(process, "cwd").mockReturnValue(root); + vi.spyOn(console, "log").mockImplementation(() => {}); + return root; +} +function populate(root: string) { + for (const path of ["AGENTS.md", "ROUTER.md", "context/architecture.md", "context/stack.md", "context/conventions.md", "context/decisions.md", "context/setup.md"]) { + writeFileSync(join(root, ".mex", path), "# Authored knowledge\n\nRetain this work.\n"); + } +} + +describe("terminal setup outcomes", () => { + it("--yes never launches a headless agent and returns an explicit resumable pause", async () => { + const root = project(); + const launch = vi.spyOn(population, "launchHeadlessSetupPopulation"); + const result = await runSetup({ mode: "agent-memory", tools: ["codex"], yes: true }); + expect(result).toMatchObject({ outcome: "paused", exitCode: 2 }); + expect(launch).not.toHaveBeenCalled(); + expect(readFileSync(join(root, ".mex/config.json"), "utf8")).toContain('"codex"'); + }); + + it("CI uses empty default choices without waiting for input", async () => { + const root = project(); vi.stubEnv("CI", "1"); + expect(await runSetup({ mode: "agent-memory" })).toMatchObject({ outcome: "paused", exitCode: 2 }); + expect(JSON.parse(readFileSync(join(root, ".mex/config.json"), "utf8")).aiTools).toEqual([]); + }); + + it("plain completion preserves authored files and returns without a Hub server", async () => { + const root = project(); + await runSetup({ mode: "agent-memory", tools: [], yes: true }); + populate(root); + const before = readFileSync(join(root, ".mex/ROUTER.md"), "utf8"); + expect(await runSetup({ yes: true, openBrowser: true })).toMatchObject({ outcome: "complete", exitCode: 0 }); + expect(readFileSync(join(root, ".mex/ROUTER.md"), "utf8")).toBe(before); + expect(readdirSync(join(root, ".mex"))).not.toContain("graph.db"); + }); + + it("fresh dry-run has no prompts, agent launch, config or local writes", async () => { + const root = project(); + const launch = vi.spyOn(population, "launchHeadlessSetupPopulation"); + expect(await runSetup({ mode: "agent-memory", tools: ["codex"], dryRun: true })).toMatchObject({ outcome: "dry-run", exitCode: 0 }); + expect(readdirSync(root)).toEqual([]); + expect(launch).not.toHaveBeenCalled(); + }); + + it("the shared engine checks files even when a presentation adapter claims completion", async () => { + const root = project(); + const run = vi.fn(async () => ({ tool: "codex" as const, completed: true })); + expect(await runHeadlessSetup({ projectRoot: root, mode: "agent-memory", tools: [], populate: run })).toMatchObject({ ready: false, stage: "needs_population" }); + expect(run).toHaveBeenCalledOnce(); + }); + + it("the presentation callback can populate then continue through the shared engine", async () => { + const root = project(); + expect(await runHeadlessSetup({ projectRoot: root, mode: "agent-memory", tools: [], + populate: async () => { populate(root); return { tool: null, completed: true }; }, + })).toMatchObject({ ready: true, stage: "ready", mode: "agent-memory" }); + }); +}); diff --git a/src/setup/__tests__/wiki-finalize.test.ts b/src/setup/__tests__/wiki-finalize.test.ts index 951d8ebf..d4853819 100644 --- a/src/setup/__tests__/wiki-finalize.test.ts +++ b/src/setup/__tests__/wiki-finalize.test.ts @@ -24,6 +24,17 @@ afterEach(() => { }); describe("setup Wiki finalization", () => { + it("stops at the migration boundary without changing canonical files or creating an index", async () => { + const { projectRoot, scaffoldRoot } = legacyScaffold(false); + const before = readFileSync(join(scaffoldRoot, "context/setup.md"), "utf8"); + const abort = new AbortController(); + await expect(finalizeSetupWiki({ projectRoot, scaffoldRoot, signal: abort.signal, + onProgress: message => { if (message === "Applying Wiki migration...") abort.abort(); }, + })).rejects.toThrow("Setup was cancelled"); + expect(readFileSync(join(scaffoldRoot, "context/setup.md"), "utf8")).toBe(before); + expect(existsSync(join(scaffoldRoot, "wiki.db"))).toBe(false); + }); + it("adopts files added since an earlier setup without telling the user to run migration (#227)", async () => { const projectRoot = fixture(); const scaffoldRoot = join(projectRoot, ".mex"); diff --git a/src/setup/agent-errors.ts b/src/setup/agent-errors.ts new file mode 100644 index 00000000..c242ade5 --- /dev/null +++ b/src/setup/agent-errors.ts @@ -0,0 +1,42 @@ +import { AgentAssetsError } from "../agent-skills/installer.js"; +import { classifySetupFileSystemError, SetupError } from "./errors.js"; + +const BACKUP_REVIEW = "Look for hidden entries containing .mex-backup- beside CLAUDE.md or AGENTS.md and in .claude/skills or .agents/skills. Review and reconcile those backups before rerunning setup."; + +/** Translate known installer failures without exposing paths or child output. */ +export function classifySetupAgentAssetsError(error: unknown): SetupError | null { + if (!(error instanceof AgentAssetsError)) { + return classifySetupFileSystemError(error, "Could not install the selected agent integrations."); + } + + let userMessage: string; + switch (error.code) { + case "INVALID_PACKAGED_SKILL": + userMessage = "The packaged MEX agent skills are missing or invalid. Reinstall MEX, then rerun setup."; + break; + case "INVALID_OPTIONS": + userMessage = "Agent integration setup could not validate the project or installation settings. Check the project directory and selected tools; reinstall MEX if its packaged settings are invalid."; + break; + case "IGNORE_CHECK_FAILED": + userMessage = "Git could not verify the agent integration paths. Make sure Git is available and run git status from the project root to diagnose the repository, then rerun setup."; + break; + case "CONCURRENT_MODIFICATION": + userMessage = "Agent integration files changed during setup. Finish or stop other edits, review the affected files, then rerun setup."; + break; + case "PATH_IDENTITY_CHANGED": + userMessage = `An agent integration path changed during setup. Stop other processes changing the project. ${BACKUP_REVIEW}`; + break; + case "REPLACEMENT_ACTIVE_BACKUP_RETAINED": + userMessage = `Updated agent files are active, but an original backup was retained. ${BACKUP_REVIEW}`; + break; + case "APPLY_FAILED": + // This code also covers failed rollback. Never imply retry alone is safe. + userMessage = `Agent integration could not finish, and some files or backups may remain. ${BACKUP_REVIEW}`; + break; + default: + // UNKNOWN_PLAN and DRY_RUN_PLAN indicate internal misuse, not a user fix. + return null; + } + + return new SetupError(error.message, { cause: error, userMessage }); +} diff --git a/src/setup/errors.ts b/src/setup/errors.ts new file mode 100644 index 00000000..2db57254 --- /dev/null +++ b/src/setup/errors.ts @@ -0,0 +1,54 @@ +/** + * An expected setup failure with an explicitly safe browser explanation. + * Keep paths, child output, and underlying diagnostics in message/cause only; + * userMessage must be MEX-authored text and known relative setup paths. + */ +export class SetupError extends Error { + readonly userMessage: string; + + constructor(message: string, options?: ErrorOptions & { userMessage?: string }) { + super(message, options); + this.name = "SetupError"; + this.userMessage = options?.userMessage ?? message; + } +} + +/** Classify known filesystem failures without inspecting or exposing their text. + * operation must be authored text, never a path or message from the exception. + */ +export function classifySetupFileSystemError(error: unknown, operation: string): SetupError | null { + if (!(error instanceof Error) || !("code" in error)) return null; + let recovery: string; + switch (error.code) { + case "EACCES": + case "EPERM": + recovery = "Access was denied. Check the file and directory permissions, then retry setup."; + break; + case "EROFS": + recovery = "The filesystem is read-only. Use a writable checkout, then retry setup."; + break; + case "ENOSPC": + case "EDQUOT": + recovery = "Storage is full or its quota was reached. Free disk space or increase the quota, then retry setup."; + break; + case "EBUSY": + recovery = "A required file is busy. Close the process using it, then retry setup."; + break; + case "EEXIST": + case "ENOTDIR": + case "EISDIR": + case "ELOOP": + recovery = "A required path has a conflicting file, directory, or symbolic link. Correct it, then retry setup."; + break; + case "ENOENT": + recovery = "A required file or directory is missing. Restore it, or reinstall MEX if its templates are missing, then retry setup."; + break; + case "EMFILE": + case "ENFILE": + recovery = "The system has too many open files. Close other processes, then retry setup."; + break; + default: + return null; + } + return new SetupError(error.message, { cause: error, userMessage: `${operation} ${recovery}` }); +} diff --git a/src/setup/graph-progress.ts b/src/setup/graph-progress.ts new file mode 100644 index 00000000..04a172cf --- /dev/null +++ b/src/setup/graph-progress.ts @@ -0,0 +1,28 @@ +import type { GraphMaintenanceProgress } from "../team/contracts/graph.js"; + +const LABELS: Record = { + discover: "Checking existing graph and project files", + stage: "Preparing an isolated graph build", + parse: "Parsing source files", + resolve: "Resolving code references", + validate: "Validating the rebuilt graph", + publish: "Saving the verified graph", +}; + +/** Keep maintenance text and arbitrary paths out of the presentation contract. */ +export function setupGraphActivity(update: GraphMaintenanceProgress): { + detail: string; + progress?: { completed: number; total?: number; unit: string }; +} { + const detail = LABELS[update.phase]; + const completed = update.completed; + if (update.phase !== "parse" || completed === undefined || !Number.isSafeInteger(completed) || completed < 0) return { detail }; + const total = update.total !== undefined && Number.isSafeInteger(update.total) && update.total > 0 && update.total >= completed + ? update.total : undefined; + return { detail, progress: { completed, ...(total === undefined ? {} : { total }), unit: "files parsed" } }; +} + +export function formatSetupGraphActivity(update: GraphMaintenanceProgress): string { + const { detail, progress } = setupGraphActivity(update); + return progress ? `${detail}: ${progress.completed}${progress.total === undefined ? "" : ` / ${progress.total}`} ${progress.unit}` : detail; +} diff --git a/src/setup/headless.ts b/src/setup/headless.ts index c10366fd..d5d10b90 100644 --- a/src/setup/headless.ts +++ b/src/setup/headless.ts @@ -9,6 +9,10 @@ import { saveAiTools, } from "../config.js"; import { AI_TOOLS, type AiTool } from "../types.js"; +import { AGENT_SKILL_TARGETS, OFFICIAL_MEX_SKILLS } from "../agent-skills/types.js"; +import { SetupError, classifySetupFileSystemError } from "./errors.js"; +import { classifySetupAgentAssetsError } from "./agent-errors.js"; +import type { GraphMaintenanceProgress } from "../team/contracts/graph.js"; import { launchHeadlessSetupPopulation, type HeadlessPopulationActivity, type HeadlessPopulationTranscript } from "./headless-population.js"; import { detectProjectState, @@ -85,6 +89,16 @@ export interface HeadlessSetupOptions { readonly onPopulationTranscript?: (entry: HeadlessPopulationTranscript) => void; readonly onPopulationPrompt?: (prompt: string) => void; readonly onAnchorNotes?: (notes: readonly string[]) => void; + /** Presentation-owned population (the terminal temporarily yields to a native agent). */ + readonly populate?: (input: { + selectedTools: readonly AiTool[]; + prompt: string; + projectRoot: string; + signal?: AbortSignal; + }) => Promise<{ tool: "claude" | "codex" | null; completed: boolean }>; + readonly onMessage?: (message: string) => void; + /** Live maintenance updates, separate from the ordered setup phase transitions. */ + readonly onGraphProgress?: (progress: GraphMaintenanceProgress) => void; } export interface HeadlessSetupProgress { @@ -162,11 +176,12 @@ export function setupUnavailableReason(): string { } /** - * Run the same ordered setup path as `mex setup`, without readline or a TTY. + * Run the shared ordered setup path, with presentation and input owned by callers. * - * Tool choice arrives from the Hub form. Population uses a headless Claude or - * Codex session when one is installed; otherwise the prompt is returned for - * the user to paste. Finalize only runs after `isScaffoldPopulated`. + * The Hub defaults to a headless Claude/Codex session; the terminal supplies a + * native handoff callback. Scripts use confirmPopulation to inspect without an + * agent. Finalize only runs after checking the populated files, regardless of + * the population adapter's result. */ export async function runHeadlessSetup( options: HeadlessSetupOptions, @@ -186,7 +201,7 @@ export async function runHeadlessSetup( throwIfSetupAborted(options.signal); if (mode === "code-repo" && !existsSync(resolve(projectRoot, ".git"))) { - throw new Error("No Git repository found. Run `git init` first, then rerun setup."); + throw new SetupError("No Git repository found. Run `git init` first, then rerun setup."); } const scaffoldPopulatedAtStart = isScaffoldPopulated(mexDir); @@ -199,28 +214,51 @@ export async function runHeadlessSetup( report("tools"); const requestedTools = uniqueTools(options.tools ?? loadConfiguredAiTools(mexDir)); const selectedTools = requestedTools; - const anchorNotes = ensureToolAnchors(projectRoot, templatesDir, selectedTools, false); + let anchorNotes: string[]; + try { + anchorNotes = ensureToolAnchors(projectRoot, templatesDir, selectedTools, false, options.onMessage); + } catch (error) { + throw classifySetupFileSystemError(error, "Could not link the selected AI tool instruction files.") ?? error; + } options.onAnchorNotes?.(anchorNotes); - saveAiTools(mexDir, selectedTools); + try { + saveAiTools(mexDir, selectedTools); + } catch (error) { + throw classifySetupFileSystemError(error, "Could not save the AI tool selection in .mex/config.json.") ?? error; + } const selectedAgentClients = selectedTools.filter((tool) => tool === "claude" || tool === "codex"); if (selectedAgentClients.length > 0) { report("skills"); - const agentAssets = installSetupAgentAssets({ - projectRoot, - selectedTools, - dryRun: false, - checkIgnored: mode === "code-repo", - }); + let agentAssets: ReturnType; + try { + agentAssets = installSetupAgentAssets({ + projectRoot, + selectedTools, + dryRun: false, + checkIgnored: mode === "code-repo", + }); + } catch (error) { + throw classifySetupAgentAssetsError(error) ?? error; + } if (agentAssets?.conflicted) { - throw new Error("Official MEX agent assets have conflicts. Resolve them and rerun setup or mex skills sync."); + // Only known install destinations can enter the browser explanation. + const destinations = new Set(Object.values(AGENT_SKILL_TARGETS).flatMap((target) => [ + target.instructionsPath, + ...OFFICIAL_MEX_SKILLS.map((skill) => `${target.skillsDirectory}/${skill}`), + ])); + const conflicts = [...new Set(agentAssets.actions + .filter((action) => action.action === "conflict" && destinations.has(action.path)) + .map((action) => action.path))].slice(0, 2); + const detail = conflicts.length ? ` Check ${conflicts.join(" and ")}.` : ""; + throw new SetupError(`Official MEX agent assets have conflicts.${detail} Review them with mex skills sync --dry-run, resolve the conflicts, then retry setup.`); } } report("identity"); const identity = ensureScaffoldIdentity(mexDir, projectRoot); if (readScaffoldId(mexDir) !== identity.scaffold_id) { - throw new Error("Could not persist .mex/config.json. Fix its permissions or contents and rerun setup."); + throw new SetupError("Could not persist .mex/config.json. Fix its permissions or contents and rerun setup."); } let scannerBrief: string | null = null; @@ -231,7 +269,11 @@ export async function runHeadlessSetup( if (mode === "code-repo") { report("graph"); - await buildSetupGraph(projectRoot, { background: true, signal: options.signal }); + await buildSetupGraph(projectRoot, { + background: true, + signal: options.signal, + onProgress: options.onGraphProgress, + }); } const prompt = await buildSetupPopulationPrompt(mode, state, scannerBrief); @@ -244,7 +286,7 @@ export async function runHeadlessSetup( if (!populationFinished && options.confirmPopulation !== true) { throwIfSetupAborted(options.signal); - const launched = await launchHeadlessSetupPopulation({ + const launched = await (options.populate ?? launchHeadlessSetupPopulation)({ selectedTools, prompt, projectRoot, @@ -288,7 +330,10 @@ export async function runHeadlessSetup( if (mode === "code-repo") { report("finalize"); throwIfSetupAborted(options.signal); - await finalizeCodeRepoSetup(projectRoot, mexDir); + await finalizeCodeRepoSetup(projectRoot, mexDir, { + signal: options.signal, + onMessage: options.onMessage, + }); throwIfSetupAborted(options.signal); } diff --git a/src/setup/ignore.ts b/src/setup/ignore.ts index 49026571..c093278e 100644 --- a/src/setup/ignore.ts +++ b/src/setup/ignore.ts @@ -7,6 +7,7 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; +import { classifySetupFileSystemError, SetupError } from "./errors.js"; export const SETUP_IGNORE_PATH = ".mex/.gitignore" as const; export const SETUP_IGNORE_RULES = ["graph.db*", "wiki.db*", "local/"] as const; @@ -29,8 +30,8 @@ export interface SetupIgnoreProtectionResult { readonly addedRules: readonly string[]; } -export class SetupIgnoreProtectionError extends Error { - constructor(message: string, options?: ErrorOptions) { +export class SetupIgnoreProtectionError extends SetupError { + constructor(message: string, options?: ErrorOptions & { userMessage?: string }) { super(message, options); this.name = "SetupIgnoreProtectionError"; } @@ -54,6 +55,7 @@ export function ensureSetupIgnoreProtection( if (mexStats && (mexStats.isSymbolicLink() || !mexStats.isDirectory())) { throw new SetupIgnoreProtectionError( `Cannot protect local MEX data: ${mexDirectory} is not a safe directory.`, + { userMessage: "The .mex path must be a regular directory, not a symlink. Fix that path, then rerun setup." }, ); } @@ -61,10 +63,23 @@ export function ensureSetupIgnoreProtection( if (ignoreStats && (ignoreStats.isSymbolicLink() || !ignoreStats.isFile())) { throw new SetupIgnoreProtectionError( `Cannot protect local MEX data: ${ignorePath} is not a regular file.`, + { userMessage: "The .mex/.gitignore path must be a regular file, not a symlink. Fix that path, then rerun setup." }, ); } - const existing = ignoreStats ? readFileSync(ignorePath) : Buffer.alloc(0); + let existing: Buffer; + try { + existing = ignoreStats ? readFileSync(ignorePath) : Buffer.alloc(0); + } catch (error) { + throw new SetupIgnoreProtectionError( + error instanceof Error ? error.message : `Could not read local MEX ignore rules at ${ignorePath}.`, + { + cause: error, + userMessage: classifySetupFileSystemError(error, "Could not read .mex/.gitignore.")?.userMessage + ?? "Could not read .mex/.gitignore. Check its permissions, then rerun setup.", + }, + ); + } const content = existing.toString("utf8"); const presentRules = new Set(splitLines(content)); const addedRules = SETUP_IGNORE_RULES.filter((rule) => !presentRules.has(rule)); @@ -90,7 +105,11 @@ export function ensureSetupIgnoreProtection( } catch (error) { throw new SetupIgnoreProtectionError( `Could not create the MEX directory at ${mexDirectory}.`, - { cause: error }, + { + cause: error, + userMessage: classifySetupFileSystemError(error, "Could not create .mex.")?.userMessage + ?? "Could not create .mex. Check write permissions for the project directory, then rerun setup.", + }, ); } requireSafeDirectory(mexDirectory, ".mex directory"); @@ -109,7 +128,11 @@ export function ensureSetupIgnoreProtection( } catch (error) { throw new SetupIgnoreProtectionError( `Could not write local MEX data protection to ${ignorePath}.`, - { cause: error }, + { + cause: error, + userMessage: classifySetupFileSystemError(error, "Could not write .mex/.gitignore.")?.userMessage + ?? "Could not write .mex/.gitignore. Check write permissions for .mex and its ignore file, then rerun setup.", + }, ); } @@ -168,7 +191,9 @@ export function verifySetupIgnoreProtection(projectRoot: string): void { ".mex/local/setup-state.json", ]) { if (!isIgnored(root, path)) { - throw new SetupIgnoreProtectionError(`Local MEX path is not ignored by Git: ${path}`); + throw new SetupIgnoreProtectionError(`Local MEX path is not ignored by Git: ${path}`, { + userMessage: "Local MEX data is not ignored by Git. Check .mex/.gitignore and conflicting negation rules, then retry setup.", + }); } } } @@ -192,6 +217,9 @@ function requireSafeDirectory(path: string, label: string): void { if (!stats || stats.isSymbolicLink() || !stats.isDirectory()) { throw new SetupIgnoreProtectionError( `Cannot protect local MEX data: ${label} at ${path} is not a safe directory.`, + { userMessage: label === "project root" + ? "The project root must be a regular directory, not a symlink. Run setup from a valid project directory." + : "The .mex path must be a regular directory, not a symlink. Fix that path, then rerun setup." }, ); } } @@ -203,7 +231,11 @@ function lstatIfExists(path: string): ReturnType | null { if (isMissing(error)) return null; throw new SetupIgnoreProtectionError( `Could not inspect setup path ${path}.`, - { cause: error }, + { + cause: error, + userMessage: classifySetupFileSystemError(error, "Could not inspect the setup paths.")?.userMessage + ?? "Could not inspect the setup paths. Check permissions for the project directory, .mex, and .mex/.gitignore, then rerun setup.", + }, ); } } @@ -223,11 +255,13 @@ function isIgnored(projectRoot: string, path: string): boolean { if (checked.error) { throw new SetupIgnoreProtectionError("Could not run Git to verify local MEX data protection.", { cause: checked.error, + userMessage: "Could not run Git to verify ignore rules. Make sure Git is installed and available on PATH, then rerun setup.", }); } if (checked.status === 0) return true; if (checked.status === 1) return false; throw new SetupIgnoreProtectionError( `Git could not verify local MEX data protection: ${checked.stderr.trim() || `exit ${checked.status}`}`, + { userMessage: "Git could not verify ignore rules. Check that this project is a valid Git repository and run git status to diagnose it, then rerun setup." }, ); } diff --git a/src/setup/index.ts b/src/setup/index.ts index 61bcc895..2e0e214a 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -1,16 +1,9 @@ import { existsSync, readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; -import { createInterface } from "node:readline/promises"; -import { stdin, stdout } from "node:process"; import { globSync } from "glob"; import chalk from "chalk"; import { - saveAiTools, - ensureScaffoldIdentity, findConfig, - loadConfiguredAiTools, - hasConfiguredAiTools, - readScaffoldId, } from "../config.js"; import { captureGroundingBaselines, @@ -18,20 +11,13 @@ import { } from "../graph/runtime.js"; import { VERSION } from "../version.js"; import { - renderInstructionChangePreview, syncAgentAssets, type AgentAssetsReport, type AgentSkillClient, } from "../agent-skills/index.js"; -import { AI_TOOLS, type AiTool } from "../types.js"; -import { launchSetupPopulation } from "./population.js"; +import { type AiTool } from "../types.js"; import { - buildSetupPopulationPrompt, - buildSetupGraph, - createSetupScaffold, - resolveSetupMode, - scanSetupCodebase, - setupTemplatesDirectory, + throwIfSetupAborted, type ProjectState, } from "./phases.js"; export { @@ -46,6 +32,8 @@ export { type SetupMode, } from "./phases.js"; import { finalizeSetupWiki } from "./wiki-finalize.js"; +import { SetupError } from "./errors.js"; +import { classifySetupMaintenanceError, setupWikiFailureMessage } from "./maintenance-errors.js"; import { ensureMarkdownAnchor, ensureOpencodeAnchor, @@ -102,6 +90,7 @@ export function ensureToolAnchors( templatesDir: string, tools: readonly AiTool[], dryRun: boolean, + onMessage?: (message: string) => void, ): string[] { const notes: string[] = []; @@ -116,7 +105,7 @@ export function ensureToolAnchors( let result: AnchorWriteResult; if (dryRun) { if (!existsSync(dest)) { - ok(`(dry run) Would copy ${config.dest}`); + (onMessage ?? ok)(`(dry run) Would copy ${config.dest}`); continue; } result = isJson @@ -128,7 +117,7 @@ export function ensureToolAnchors( : ensureMarkdownAnchor(projectRoot, config.dest, src); } - const note = reportAnchor(config.dest, result, dryRun); + const note = reportAnchor(config.dest, result, dryRun, onMessage); if (note) notes.push(note); } @@ -136,27 +125,27 @@ export function ensureToolAnchors( } /** Print an anchor outcome; return a note for the ones the user must act on. */ -function reportAnchor(dest: string, result: AnchorWriteResult, dry: boolean): string | null { +function reportAnchor(dest: string, result: AnchorWriteResult, dry: boolean, onMessage?: (message: string) => void): string | null { const prefix = dry ? "(dry run) Would " : ""; switch (result.outcome) { case "created": - ok(`${prefix}${dry ? "copy" : "Copied"} ${dest}`); + (onMessage ?? ok)(`${prefix}${dry ? "copy" : "Copied"} ${dest}`); return null; case "appended": - ok(`${prefix}${dry ? "add" : "Added"} a MEX pointer to your existing ${dest}`); + (onMessage ?? ok)(`${prefix}${dry ? "add" : "Added"} a MEX pointer to your existing ${dest}`); return null; case "updated": - ok(`${prefix}${dry ? "refresh" : "Refreshed"} the MEX pointer in ${dest}`); + (onMessage ?? ok)(`${prefix}${dry ? "refresh" : "Refreshed"} the MEX pointer in ${dest}`); return null; case "already-linked": - info(`${dest} already points at .mex/ — left unchanged`); + (onMessage ?? info)(`${dest} already points at .mex/ — left unchanged`); return null; case "conflict": { const note = `${dest} was left untouched because ${result.reason}. ` + "Add this line to it by hand so the scaffold is loaded: " + "`At the start of every session, read .mex/AGENTS.md and .mex/ROUTER.md.`"; - warn(note); + (onMessage ?? warn)(note); return note; } } @@ -167,7 +156,6 @@ function reportAnchor(dest: string, result: AnchorWriteResult, dry: boolean): st const ok = (msg: string) => console.log(`${chalk.green("✓")} ${msg}`); const info = (msg: string) => console.log(`${chalk.blue("→")} ${msg}`); const warn = (msg: string) => console.log(`${chalk.yellow("!")} ${msg}`); -const header = (msg: string) => console.log(`\n${chalk.bold(msg)}`); export function findSetupProjectRoot(startDir: string = process.cwd()): string { let current = resolve(startDir); @@ -179,30 +167,6 @@ export function findSetupProjectRoot(startDir: string = process.cwd()): string { } } -function findProjectRoot(): string { - return findSetupProjectRoot(); -} - -function banner() { - const GRN = "\x1b[38;2;91;140;90m"; - const DGR = "\x1b[38;2;74;122;73m"; - const ORN = "\x1b[38;2;232;132;92m"; - const DRK = "\x1b[38;2;61;61;61m"; - const ROYAL = "\x1b[38;2;25;68;241m"; - const NC = "\x1b[0m"; - const BOLD = "\x1b[1m"; - - console.log(); - console.log(`${GRN} ████ ${ROYAL}███╗ ███╗███████╗██╗ ██╗${NC}`); - console.log(`${GRN} █${DGR}█${GRN}██${DGR}█${GRN}█ ${ROYAL}████╗ ████║██╔════╝╚██╗██╔╝${NC}`); - console.log(`${ORN} ██████████ ${ROYAL}██╔████╔██║█████╗ ╚███╔╝${NC}`); - console.log(`${ORN}█ ██${DRK}██${ORN}██${DRK}██${ORN}██ █ ${ROYAL}██║╚██╔╝██║██╔══╝ ██╔██╗${NC}`); - console.log(`${ORN}█ ██████████ █ ${ROYAL}██║ ╚═╝ ██║███████╗██╔╝ ██╗${NC}`); - console.log(`${ORN} █ █ █ █ ${ROYAL}╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝${NC}`); - console.log(); - console.log(` ${BOLD}universal ai context scaffold${NC}`); -} - // ── Main ── /** Exact git commands CLI setup prints after a successful code-repo run. */ @@ -225,229 +189,23 @@ export function setupCommitCheckpointCommands(selectedTools: readonly AiTool[]): export interface SetupRunOptions { dryRun?: boolean; mode?: string; - /** Use these AI tools instead of asking (`--tool`, repeatable). */ tools?: AiTool[]; - /** - * Never wait on a question (`--yes`): population counts as finished once the - * scaffold has no placeholders left, and the optional closing prompts are - * skipped. For scripts, CI and benchmarks. - */ + /** Plain, noninteractive setup. Never starts an agent or a browser. */ yes?: boolean; + port?: number; + openBrowser?: boolean; } -export async function runSetup(opts: SetupRunOptions = {}): Promise { - const { dryRun = false, yes = false } = opts; - - banner(); - console.log(); - - if (dryRun) { - warn("DRY RUN — no files will be created or modified"); - console.log(); - } - - const templatesDir = setupTemplatesDirectory(); - const projectRoot = findProjectRoot(); - const mexDir = resolve(projectRoot, ".mex"); - const mode = resolveSetupMode(mexDir, opts.mode); - - if (mode === "code-repo" && !existsSync(resolve(projectRoot, ".git"))) { - throw new Error("No Git repository found. Run `git init` first, then rerun mex setup --cli."); - } - - // ── Step 1: Detect project state ── - - const scaffoldPopulatedAtStart = isScaffoldPopulated(mexDir); - const state = detectProjectState(projectRoot, mexDir); - - if (mode === "agent-memory") { - info("Detected: agent-memory workspace"); - info("Mode: persistent-agent operational memory"); - } else { - switch (state) { - case "existing": - info("Detected: existing codebase with source files"); - info("Mode: populate scaffold from code"); - break; - case "fresh": - info("Detected: fresh project (no source files yet)"); - info("Mode: populate scaffold from intent"); - break; - case "partial": - info("Detected: existing codebase with a populated scaffold"); - info("Mode: preserve authored files and finish setup readiness"); - break; - } - } - console.log(); - - // ── Step 2: Create .mex/ scaffold ── - - header("Creating .mex/ scaffold..."); - console.log(); - - createSetupScaffold({ - projectRoot, - mode, - dryRun, - onIgnore: (message, changed) => changed ? ok(message) : info(message), - onFile: (file, action) => { - if (action === "skip") info(`Skipped .mex/${file} (already exists)`); - else ok(`${dryRun ? "(dry run) Would copy" : "Copied"} .mex/${file}`); - }, - }); - console.log(); - - // ── Step 3: Tool config selection ── - - let selectedTools: AiTool[] = []; - let anchorNotes: string[] = []; - - // A persisted selection belongs to the scaffold even when population was - // interrupted or the templates gained new required slots. Reuse it instead - // of making a resumed setup ask the user the same question again. - const configuredTools = loadConfiguredAiTools(mexDir); - if (opts.tools !== undefined) { - selectedTools = [...new Set(opts.tools)]; - anchorNotes = ensureToolAnchors(projectRoot, templatesDir, selectedTools, dryRun); - if (!dryRun) saveAiTools(mexDir, selectedTools); - info(`Using AI tools from --tool: ${selectedTools.map((tool) => AI_TOOLS[tool].name).join(", ") || "none"}`); - } else if (hasConfiguredAiTools(mexDir)) { - selectedTools = configuredTools; - // A scaffold orphaned by the old skip lands here, not in the menu - // branch: it is populated and its aiTools are saved. Link on this path - // too, or rerunning setup could never repair the installs that need it. - anchorNotes = ensureToolAnchors(projectRoot, templatesDir, selectedTools, dryRun); - info(`Using configured AI tools: ${selectedTools.map((tool) => AI_TOOLS[tool].name).join(", ") || "none"}`); - } else { - const rl = createInterface({ input: stdin, output: stdout }); - try { - const selection = await selectToolConfig(rl, projectRoot, templatesDir, dryRun); - selectedTools = selection.tools; - anchorNotes = selection.anchorNotes; - } finally { - rl.close(); - } - } - console.log(); - - const selectedAgentClients = selectedTools.filter( - (tool): tool is AgentSkillClient => tool === "claude" || tool === "codex", - ); - if (selectedAgentClients.length > 0) { - header("Installing official MEX agent skills..."); - console.log(); - const agentAssets = installSetupAgentAssets({ - projectRoot, - selectedTools, - dryRun, - checkIgnored: mode === "code-repo", - })!; - renderAgentAssetsReport(agentAssets); - console.log(); - const sessionSummary = `a new ${formatAgentClientList(agentAssets.clients)} session ` - + "to guarantee the new skills and project instructions are loaded."; - if (agentAssets.conflicted) { - throw new Error("Official MEX agent assets have conflicts. Resolve the warnings above and rerun setup or mex skills sync."); - } else if (dryRun) { - info(`After applying this setup, start ${sessionSummary}`); - } else { - info(`Start ${sessionSummary}`); - } - console.log(); - } - - // Mint a stable scaffold identity. Independent of tool selection so a setup - // that picks no AI tool still gets a scaffold_id written to config.json. - if (!dryRun) { - const identity = ensureScaffoldIdentity(mexDir, projectRoot); - if (readScaffoldId(mexDir) !== identity.scaffold_id) { - throw new Error("Could not persist .mex/config.json. Fix its permissions or contents and rerun setup."); - } - } - - // ── Step 4: Run scanner (if not fresh) ── - - let scannerBrief: string | null = null; - - if (mode === "code-repo" && state !== "fresh") { - info("Scanning codebase..."); - scannerBrief = await scanSetupCodebase(projectRoot, mexDir); - if (scannerBrief) ok("Pre-analysis complete — AI will reason from brief instead of exploring"); - else warn("Scanner failed — AI will explore the filesystem directly"); - } - - if (mode === "code-repo" && !dryRun) { - info("Building code graph..."); - await buildSetupGraph(projectRoot); - ok("Code graph ready"); - } - - const prompt = await buildSetupPopulationPrompt(mode, state, scannerBrief); - - // ── Step 6: Run or print ── - - if (dryRun) { - header("Would run population prompt (dry run — skipping)"); - console.log(); - ok("Done (dry run)."); - return; - } - - let populationFinished = scaffoldPopulatedAtStart; - if (populationFinished) { - header("Finishing setup from the existing populated scaffold..."); - console.log(); - } else { - header("Launching an agent to populate the scaffold..."); - console.log(); - info("The first selected available Claude Code or Codex CLI will run in the project root."); - console.log(); - const launched = launchSetupPopulation(selectedTools, prompt, projectRoot); - if (launched.completed) { - ok(`${AI_TOOLS[launched.tool!].name} finished the population session`); - populationFinished = isScaffoldPopulated(mexDir); - if (!populationFinished) { - warn("The agent exited successfully, but required scaffold placeholders remain."); - } - } else if (launched.tool !== null) { - warn(`${AI_TOOLS[launched.tool].name} did not complete population.`); - } - } - - if (!populationFinished) { - header("Almost done. One more step — populate the scaffold."); - console.log(); - info("Paste the prompt below into your AI tool."); - info("The agent will read your codebase and fill every scaffold file."); - printPromptForManualPaste(prompt); - populationFinished = yes ? isScaffoldPopulated(mexDir) : await confirmPopulationFinished(mexDir); - } - - if (!populationFinished || !isScaffoldPopulated(mexDir)) { - console.log(); - info("Setup paused at population. After the agent finishes, rerun `mex setup --cli` to finalize Graph and Wiki readiness."); - // The anchors were written before population, so an unlinked one is just - // as true on this path -- and this is the last output the user sees. - printAnchorNotes(anchorNotes); - return; - } - - if (mode === "code-repo") { - await finalizeCodeRepoSetup(projectRoot, mexDir); - console.log(); - ok("Graph and Wiki are ready. Setup is ready to commit."); - printCommitCheckpoint(selectedTools); - } else { - console.log(); - ok("Setup complete."); - } +export interface SetupRunResult { + outcome: "complete" | "paused" | "cancelled" | "failed" | "dry-run"; + message: string; + /** 2 is a resumable pause; 130/143 indicate cancellation. */ + exitCode: number; +} - printAnchorNotes(anchorNotes); - if (yes) return; - await promptGlobalInstall(); - if (process.exitCode === 130 || process.exitCode === 143) return; - await promptSetupContact(); +export async function runSetup(opts: SetupRunOptions = {}): Promise { + const { runTerminalSetup } = await import("./terminal.js"); + return runTerminalSetup(opts); } /** Parse `--tool` values for a non-interactive setup; unknown names are an error. */ @@ -513,85 +271,6 @@ const TOOL_CHOICE_MAP: Record = { "6": "codex", }; -async function selectToolConfig( - rl: ReturnType, - projectRoot: string, - templatesDir: string, - dryRun: boolean, -): Promise<{ tools: AiTool[]; anchorNotes: string[] }> { - header("Which AI tool do you use?"); - console.log(); - console.log(" 1) Claude Code"); - console.log(" 2) Cursor"); - console.log(" 3) Windsurf"); - console.log(" 4) GitHub Copilot"); - console.log(" 5) OpenCode"); - console.log(" 6) Codex (OpenAI)"); - console.log(" 7) Multiple (select next)"); - console.log(" 8) None / skip"); - console.log(); - - const choice = (await rl.question("Choice [1-8] (default: 1): ")).trim() || "1"; - - const selectedTools: AiTool[] = []; - - const copyConfig = (key: string) => { - const tool = TOOL_CHOICE_MAP[key]; - if (!tool) return; - selectedTools.push(tool); - }; - - switch (choice) { - case "1": - case "2": - case "3": - case "4": - case "5": - case "6": - copyConfig(choice); - break; - case "7": { - const multi = (await rl.question("Enter tool numbers separated by spaces (e.g. 1 2 5): ")).trim(); - for (const c of multi.split(/\s+/)) { - copyConfig(c); - } - break; - } - case "8": - info("Skipped tool config — AGENTS.md in .mex/ works with any tool that can read files"); - break; - default: - warn("Unknown choice, skipping tool config"); - break; - } - - const anchorNotes = ensureToolAnchors(projectRoot, templatesDir, selectedTools, dryRun); - - // Persist tool selection - if (!dryRun) { - const mexDir = resolve(projectRoot, ".mex"); - saveAiTools(mexDir, selectedTools); - } - - return { tools: [...new Set(selectedTools)], anchorNotes }; -} - -function renderAgentAssetsReport(report: AgentAssetsReport): void { - for (const action of report.actions) { - if (action.action === "conflict") continue; - if (action.action === "noop") info(action.message); - else ok(action.message); - if (report.dryRun) { - const preview = renderInstructionChangePreview(action); - if (preview !== null) console.log(preview); - } - } - for (const warning of report.warnings) { - warn(warning.message); - if (warning.resolution) info(warning.resolution); - } -} - export interface InstallSetupAgentAssetsOptions { projectRoot: string; selectedTools: readonly AiTool[]; @@ -624,51 +303,13 @@ export function installSetupAgentAssets( }); } -function formatAgentClientList(clients: readonly AgentSkillClient[]): string { - const labels = [...new Set(clients)].map((client) => ( - client === "claude" ? "Claude Code" : "Codex" - )); - if (labels.length === 1) return labels[0]!; - return `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`; -} - -function printPromptForManualPaste(prompt: string): void { - console.log(); - console.log("─────────────────── COPY BELOW THIS LINE ───────────────────"); - console.log(); - console.log(prompt); - console.log(); - console.log("─────────────────── COPY ABOVE THIS LINE ───────────────────"); - console.log(); - ok("Paste the prompt above into your agent to populate the scaffold."); -} - -async function confirmPopulationFinished(mexDir: string): Promise { - if (!stdin.isTTY) return false; - const rl = createInterface({ input: stdin, output: stdout }); - try { - console.log(); - info("After the agent finishes populating, return here to finish setup."); - const answer = (await rl.question(" Has population finished? [y/N] ")).trim().toLowerCase(); - if (answer !== "y" && answer !== "yes") return false; - if (!isScaffoldPopulated(mexDir)) { - warn("Required placeholders remain in the .mex scaffold files."); - return false; - } - return true; - } finally { - rl.close(); - } -} - /** - * A finalization failure whose message is composed here from MEX-authored text, - * relative scaffold paths, and scaffold-authored node ids — never from arbitrary - * exceptions — so the Hub may show it instead of a generic failure. + * Expected finalization failure. The Hub uses its authored userMessage; + * message may retain fuller terminal diagnostics without exposing them there. */ -export class SetupFinalizationError extends Error { - constructor(message: string) { - super(message); +export class SetupFinalizationError extends SetupError { + constructor(message: string, options?: ErrorOptions & { userMessage?: string }) { + super(message, options); this.name = "SetupFinalizationError"; } } @@ -677,7 +318,14 @@ export class SetupFinalizationError extends Error { const FINALIZATION_DETAIL_LIMIT = 2; const FINALIZATION_DETAIL_CHARS = 140; -export async function finalizeCodeRepoSetup(projectRoot: string, mexDir: string): Promise { +export async function finalizeCodeRepoSetup(projectRoot: string, mexDir: string, options: { + signal?: AbortSignal; + onMessage?: (message: string) => void; +} = {}): Promise { + const info = options.onMessage ?? ((message: string) => console.log(message)); + const warn = info; + const ok = info; + throwIfSetupAborted(options.signal); info("Capturing grounding baselines..."); const captureWarnings: string[] = []; let result: GroundingBaselineCaptureResult; @@ -694,9 +342,12 @@ export async function finalizeCodeRepoSetup(projectRoot: string, mexDir: string) }, ); } catch (error) { + const expected = classifySetupMaintenanceError(error, "grounding"); + if (expected) throw expected; const message = error instanceof Error ? error.message : String(error); throw new Error(`Grounding finalization failed: ${message}. Rerun mex setup after fixing it.`); } + throwIfSetupAborted(options.signal); try { assertGroundingCaptureReady(result); } catch (error) { @@ -710,18 +361,27 @@ export async function finalizeCodeRepoSetup(projectRoot: string, mexDir: string) else info("No authored grounding baselines needed capture"); const config = findConfig(projectRoot); - const wiki = await finalizeSetupWiki({ - projectRoot, - scaffoldRoot: mexDir, - exclude: config.wiki?.exclude, - readOnly: config.wiki?.readOnly, - onProgress: info, - onWarning: warn, - }); + let wiki: Awaited>; + try { + wiki = await finalizeSetupWiki({ + projectRoot, + scaffoldRoot: mexDir, + exclude: config.wiki?.exclude, + readOnly: config.wiki?.readOnly, + signal: options.signal, + onProgress: info, + onWarning: warn, + }); + } catch (error) { + throwIfSetupAborted(options.signal); + throw classifySetupMaintenanceError(error, "wiki") ?? error; + } + throwIfSetupAborted(options.signal); if (!wiki.ready) { const codes = [...new Set(wiki.diagnostics.map((entry) => entry.code))].join(", "); const suffix = codes.length === 0 ? "" : ` (${codes})`; - throw new SetupFinalizationError(`${wiki.reason ?? "Wiki setup did not finish."}${suffix} Fix the issue and rerun mex setup --cli.`); + const userMessage = setupWikiFailureMessage(wiki.stage, wiki.diagnostics); + throw new SetupFinalizationError(`${wiki.reason ?? "Wiki setup did not finish."}${suffix} ${userMessage}`, { userMessage }); } ok(`Wiki ready with ${wiki.indexedEntities} indexed entit${wiki.indexedEntities === 1 ? "y" : "ies"}`); } @@ -733,135 +393,3 @@ export function assertGroundingCaptureReady(result: GroundingBaselineCaptureResu ); } } - -/** - * Repeat anchors that could not be linked automatically. - * - * The warning at the moment of the decision scrolls past behind population - * output and the readiness report, and the whole failure mode of #106 is that - * the user never learns the scaffold is not being loaded. Saying it again at - * the end is the last point where it is still in front of them. - */ -function printAnchorNotes(notes: readonly string[]): void { - if (notes.length === 0) return; - console.log(); - header("Action needed: these files do not point at the scaffold yet"); - console.log(); - for (const note of notes) warn(note); - console.log(); - info("Until one always-loaded file names `.mex/`, your agent will not read the scaffold."); - info("Run `mex check` after fixing them to confirm."); -} - -function printCommitCheckpoint(selectedTools: readonly AiTool[]): void { - header("Commit the canonical MEX setup before opening Hub"); - console.log(); - info("Review the scoped files, then commit them. MEX will not stage or commit automatically."); - for (const command of setupCommitCheckpointCommands(selectedTools)) { - console.log(` ${command}`); - } - console.log(); - info("After that commit, start Hub with `mex hub` (or `npx mex-agent hub`)."); -} - -async function promptGlobalInstall(): Promise { - if (!stdin.isTTY) { - printNextSteps(false); - return; - } - const rl = createInterface({ input: stdin, output: stdout }); - try { - header("One more thing"); - console.log(); - info("Install mex globally so `mex check` works anywhere?"); - console.log(); - - const answer = (await rl.question(" Install mex globally? [y/N] ")).trim().toLowerCase(); - - if (answer === "y" || answer === "yes") { - console.log(); - info("Installing mex-agent globally..."); - try { - const { SetupGlobalInstaller } = await import("./global-install.js"); - const installer = new SetupGlobalInstaller(); - const interrupt = () => { process.exitCode = 130; void installer.shutdown(); }; - const terminate = () => { process.exitCode = 143; void installer.shutdown(); }; - process.once("SIGINT", interrupt); - process.once("SIGTERM", terminate); - let result; - try { installer.start(); result = await installer.wait(); } - finally { process.removeListener("SIGINT", interrupt); process.removeListener("SIGTERM", terminate); } - if (result.state !== "succeeded") throw new Error(result.message); - console.log(); - ok(result.message); - printNextSteps(true); - } catch { - console.log(); - warn("Global install failed. You can retry manually:"); - console.log(` npm install -g mex-agent@${VERSION}`); - console.log(); - printNextSteps(false); - } - } else { - console.log(); - info("No problem. You can always install later:"); - console.log(` npm install -g mex-agent@${VERSION}`); - console.log(); - printNextSteps(false); - } - } finally { - rl.close(); - } -} - -async function promptSetupContact(): Promise { - if (!stdin.isTTY) return; - const { readContactPreference, rememberContactPreference, submitSetupContact } = await import("./contact.js"); - if (readContactPreference().status !== "unasked") return; - const rl = createInterface({ input: stdin, output: stdout }); - try { - header("Help shape MEX (optional)"); - info("Leave your email if we may follow up about your experience with MEX. Your details are sent through Web3Forms, separately from usage telemetry."); - const answer = (await rl.question(" May we contact you? [y/N] ")).trim().toLowerCase(); - if (answer !== "y" && answer !== "yes") { - await rememberContactPreference({ status: "skipped" }); - return; - } - const email = (await rl.question(" Email (leave empty to skip): ")).trim(); - if (!email) { await rememberContactPreference({ status: "skipped" }); return; } - const name = (await rl.question(" Name (optional): ")).trim(); - const { SetupContactRequestSchema } = await import("@mex/hub-contracts/setup"); - const parsed = SetupContactRequestSchema.safeParse({ email, name }); - if (!parsed.success) { warn("The contact details were not valid, so nothing was sent. Setup is complete."); return; } - const result = await submitSetupContact(parsed.data); - if (result.ok) ok(result.message); else warn(result.message); - } catch { - warn("The optional contact step could not finish. Setup is complete; you can continue using MEX."); - } finally { rl.close(); } -} - -function printNextSteps(globalInstalled: boolean) { - header("What's next"); - console.log(); - info("Verify — start a fresh session and ask:"); - console.log(' "Read .mex/ROUTER.md and tell me what you know about this project."'); - console.log(); - - if (globalInstalled) { - info("Ongoing commands:"); - console.log(" mex check Drift score — are scaffold files still accurate?"); - console.log(" mex check --quiet One-liner drift score"); - console.log(" mex sync Fix drift — AI updates only what's broken"); - console.log(" mex watch Auto-check drift after every commit"); - } else { - info("Ongoing commands (via npx):"); - console.log(` npx mex-agent@${VERSION} check Drift score — are scaffold files still accurate?`); - console.log(` npx mex-agent@${VERSION} check --quiet One-liner drift score`); - console.log(` npx mex-agent@${VERSION} sync Fix drift — AI updates only what's broken`); - console.log(` npx mex-agent@${VERSION} watch Auto-check drift after every commit`); - console.log(); - info("Or install globally to use the shorter `mex` command:"); - console.log(` npm install -g mex-agent@${VERSION}`); - } - console.log(); -} diff --git a/src/setup/interactive-population.ts b/src/setup/interactive-population.ts new file mode 100644 index 00000000..1b9105ce --- /dev/null +++ b/src/setup/interactive-population.ts @@ -0,0 +1,273 @@ +import crossSpawn from "cross-spawn"; +import { execFile, type ChildProcess } from "node:child_process"; +import { buildAgentCommand } from "../agent-command.js"; +import { AI_TOOLS, type AiTool } from "../types.js"; +import { createPopulationSession, type SetupAgentTool } from "./population.js"; + +const DISCOVERY_TIMEOUT_MS = 5_000; +const PROCESS_SNAPSHOT_TIMEOUT_MS = 2_000; +const PROCESS_SNAPSHOT_BYTES = 2 * 1024 * 1024; +const MAX_OWNED_PROCESSES = 4_096; +const PROCESS_POLL_MS = 100; +const TERMINATE_GRACE_MS = 500; + +export interface InteractivePopulationResult { + readonly tool: SetupAgentTool | null; + /** A clean agent exit is not confirmation that setup is complete. */ + readonly status: "exited" | "failed" | "cancelled" | "unavailable"; + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + readonly message?: string; +} + +interface ProcessIdentity { + readonly pid: number; + readonly parentPid: number; + readonly started: string; +} + +export interface InteractivePopulationOptions { + readonly selectedTools: readonly AiTool[]; + readonly prompt: string; + readonly projectRoot: string; + readonly signal?: AbortSignal; + /** Local subprocess test seams, never accepted from a setup request. */ + readonly __internal?: { + readonly isAvailable?: (command: string) => Promise; + readonly spawn?: typeof crossSpawn; + readonly snapshot?: () => Promise; + readonly kill?: (pid: number, signal: NodeJS.Signals) => void; + }; +} + +/** Hand the existing terminal to the agent; the caller suspends and resumes its UI. */ +export async function launchInteractiveSetupPopulation( + options: InteractivePopulationOptions, +): Promise { + let tool: SetupAgentTool | null = null; + const outcome = (status: InteractivePopulationResult["status"], message: string): InteractivePopulationResult => ({ + tool, status, exitCode: null, signal: null, message, + }); + if (options.signal?.aborted) return outcome("cancelled", "AI population was cancelled. You can resume setup when ready."); + + for (const candidate of options.selectedTools) { + if (candidate !== "claude" && candidate !== "codex") continue; + let available: boolean; + try { + available = await (options.__internal?.isAvailable ?? ((command) => isAvailable(command, options.signal)))(AI_TOOLS[candidate].cli!); + } catch { + return outcome(options.signal?.aborted ? "cancelled" : "failed", options.signal?.aborted + ? "AI population was cancelled. You can resume setup when ready." + : "MEX could not check the selected AI CLI. Check its installation and PATH, then resume setup."); + } + if (options.signal?.aborted) return outcome("cancelled", "AI population was cancelled. You can resume setup when ready."); + if (available) { tool = candidate; break; } + } + if (!tool) return outcome("unavailable", "No selected interactive AI CLI is available. Install and sign in to a selected CLI, then resume setup."); + + let session: ReturnType; + try { + session = createPopulationSession(options.prompt, options.projectRoot); + } catch { + return outcome("failed", "The private setup population prompt could not be prepared. Check .mex/local permissions and available disk space, then resume setup."); + } + + let result: InteractivePopulationResult; + try { + result = await runInteractiveAgent(tool, session.instruction, session.root, options); + } finally { + // Cleanup happens only after process cleanup, including cancellation escalation. + try { session.cleanup(); } + catch { + result = outcome(options.signal?.aborted ? "cancelled" : "failed", "The private setup prompt could not be removed. Check .mex/local permissions and remove its setup-population folder before resuming setup."); + } + } + return result!; +} + +function isAvailable(command: string, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + const child = crossSpawn(process.platform === "win32" ? "where" : "which", [command], { + stdio: "ignore", windowsHide: true, timeout: DISCOVERY_TIMEOUT_MS, signal, + }); + child.once("error", () => resolve(false)); + child.once("close", (code) => resolve(code === 0)); + }); +} + +function runInteractiveAgent( + tool: SetupAgentTool, + instruction: string, + cwd: string, + options: InteractivePopulationOptions, +): Promise { + const name = AI_TOOLS[tool].name; + const cancelled = (): InteractivePopulationResult => ({ tool, status: "cancelled", exitCode: null, signal: null, + message: "AI population was cancelled. You can resume setup when ready." }); + if (options.signal?.aborted) return Promise.resolve(cancelled()); + const invocation = buildAgentCommand(tool, instruction, "interactive")!; + return new Promise((resolve) => { + let child: ChildProcess; + try { + child = (options.__internal?.spawn ?? crossSpawn)(invocation.command, invocation.args, { + cwd, + stdio: "inherit", + // A detached POSIX child cannot read the foreground terminal normally. + // Never signal its process group: it shares that group with MEX. + detached: false, + }); + } catch { + resolve({ tool, status: "failed", exitCode: null, signal: null, + message: `${name} could not start. Check its installation and resume setup.` }); + return; + } + + let launchFailed = false; + let cleanupVerified = true; + let wasCancelled = false; + let closed = false; + let cleanup: Promise | undefined; + const owner = ownProcessTree(child, options); + const startCleanup = () => owner.stop().then((verified) => { cleanupVerified = verified; }).catch(() => { + // A cleanup error must not strand the UI waiting for this launch result. + cleanupVerified = false; + try { child.kill("SIGKILL"); } catch { /* Already gone. */ } + }); + const stop = () => { + if (closed) return; + wasCancelled = true; + cleanup ??= startCleanup(); + }; + options.signal?.addEventListener("abort", stop, { once: true }); + child.once("error", () => { launchFailed = true; cleanup ??= startCleanup(); }); + child.once("exit", () => { cleanup ??= startCleanup(); }); + child.once("close", async (exitCode, signal) => { + closed = true; + options.signal?.removeEventListener("abort", stop); + cleanup ??= startCleanup(); + await cleanup; + const cleanupWarning = "MEX could not verify that the agent's helper processes stopped. Close any remaining agent sessions before resuming setup."; + if (wasCancelled || options.signal?.aborted || signal === "SIGINT") resolve({ ...cancelled(), exitCode, signal, + ...(!cleanupVerified ? { message: `AI population was cancelled. ${cleanupWarning}` } : {}) }); + else if (launchFailed) resolve({ tool, status: "failed", exitCode, signal, + message: `${name} could not start. Check its installation and resume setup.` }); + else if (!cleanupVerified) resolve({ tool, status: "failed", exitCode, signal, message: cleanupWarning }); + else if (exitCode !== 0 || signal) resolve({ tool, status: "failed", exitCode, signal, + message: `${name} stopped before a clean exit. Review its terminal output and resume setup.` }); + else resolve({ tool, status: "exited", exitCode, signal }); + }); + if (options.signal?.aborted) stop(); + }); +} + +/** + * Inherited terminal access precludes the detached group used by headless setup. + * Track only this child's observed descendants and revalidate identities before + * signalling individual PIDs. Never adopt siblings or signal the foreground group. + * A descendant that daemonizes between snapshots cannot safely be rediscovered. + */ +function ownProcessTree(child: ChildProcess, options: InteractivePopulationOptions): { stop: () => Promise } { + const pid = child.pid; + const snapshot = options.__internal?.snapshot ?? snapshotProcesses; + const kill = options.__internal?.kill ?? ((target, signal) => { process.kill(target, signal); }); + const owned = new Map(); + let exited = false; + let stopping = false; + let refresh: Promise | undefined; + child.once("exit", () => { exited = true; }); + + const capture = (): Promise => { + if (refresh) return refresh; + refresh = (async () => { + let processes: readonly ProcessIdentity[] | null; + try { processes = await snapshot(); } catch { return false; } + if (!processes) return false; + const byPid = new Map(processes.map((entry) => [entry.pid, entry])); + for (const [target, identity] of owned) { + if (byPid.get(target)?.started !== identity.started) owned.delete(target); + } + const root = pid ? byPid.get(pid) : undefined; + if (!exited && root && !owned.has(root.pid)) owned.set(root.pid, root); + // Reparented descendants remain owned only while their start identity matches. + const children = new Map(); + for (const entry of processes) { + const siblings = children.get(entry.parentPid) ?? []; + siblings.push(entry); + children.set(entry.parentPid, siblings); + } + const parents = [...owned.keys()]; + for (let index = 0; index < parents.length && owned.size < MAX_OWNED_PROCESSES; index++) { + for (const entry of children.get(parents[index]!) ?? []) { + if (owned.size >= MAX_OWNED_PROCESSES) break; + if (owned.has(entry.pid) || entry.pid === process.pid) continue; + owned.set(entry.pid, entry); + parents.push(entry.pid); + } + } + return owned.size < MAX_OWNED_PROCESSES; + })().finally(() => { refresh = undefined; }); + return refresh; + }; + const timer = process.platform !== "win32" && pid ? setInterval(() => { if (!stopping) void capture(); }, PROCESS_POLL_MS) : undefined; + timer?.unref(); + if (process.platform !== "win32" && pid) void capture(); + + const signalOwned = async (signal: NodeJS.Signals) => { + const refreshed = await capture(); + let verified = refreshed; + // Descendants first; parents can reap them before they themselves stop. + for (const target of refreshed ? [...owned.keys()].reverse() : []) { + if (target === pid || target === process.pid) continue; + try { kill(target, signal); } + catch (error) { if ((error as NodeJS.ErrnoException)?.code !== "ESRCH") verified = false; } + } + if (!exited) { + try { if (!child.kill(signal)) verified = false; } + catch (error) { if ((error as NodeJS.ErrnoException)?.code !== "ESRCH") verified = false; } + } + return verified; + }; + return { + stop: async () => { + stopping = true; + if (timer) clearInterval(timer); + if (!pid) return true; + if (process.platform === "win32") { + return exited || await stopWindowsTree(child); + } + const terminated = await signalOwned("SIGTERM"); + if (terminated && exited && owned.size === 0) return true; + await new Promise((resolve) => setTimeout(resolve, TERMINATE_GRACE_MS)); + return await signalOwned("SIGKILL") && terminated; + }, + }; +} + +function snapshotProcesses(): Promise { + return new Promise((resolve) => { + execFile("ps", ["-A", "-o", "pid=,ppid=,lstart="], { + encoding: "utf8", env: { ...process.env, LC_ALL: "C" }, + timeout: PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: PROCESS_SNAPSHOT_BYTES, killSignal: "SIGKILL", + }, (error, stdout) => { + if (error) { resolve(null); return; } + const processes: ProcessIdentity[] = []; + for (const line of stdout.split("\n")) { + const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/u.exec(line); + if (match) processes.push({ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3]! }); + } + resolve(processes); + }); + }); +} + +function stopWindowsTree(child: ChildProcess): Promise { + return new Promise((resolve) => { + // Reuse the headless ownership strategy for npm .cmd wrappers on Windows. + const killer = crossSpawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", windowsHide: true, timeout: DISCOVERY_TIMEOUT_MS, + }); + const done = (verified: boolean) => { try { child.kill("SIGKILL"); } catch { /* Already gone. */ } resolve(verified); }; + killer.once("error", () => done(false)); + killer.once("close", (code) => done(code === 0)); + }); +} diff --git a/src/setup/maintenance-errors.ts b/src/setup/maintenance-errors.ts new file mode 100644 index 00000000..2243a276 --- /dev/null +++ b/src/setup/maintenance-errors.ts @@ -0,0 +1,157 @@ +import { GraphCandidateProcessError } from "../graph/candidate-process.js"; +import { IndexInUseError, IndexPathError } from "../wiki/index/dbfile.js"; +import { WikiSourceReadError } from "../wiki/index/source-read.js"; +import { WIKI_DIAGNOSTICS, type WikiDiagnostic } from "../wiki/model/diagnostic.js"; +import { SetupError, classifySetupFileSystemError } from "./errors.js"; +import type { SetupWikiFinalizationStage } from "./wiki-finalize.js"; + +type SetupMaintenancePhase = "graph" | "grounding" | "wiki"; + +const PHASE_LABELS: Record = { + graph: "Code graph setup", + grounding: "Grounding finalization", + wiki: "Wiki finalization", +}; + +/** Closed codes select authored advice; exception text remains terminal-only. */ +const RECOVERY_BY_CODE: Readonly> = { + GRAPH_INDEX_MISSING: "The code graph is missing. Run `mex graph rebuild`, then retry setup.", + GRAPH_INDEX_NOT_REFRESHABLE: "The code graph needs a rebuild. Run `mex graph rebuild`, resolve any reported problems, then retry setup.", + GRAPH_INDEX_NOT_REPAIRABLE: "The code graph cannot be repaired safely. Run `mex graph rebuild`, then retry setup.", + GRAPH_MAINTENANCE_LOCKED: "Another graph operation is active. Wait for it to finish, then retry setup.", + GRAPH_MAINTENANCE_GATE_STALE: "An interrupted graph operation left .mex/graph.db.lock.gate behind. Verify no MEX process is running before removing that gate, then retry setup.", + GRAPH_MAINTENANCE_CANCELLED: "Graph maintenance was interrupted. Retry setup when ready.", + GRAPH_MAINTENANCE_PATH_UNSAFE: "A graph database or maintenance path is unsafe. Check .mex for redirected or conflicting paths, correct them, then retry setup.", + GRAPH_MAINTENANCE_RACE: "The graph or project files changed during maintenance. Wait for other writers to finish, then retry setup.", + GRAPH_CANDIDATE_INVALID: "The rebuilt graph failed validation. Run `mex graph rebuild` to inspect its diagnostics, resolve the reported problems, then retry setup.", + GRAPH_PUBLICATION_FAILED: "The graph could not be published safely and may need recovery. Keep any .mex/graph.db recovery files and inspect `mex graph status` before retrying setup.", + GRAPH_SOURCE_STAGING_FAILED: "Source files could not be read or parsed safely. Check `mex graph rebuild` diagnostics, correct the reported files, then retry setup.", + WIKI_MAINTENANCE_LOCKED: "Another Wiki operation is active. Wait for it to finish, then retry setup.", + WIKI_CORPUS_LIMIT_EXCEEDED: "The Wiki exceeds a file, directory, or size safety limit. Reduce oversized content or exclude unrelated files using wiki.exclude in .mex/config.json, then retry setup.", + WIKI_INDEX_RECOVERY_REQUIRED: "The Wiki index could not be published and recovery files were retained. Keep the .mex/wiki.db recovery files and inspect them before retrying setup.", + OPERATION_INTERRUPTED: "Wiki files changed or maintenance was interrupted. Wait for other writers to finish, then retry setup.", +}; + +/** Expected maintenance failures only. Unknown exceptions retain the Hub fallback. */ +export function classifySetupMaintenanceError( + error: unknown, + phase: SetupMaintenancePhase, +): SetupError | null { + if (error instanceof SetupError) return error; + if (!(error instanceof Error)) return null; + const label = PHASE_LABELS[phase]; + let recovery: string | undefined; + if ("code" in error && typeof error.code === "string" && Object.hasOwn(RECOVERY_BY_CODE, error.code)) { + recovery = RECOVERY_BY_CODE[error.code]; + } else if (error instanceof GraphCandidateProcessError) { + recovery = error.category === "compatibility" + ? "The graph worker could not use the current index. Run `mex graph rebuild` with the current MEX installation, then retry setup." + : error.category === "staging" + ? RECOVERY_BY_CODE.GRAPH_SOURCE_STAGING_FAILED + : error.category === "unsafe" + ? RECOVERY_BY_CODE.GRAPH_MAINTENANCE_PATH_UNSAFE + : error.category === "cancelled" + ? RECOVERY_BY_CODE.GRAPH_MAINTENANCE_CANCELLED + : graphWorkerRecovery(error); + } else if (error instanceof IndexInUseError) { + recovery = "The Wiki index is busy or changed during publication. Close other processes using it, then retry setup."; + } else if (error instanceof IndexPathError || error instanceof WikiSourceReadError) { + recovery = "A Wiki file or index path could not be read safely. Check .mex for unreadable, redirected, or conflicting paths, correct them, then retry setup."; + } else { + recovery = sqliteRecovery(error); + } + if (recovery !== undefined) { + return new SetupError(`${label} failed: ${error.message}`, { + cause: error, + userMessage: `${label} could not finish. ${recovery}`, + }); + } + return classifySetupFileSystemError(error, `${label} could not finish.`); +} + +function graphWorkerRecovery(error: GraphCandidateProcessError): string { + if (error.diagnostic?.resourceFailure === "heap-limit") { + return "The graph worker reached Node's JavaScript heap limit. Retry setup with a larger heap using NODE_OPTIONS=--max-old-space-size=, sized for your available memory."; + } + switch (error.diagnostic?.reason) { + case "entrypoint-missing": + return "The graph worker is missing from this MEX installation. Rebuild MEX if running from source, or reinstall it, then retry setup."; + case "spawn": + return "The graph worker could not start. Check the Node runtime and execution permissions, then retry setup."; + case "startup-timeout": + return "The graph worker did not start before its startup deadline. Retry setup; if this repeats, check the Node runtime and MEX installation."; + case "build-timeout": + return "The graph worker exceeded its build time limit. Review the last reported phase and worker diagnostics before retrying setup."; + case "protocol": + return "The graph worker returned an invalid response. Rebuild MEX if running from source, or reinstall it, then retry setup."; + case "ipc-disconnected": + return "Setup lost communication with the graph worker. Check the worker diagnostics, then retry setup."; + case "observer": + return "Setup could not process a graph worker update. Retry setup; if this repeats, report the terminal diagnostics."; + case "exit": { + const diagnostic = error.diagnostic; + const status = diagnostic.signal + ? "The graph worker was terminated by a signal." + : Number.isSafeInteger(diagnostic.exitCode) && diagnostic.exitCode !== null + ? `The graph worker exited with status ${diagnostic.exitCode}.` + : "The graph worker exited without completing the build."; + return `${status} Check the worker diagnostics, then retry setup.`; + } + default: + return "The graph worker could not complete the build. Check the worker diagnostics or run `mex graph rebuild`, then retry setup."; + } +} + +function sqliteRecovery(error: Error): string | undefined { + if (!("code" in error) || typeof error.code !== "string") return undefined; + const code = error.code; + // node:sqlite reports numeric SQLite result codes behind ERR_SQLITE_ERROR. + // The low byte identifies the primary result, including extended codes. + const primary = code === "ERR_SQLITE_ERROR" && "errcode" in error + && typeof error.errcode === "number" && Number.isSafeInteger(error.errcode) && error.errcode >= 0 + ? error.errcode & 0xff : undefined; + if (["SQLITE_BUSY", "SQLITE_LOCKED", "ERR_SQLITE_BUSY", "ERR_SQLITE_LOCKED"].includes(code) + || primary === 5 || primary === 6) { + return "The local database is busy. Wait for other MEX or database processes to finish, then retry setup."; + } + if (["SQLITE_FULL", "ERR_SQLITE_FULL"].includes(code) || primary === 13) { + return "The local database ran out of storage. Free disk space or increase the quota, then retry setup."; + } + if (["SQLITE_READONLY", "ERR_SQLITE_READONLY"].includes(code) || primary === 8) { + return "The local database is read-only. Check the checkout and database permissions, then retry setup."; + } + return undefined; +} + +/** Select registry-owned remediation, never a diagnostic's raw message or override. */ +export function setupWikiFailureMessage( + stage: SetupWikiFinalizationStage, + diagnostics: readonly WikiDiagnostic[], +): string { + const label = stage === "plan" ? "Wiki migration planning" + : stage === "migration" ? "Wiki migration" + : stage === "index" ? "Wiki indexing" : "Wiki validation"; + const diagnostic = diagnostics.find((entry) => entry.severity === "error" && Object.hasOwn(WIKI_DIAGNOSTICS, entry.code)); + if (!diagnostic) return `${label} could not finish. Review the .mex files and retry setup after correcting the problem.`; + const location = safeScaffoldPath(diagnostic.file ?? diagnostic.location?.file); + const subject = `${diagnostic.code}${location === null ? "" : ` in ${location}`}`; + const remediation = diagnostic.code === "WRITE_SCOPE_VIOLATION" + ? "Check the affected file and wiki.readOnly rules in .mex/config.json. Preserve protected files or intentionally update their configuration." + : diagnostic.code === "WIKI_INDEX_MISSING" + ? "The Wiki index is missing. Setup can rebuild it on retry." + : diagnostic.code === "WIKI_INDEX_REBUILD_REQUIRED" + ? "Close other processes using the Wiki index before retrying. If the problem persists, run `mex wiki rebuild-index` to inspect its diagnostics." + : WIKI_DIAGNOSTICS[diagnostic.code].remediation; + const retry = " Retry setup after correcting this problem."; + const detail = `${label} could not finish (${subject}). ${remediation}`; + const available = 512 - retry.length; + return `${detail.length > available ? `${detail.slice(0, available - 1)}…` : detail}${retry}`; +} + +function safeScaffoldPath(path: string | undefined): string | null { + if (path === undefined || path.length > 160 || /[\u0000-\u001f\u007f]/u.test(path)) return null; + const normalized = path.replaceAll("\\", "/"); + if (normalized.startsWith("/") || /^[a-z]:/iu.test(normalized) + || normalized.split("/").some((part) => part === "" || part === "." || part === "..")) return null; + return normalized.startsWith(".mex/") ? normalized : `.mex/${normalized}`; +} diff --git a/src/setup/phases.ts b/src/setup/phases.ts index 268ea103..660085b2 100644 --- a/src/setup/phases.ts +++ b/src/setup/phases.ts @@ -3,6 +3,9 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { loadConfiguredSetupMode, saveConfiguredSetupMode } from "../config.js"; import { ensureSetupIgnoreProtection, renderSetupIgnoreProtection, verifySetupIgnoreProtection } from "./ignore.js"; +import { SetupError, classifySetupFileSystemError } from "./errors.js"; +import { classifySetupMaintenanceError } from "./maintenance-errors.js"; +import type { GraphMaintenanceProgress } from "../team/contracts/graph.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -46,18 +49,25 @@ export function verifyExistingSetupConfig(mexDir: string): void { if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") return; - throw new Error("Could not inspect existing .mex/config.json. Fix its permissions before rerunning setup.", { - cause: error, - }); + throw classifySetupFileSystemError(error, "Could not inspect .mex/config.json.") ?? new SetupError( + "Could not inspect existing .mex/config.json. Fix its permissions before rerunning setup.", + { cause: error }, + ); } if (stats.isSymbolicLink() || !stats.isFile()) { - throw new Error("Existing .mex/config.json must be a regular file. Fix it before rerunning setup."); + throw new SetupError("Existing .mex/config.json must be a regular file. Fix it before rerunning setup."); + } + let contents: string; + try { + contents = readFileSync(path, "utf8"); + } catch (error) { + throw classifySetupFileSystemError(error, "Could not read .mex/config.json.") ?? error; } try { - const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + const parsed: unknown = JSON.parse(contents); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(); } catch { - throw new Error("Existing .mex/config.json is not a valid JSON object. Fix it before rerunning setup."); + throw new SetupError("Existing .mex/config.json is not a valid JSON object. Fix it before rerunning setup."); } } @@ -74,15 +84,18 @@ export function setupTemplatesDirectory(): string { for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } - throw new Error( + throw new SetupError( `Templates directory not found. Looked in:\n${candidates.map((path) => ` - ${path}`).join("\n")}\nThe mex-agent package may be corrupted — try reinstalling.`, + { userMessage: "MEX setup templates are missing. Reinstall mex-agent, restart the Hub, then retry setup." }, ); } export function normalizeSetupMode(raw: string | undefined): SetupMode { const mode = raw ?? "code-repo"; if (mode === "code-repo" || mode === "agent-memory") return mode; - throw new Error(`Unknown setup mode "${mode}". Use code-repo or agent-memory.`); + throw new SetupError(`Unknown setup mode "${mode}". Use code-repo or agent-memory.`, { + userMessage: "The setup mode is not supported. Choose code-repo or agent-memory, then retry setup.", + }); } /** Explicit choices override persisted intent; a resume uses the original mode. */ @@ -112,10 +125,21 @@ export function createSetupScaffold(options: { const memorySource = resolve(templatesDir, "agent-memory", file); const source = mode === "agent-memory" && existsSync(memorySource) ? memorySource : resolve(templatesDir, file); - const action = ensureScaffoldFile(source, resolve(mexDir, file), dryRun); + let action: ScaffoldFileAction; + try { + action = ensureScaffoldFile(source, resolve(mexDir, file), dryRun); + } catch (error) { + throw classifySetupFileSystemError(error, `Could not create .mex/${file}.`) ?? error; + } options.onFile?.(file, action); } - if (!dryRun) saveConfiguredSetupMode(mexDir, mode); + if (!dryRun) { + try { + saveConfiguredSetupMode(mexDir, mode); + } catch (error) { + throw classifySetupFileSystemError(error, "Could not save the setup mode in .mex/config.json.") ?? error; + } + } } /** Scanner failure is optional: both transports fall back to filesystem discovery. */ @@ -133,6 +157,7 @@ export async function scanSetupCodebase(projectRoot: string, mexDir: string): Pr export async function buildSetupGraph(projectRoot: string, options: { signal?: AbortSignal; background?: boolean; + onProgress?: (progress: GraphMaintenanceProgress) => void; } = {}): Promise { throwIfSetupAborted(options.signal); try { @@ -140,9 +165,12 @@ export async function buildSetupGraph(projectRoot: string, options: { await rebuildGraph(projectRoot, { ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.background ? { candidateExecution: "process" as const } : {}), + ...(options.onProgress ? { onProgress: options.onProgress } : {}), }); } catch (error) { throwIfSetupAborted(options.signal); + const expected = classifySetupMaintenanceError(error, "graph"); + if (expected) throw expected; const message = error instanceof Error ? error.message : String(error); throw new Error(`Code graph setup failed: ${message}. Fix the problem and rerun mex setup.`); } diff --git a/src/setup/population.ts b/src/setup/population.ts index 72d6536a..0813b9d3 100644 --- a/src/setup/population.ts +++ b/src/setup/population.ts @@ -96,7 +96,7 @@ export async function launchSetupPopulationAsync( } } -function createPopulationSession(prompt: string, projectRoot: string): { +export function createPopulationSession(prompt: string, projectRoot: string): { root: string; instruction: string; cleanup: () => void; diff --git a/src/setup/terminal-ui.ts b/src/setup/terminal-ui.ts new file mode 100644 index 00000000..335cb9fc --- /dev/null +++ b/src/setup/terminal-ui.ts @@ -0,0 +1,448 @@ +import React, { useEffect, useState } from "react"; +import { stdin } from "node:process"; +import { Box, Text, render, useInput, useStdout, type Instance } from "ink"; +import type { AiTool } from "../types.js"; +import type { SetupProgressStep } from "./headless.js"; + +const h = React.createElement; + +export interface TerminalSetupView { + projectName: string; + mode: string; + screen: "configure" | "progress" | "population" | "error" | "hub"; + tools: readonly { id: AiTool; name: string; selected: boolean; cliAvailable: boolean }[]; + initialTool?: AiTool | "continue"; + activeStep?: SetupProgressStep; + completedSteps: readonly SetupProgressStep[]; + detail: string; + startedAt: number; + phaseStartedAt?: number; + lastActivityAt?: number; + progress?: { completed?: number; total?: number; unit?: string }; + error?: string; + hubUrl?: string; + populationAgent?: string | null; + stopping?: boolean; +} + +export type TerminalSetupAction = + | { type: "toggle"; tool: AiTool } + | { type: "start"; tool?: AiTool } + | { type: "cancel" | "agent" | "prompt" | "check" | "exit" | "retry" }; + +type ActionHandler = (action: TerminalSetupAction) => void; +type Line = { text: string; color?: string; backgroundColor?: string; dimColor?: boolean; bold?: boolean }; +type Choice = { label: string; action: TerminalSetupAction }; + +const BANNER = [ + " __ __ _____ __ __", + "| \\/ | ____|\\ \\/ /", + "| |\\/| | _| > <", + "|_| |_|_____|/_/\\_\\", +]; +const COLOR = { + ink: "#ECE5D5", + accent: "#F0B67F", + cyan: "#74D2D0", + border: "#496674", + muted: "#95A6AC", + complete: "#8CBAA3", + selected: "#203440", +}; +const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const PHASES: readonly [SetupProgressStep, string][] = [ + ["detect", "Project"], + ["scaffold", "Scaffold"], + ["tools", "AI tools"], + ["skills", "Agent skills"], + ["identity", "Identity"], + ["scan", "Codebase scan"], + ["graph", "Code graph"], + ["population", "Project knowledge"], + ["finalize", "Grounding + Wiki"], +]; + +// Text supplied by a subprocess or a project must not control the terminal. +function plain(value: string): string { + return value + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "") + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function wrapped(value: string, width: number, limit: number): Line[] { + if (limit < 1) return []; + // Bound work as well as output when an agent supplies a very large detail. + let remaining = plain(value.slice(0, 8_192)); + const result: Line[] = []; + const span = Math.max(1, width - 1); + while (remaining && result.length < limit) { + if (remaining.length <= span) { + result.push({ text: remaining }); + remaining = ""; + } else if (result.length === limit - 1) { + result.push({ text: remaining.slice(0, Math.max(1, span - 3)) + "..." }); + remaining = ""; + } else { + const space = remaining.lastIndexOf(" ", span); + const end = space > span / 2 ? space : span; + result.push({ text: remaining.slice(0, end) }); + remaining = remaining.slice(end).trimStart(); + } + } + return result; +} + +function elapsed(startedAt: number, now: number): string { + const seconds = Math.max(0, Math.floor((now - startedAt) / 1_000)) || 0; + return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`; +} + +function reportedCount(progress: TerminalSetupView["progress"]): string | undefined { + if (!progress || !Number.isFinite(progress.completed) || progress.completed! < 0) return undefined; + const completed = Math.floor(progress.completed!); + const total = Number.isFinite(progress.total) && progress.total! >= completed ? Math.floor(progress.total!) : undefined; + return `${completed.toLocaleString("en-US")}${total === undefined ? "" : ` / ${total.toLocaleString("en-US")}`}${progress.unit ? ` ${plain(progress.unit)}` : ""}`; +} + +function divider(width: number, label: string, start = "├", end = "┤"): string { + const text = plain(label).slice(0, Math.max(0, width - 6)); + return start + "─ " + text + " " + "─".repeat(Math.max(0, width - text.length - 5)) + end; +} + +function choices(view: TerminalSetupView): Choice[] { + if (view.screen === "population") { + return [ + ...(view.populationAgent ? [{ label: `Open ${plain(view.populationAgent)}`, action: { type: "agent" as const } }] : []), + { label: "Show setup prompt", action: { type: "prompt" } }, + { label: "Check populated files", action: { type: "check" } }, + { label: "Finish later", action: { type: "exit" } }, + ]; + } + if (view.screen === "error") { + return [ + { label: "Retry setup", action: { type: "retry" } }, + { label: "Exit setup", action: { type: "exit" } }, + ]; + } + return []; +} + +function title(view: TerminalSetupView): string { + if (view.stopping) return "Stopping safely"; + switch (view.screen) { + case "configure": return "Choose your AI tools"; + case "population": return "Add project knowledge"; + case "error": return "Setup needs attention"; + case "hub": return "Continue in your browser"; + case "progress": return "Preparing your project"; + } +} + +function footer(view: TerminalSetupView, narrow: boolean): string { + if (view.stopping) return "Waiting for the current operation to stop..."; + switch (view.screen) { + case "configure": return narrow ? "Enter start Space multi ↑↓ ^C" : "Enter select/start Space multi-select Up/Down move Ctrl+C exit"; + case "population": return narrow ? "Arrows / Enter / d details / ^C" : "Up/Down move Enter choose d details Ctrl+C stop"; + case "error": return narrow ? "↑↓ Enter d diagnostics ^C" : "Up/Down move Enter choose d diagnostics Ctrl+C stop"; + case "hub": return "d details q / Ctrl+C stop Hub"; + case "progress": return "d details Ctrl+C stop setup"; + } +} + +function initialFocus(view: TerminalSetupView): number { + if (view.screen !== "configure") return 0; + if (view.initialTool === "continue") return view.tools.length; + const index = view.tools.findIndex(tool => tool.id === view.initialTool); + return Math.max(0, index); +} + +function visibleChoices(groups: Line[][], focus: number, height: number): Line[] { + const focusEnd = groups.slice(0, focus + 1).reduce((total, group) => total + group.length, 0); + const start = Math.max(0, focusEnd - height); + return groups.flat().slice(start, start + height); +} + +function phaseLines(view: TerminalSetupView): Line[] { + return PHASES.filter(([step]) => { + if (view.mode === "agent-memory" && ["scan", "graph", "finalize"].includes(step)) return false; + return step !== "skills" || view.tools.some(tool => tool.selected && (tool.id === "claude" || tool.id === "codex")); + }).map(([step, label], index) => { + const line = phaseLine(view, step, label); + return { ...line, text: `${String(index + 1).padStart(2, "0")} ${line.text}` }; + }); +} + +function phaseLine(view: TerminalSetupView, step: SetupProgressStep, label: string): Line { + if (view.completedSteps.includes(step)) return { text: `[done] ${label}`, color: COLOR.complete }; + if (view.screen === "hub") return { text: `[skip] ${label}`, color: COLOR.muted }; + if (view.activeStep === step) { + const status = view.stopping ? "stop" : view.screen === "error" ? "fail" : view.screen === "population" ? "wait" : "now "; + return { text: `[${status}] ${label}`, color: COLOR.accent, bold: true }; + } + return { text: `[wait] ${label}`, color: COLOR.muted }; +} + +/** The renderer owns input only while mounted; native agents get the entire terminal. */ +export function TerminalSetupHUD({ view, onAction }: { view: TerminalSetupView; onAction: ActionHandler }): React.ReactElement { + const { stdout } = useStdout(); + const [dimensions, setDimensions] = useState(() => ({ columns: stdout.columns || 80, rows: stdout.rows || 24 })); + const [focus, setFocus] = useState(() => initialFocus(view)); + const [expanded, setExpanded] = useState(false); + const [detailOffset, setDetailOffset] = useState(0); + const [now, setNow] = useState(Date.now); + const working = view.screen === "progress" && !view.stopping; + const menu = choices(view); + const count = view.screen === "configure" ? view.tools.length + 1 : menu.length; + const selected = Math.min(focus, Math.max(0, count - 1)); + + useEffect(() => { + const resize = () => setDimensions({ columns: stdout.columns || 80, rows: stdout.rows || 24 }); + stdout.on("resize", resize); + return () => { stdout.off("resize", resize); }; + }, [stdout]); + useEffect(() => { setFocus(initialFocus(view)); setExpanded(false); setDetailOffset(0); }, [view.screen, view.initialTool]); + useEffect(() => { setDetailOffset(0); }, [view.detail, view.error, view.hubUrl]); + useEffect(() => { + setNow(Date.now()); + if (!working) return; + const timer = setInterval(() => { setNow(Date.now()); }, 120); + return () => { clearInterval(timer); }; + }, [working]); + + useInput((input, key) => { + if (view.stopping) return; + if ((key.ctrl && (input === "c" || input === "d")) || (view.screen === "hub" && input.toLowerCase() === "q")) { + onAction({ type: "cancel" }); + return; + } + if (input === "d" && view.screen !== "configure") { setExpanded(!expanded); return; } + if (expanded) { + if (key.escape) setExpanded(false); + else if (key.upArrow) setDetailOffset(Math.max(0, visibleOffset - 1)); + else if (key.downArrow) setDetailOffset(Math.min(maxOffset, visibleOffset + 1)); + else if (key.pageUp) setDetailOffset(Math.max(0, visibleOffset - Math.max(1, bodyHeight - 1))); + else if (key.pageDown) setDetailOffset(Math.min(maxOffset, visibleOffset + Math.max(1, bodyHeight - 1))); + return; + } + if (key.upArrow && count) setFocus((selected + count - 1) % count); + else if (key.downArrow && count) setFocus((selected + 1) % count); + else if (view.screen === "configure" && input === " " && view.tools[selected]) { + onAction({ type: "toggle", tool: view.tools[selected].id }); + } else if (key.return) { + if (view.screen === "configure") onAction(view.tools[selected] ? { type: "start", tool: view.tools[selected].id } : { type: "start" }); + else if (menu[selected]) onAction(menu[selected].action); + } + }); + + const height = Math.max(1, Math.min(23, dimensions.rows - 1)); + const padding = dimensions.columns >= 30 ? 1 : 0; + const width = Math.max(1, dimensions.columns - padding * 2); + const framed = width >= 24 && height >= 7; + const fullBanner = view.screen === "configure" && width >= 60 && height >= 21; + const header: Line[] = fullBanner + ? BANNER.map((text, index) => ({ text: text + (index === 1 ? " PROJECT MEMORY / SETUP" : index === 3 ? " A place for project knowledge." : ""), color: COLOR.cyan, bold: true })) + : []; + if (height >= 11) header.push({ text: `${plain(view.projectName)} / ${plain(view.mode)}${width >= 60 ? ` / TOTAL ${elapsed(view.startedAt, now)}` : ""}`, color: COLOR.muted }); + const bodyHeight = Math.max(0, height - header.length - (framed ? 4 : 2)); + const split = !expanded && width >= 74 && bodyHeight >= 9; + const railWidth = view.screen === "configure" ? 46 : 31; + const insideWidth = framed ? width - 4 : width; + const contentWidth = split ? width - railWidth - 5 : insideWidth; + const fullDetail = [view.error, view.detail, view.hubUrl].filter((value, index, values) => value && values.indexOf(value) === index).join("\n\n"); + const boundedDetail = fullDetail.length > 4_096 + ? fullDetail.slice(0, 3_040) + "\n[Middle of detail omitted]\n" + fullDetail.slice(-1_024) + : fullDetail; + const detailLines = wrapped(boundedDetail, insideWidth, 4_096); + const maxOffset = Math.max(0, detailLines.length - bodyHeight); + const visibleOffset = Math.min(detailOffset, maxOffset); + let content: Line[] = []; + let rail: Line[] = []; + + if (expanded) { + content = detailLines.slice(visibleOffset, visibleOffset + bodyHeight); + } else if (view.screen === "configure") { + const selectedCount = view.tools.filter(tool => tool.selected).length; + const continueFocused = selected === view.tools.length; + const continueLabel = selectedCount ? "Continue with selected tools" : "Continue without an agent"; + const continueLines = wrapped(continueLabel, (split ? railWidth - 2 : insideWidth) - 2, 3).map((item, index) => ({ + text: `${continueFocused && index === 0 ? ">" : " "} ${item.text}`, + color: continueFocused ? COLOR.accent : COLOR.cyan, + backgroundColor: continueFocused ? COLOR.selected : undefined, + bold: continueFocused, + })); + const toolLines = view.tools.map((tool, index) => ({ + text: `${index === selected ? ">" : " "} [${tool.selected ? "x" : " "}] ${plain(tool.name)} ${tool.id === "claude" || tool.id === "codex" ? tool.cliAvailable ? "CLI ready" : "CLI not found" : "integration only"}`, + color: index === selected ? COLOR.accent : COLOR.ink, + backgroundColor: index === selected ? COLOR.selected : undefined, + bold: index === selected, + })); + if (split) { + rail = visibleChoices([...toolLines.map(item => [item, { text: "" }]), continueLines], selected, bodyHeight); + const tool = view.tools[selected]; + const native = tool?.id === "claude" || tool?.id === "codex"; + content = [ + { text: continueFocused ? "CONTINUE" : native ? "NATIVE SETUP" : "INTEGRATION", color: COLOR.cyan, bold: true }, + { text: tool?.name ?? (selectedCount ? "Your selected tools" : "Manual setup"), color: COLOR.accent, bold: true }, + { text: "" }, + ...wrapped(continueFocused + ? selectedCount + ? "Start with only the checked tools. A selected Claude or Codex CLI can populate project knowledge." + : "Start without selecting a tool. MEX will show a prompt to complete project knowledge manually." + : native + ? tool.cliAvailable + ? `${tool.selected ? "Enter starts setup with this tool selected." : "Enter selects this tool and starts setup."} Its CLI is available.` + : "Enter adds its instructions and starts setup. CLI not found; population may need the manual prompt." + : "Enter adds this integration and starts setup. Populate project knowledge with a selected Claude or Codex CLI, or the manual prompt.", contentWidth, bodyHeight - 5), + { text: "" }, + { text: `${selectedCount} selected`, color: COLOR.cyan }, + ]; + } else { + // A compact Continue choice can occupy two lines; keep its full label + // visible while moving through the same logical rows as a wider terminal. + content = visibleChoices([...toolLines.map(item => [item]), continueLines], selected, bodyHeight); + if (content.length < bodyHeight) content.push({ text: "" }, ...wrapped(view.detail || "Enter selects a tool and starts setup. Space optionally selects multiple tools.", contentWidth, bodyHeight - content.length - 1)); + } + } else { + if (split) rail = phaseLines(view); + const menuBudget = Math.min(menu.length, Math.max(0, bodyHeight - 1)); + const detailBudget = Math.max(0, Math.min(6, bodyHeight - menuBudget - (menu.length ? 1 : 0))); + if (working) { + const activeLabel = PHASES.find(([step]) => step === view.activeStep)?.[1] ?? "Preparing setup"; + const phaseTime = elapsed(view.phaseStartedAt ?? view.startedAt, now); + const quietTime = view.lastActivityAt === undefined ? undefined : elapsed(view.lastActivityAt, now); + const counter = reportedCount(view.progress); + content.push( + { text: `${SPINNER[Math.floor(now / 120) % SPINNER.length]} ${activeLabel}`, color: COLOR.accent, bold: true }, + { text: `Phase ${phaseTime}${!split && quietTime ? ` / Quiet ${quietTime}` : ""}`, color: COLOR.ink }, + ); + if (counter && content.length < bodyHeight - 1) content.push({ text: counter, color: COLOR.cyan, bold: true }); + if (split) content.push({ text: "" }, { text: "LATEST EVENT", color: COLOR.cyan, bold: true }); + const remaining = Math.max(0, bodyHeight - content.length - (split ? 4 : 0)); + content.push(...wrapped(view.detail, contentWidth, Math.min(split ? 6 : 3, remaining))); + if (split) content.push( + { text: "" }, + { text: quietTime ? `No new events for ${quietTime}` : "No activity update yet", color: COLOR.muted }, + { text: "Waiting for the next reported event.", color: COLOR.muted }, + ); + } else if (!split && view.activeStep && bodyHeight >= menuBudget + 3) { + const active = PHASES.find(([step]) => step === view.activeStep); + if (active) content.push(phaseLine(view, ...active)); + } + const detail = view.screen === "error" ? view.error || view.detail : view.detail; + if (!working) content.push(...wrapped(detail, contentWidth, Math.max(0, detailBudget - content.length)).map(line => ({ ...line, color: view.screen === "error" ? COLOR.accent : COLOR.ink }))); + if (view.screen === "error" && view.detail && view.detail !== detail && bodyHeight - menuBudget - content.length >= 4) { + content.push({ text: "" }, { text: "DIAGNOSTICS [d] expand", color: COLOR.cyan, bold: true }); + content.push(...wrapped(view.detail, contentWidth, Math.min(5, bodyHeight - menuBudget - content.length - 1)).map(item => ({ ...item, color: COLOR.muted }))); + } + if (view.screen === "hub" && view.hubUrl && content.length < bodyHeight) content.push(...wrapped(view.hubUrl, contentWidth, bodyHeight - content.length).map(item => ({ ...item, color: COLOR.cyan }))); + if (menu.length) { + if (content.length < bodyHeight - menuBudget) content.push({ text: "" }); + const start = Math.max(0, selected - menuBudget + 1); + content.push(...menu.slice(start, start + menuBudget).map((choice, offset) => ({ + text: `${start + offset === selected ? ">" : " "} ${choice.label}`, + color: start + offset === selected ? COLOR.accent : COLOR.ink, + backgroundColor: start + offset === selected ? COLOR.selected : undefined, + bold: start + offset === selected, + }))); + } + } + + const line = (item: Line, key: string) => h(Text, { key, color: item.color ?? COLOR.ink, backgroundColor: item.backgroundColor, bold: item.bold, dimColor: item.dimColor, wrap: "truncate-end" }, item.text || " "); + const edge = (text: string, key: string) => line({ text, color: COLOR.border }, key); + const framedRow = (item: Line, key: string) => h(Box, { key, height: 1, flexShrink: 0 }, + ...(framed ? [edge("│ ", `${key}-left`)] : []), + h(Box, { width: insideWidth, flexShrink: 0 }, line(item, `${key}-text`)), + ...(framed ? [edge(" │", `${key}-right`)] : []), + ); + const body = Array.from({ length: bodyHeight }, (_, index) => split + ? h(Box, { key: `row-${index}`, height: 1, flexShrink: 0 }, + edge("│ ", `left-${index}`), + h(Box, { width: railWidth - 2, flexShrink: 0 }, line(rail[index] || { text: "" }, `phase-${index}`)), + edge(" │ ", `middle-${index}`), + h(Box, { width: contentWidth, flexShrink: 0 }, line(content[index] || { text: "" }, `content-${index}`)), + edge(" │", `right-${index}`), + ) + : framedRow(content[index] || { text: "" }, `row-${index}`)); + const sectionTitle = expanded ? `Details ${visibleOffset + 1}-${Math.min(visibleOffset + bodyHeight, detailLines.length)} / ${detailLines.length}` : title(view); + const section = split + ? divider(railWidth + 2, view.screen === "configure" ? "Choose your AI tools" : "WORKFLOW", "├", "┬") + divider(width - railWidth - 1, view.screen === "configure" ? "FOCUS" : sectionTitle, "", "┤") + : divider(width, sectionTitle); + + return h(Box, { flexDirection: "column", width: dimensions.columns, height, paddingX: padding, overflow: "hidden" }, + ...(height >= 2 ? [line({ text: framed ? divider(width, "MEX / SETUP", "╭", "╮") : "MEX / SETUP", color: COLOR.cyan, bold: true }, "brand")] : []), + ...header.map((item, index) => framedRow(item, `header-${index}`)), + ...(framed ? [line({ text: section, color: COLOR.border, bold: true }, "section")] : []), + ...body, + ...(framed ? [edge(split ? "╰" + "─".repeat(railWidth) + "┴" + "─".repeat(width - railWidth - 3) + "╯" : "╰" + "─".repeat(width - 2) + "╯", "bottom")] : []), + line({ text: expanded ? (width < 55 ? "Arrows scroll d/Esc back ^C" : "Up/Down / PgUp/PgDn scroll d / Esc back Ctrl+C stop") : footer(view, width < 55), color: COLOR.muted }, "footer"), + ); +} + +export function createTerminalSetupUI(initial: TerminalSetupView, onAction: ActionHandler): { + update(view: TerminalSetupView): void; + suspend(): Promise; + resume(view: TerminalSetupView): void; + close(): Promise; +} { + let view = initial; + let instance: Instance | undefined; + let pending: Promise | undefined; + let suspended = false; + let closed = false; + const mount = () => { + if (closed || suspended || instance) return; + // Ink unrefs stdin on unmount. Keep the process alive until the fresh + // renderer's input effect takes ownership again. + if (stdin.isTTY) stdin.ref(); + instance = render(h(TerminalSetupHUD, { view, onAction }), { + alternateScreen: true, + patchConsole: false, + exitOnCtrlC: false, + }); + }; + const unmount = (): Promise => { + if (pending) return pending; + const current = instance; + instance = undefined; + if (!current) return Promise.resolve(); + const teardown = (async () => { + try { + current.unmount(); + await current.waitUntilExit(); + } finally { + current.cleanup(); + } + })(); + pending = teardown.finally(() => { pending = undefined; }); + return pending; + }; + mount(); + return { + update(next) { + view = next; + instance?.rerender(h(TerminalSetupHUD, { view, onAction })); + }, + async suspend() { + suspended = true; + await unmount(); + // Removing Ink's listener alone leaves Node reading ahead on the shared + // descriptor, which can consume keystrokes intended for the native child. + stdin.pause(); + }, + resume(next) { + view = next; + suspended = false; + if (closed) return; + if (instance) instance.rerender(h(TerminalSetupHUD, { view, onAction })); + else if (pending) void pending.then(mount, () => {}); + else mount(); + }, + close() { + closed = true; + return unmount(); + }, + }; +} diff --git a/src/setup/terminal.ts b/src/setup/terminal.ts new file mode 100644 index 00000000..e33a0ec1 --- /dev/null +++ b/src/setup/terminal.ts @@ -0,0 +1,300 @@ +import { basename, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { AI_TOOLS, type AiTool } from "../types.js"; +import { loadConfiguredAiTools } from "../config.js"; +import { isCliAvailable } from "../cli-tools.js"; +import { + ensureToolAnchors, findSetupProjectRoot, installSetupAgentAssets, isScaffoldPopulated, + type SetupRunOptions, type SetupRunResult, +} from "./index.js"; +import { createSetupScaffold, resolveSetupMode, setupTemplatesDirectory, throwIfSetupAborted } from "./phases.js"; +import { runHeadlessSetup, type HeadlessSetupResult } from "./headless.js"; +import { SetupError } from "./errors.js"; +import { classifySetupAgentAssetsError } from "./agent-errors.js"; +import { selectSetupAgent } from "./population.js"; +import { formatSetupGraphActivity, setupGraphActivity } from "./graph-progress.js"; +import type { TerminalSetupAction, TerminalSetupView } from "./terminal-ui.js"; + +const CONTINUE = "Run `mex setup --cli` to continue. Existing authored files are preserved."; + +/** The terminal owns presentation and interaction; all writes use the shared setup engine. */ +export async function runTerminalSetup(options: SetupRunOptions): Promise { + const projectRoot = findSetupProjectRoot(); + const mexDir = resolve(projectRoot, ".mex"); + const mode = resolveSetupMode(mexDir, options.mode); + const selectedTools = [...new Set(options.tools ?? loadConfiguredAiTools(mexDir))]; + if (options.dryRun) return previewSetup(projectRoot, mode, selectedTools, options.tools !== undefined); + const interactive = Boolean(stdin.isTTY && stdout.isTTY && !options.yes && !process.env.CI && process.env.TERM !== "dumb"); + const abort = new AbortController(); + let cancelCode = 130; + let cancellationDetail = ""; + let nativeSession = false; + let notifyCancel = () => {}; + const cancel = (code = 130) => { + cancelCode = code; + abort.abort(); + notifyCancel(); + }; + // In a native session Ctrl+C belongs to the agent. SIGTERM still stops the owned run. + const interrupt = () => { if (!nativeSession) cancel(); }; + const terminate = () => cancel(143); + const endInput = () => { if (!nativeSession) cancel(); }; + process.on("SIGINT", interrupt); + process.on("SIGTERM", terminate); + if (interactive) stdin.on("end", endInput); + try { + if (!interactive) { + console.log(`MEX setup · ${basename(projectRoot)} · ${mode}`); + let lastGraphPhase = ""; + let lastGraphLogAt = 0; + const result = await runHeadlessSetup({ + projectRoot, mode, tools: selectedTools, confirmPopulation: true, signal: abort.signal, + onProgress: update => console.log(`${update.label}${update.detail ? ` — ${update.detail}` : ""}`), + onMessage: message => console.log(message), + onGraphProgress: progress => { + const now = Date.now(); + if (progress.phase !== lastGraphPhase || now - lastGraphLogAt >= 1000 + || (progress.total !== undefined && progress.completed === progress.total)) { + console.log(formatSetupGraphActivity(progress)); + lastGraphPhase = progress.phase; + lastGraphLogAt = now; + } + }, + }); + for (const note of result.anchorNotes) console.log(`Action needed: ${note}`); + if (!result.ready) { + console.log(`\nSetup paused: scaffold population is still required.\n${CONTINUE}`); + if (result.prompt) console.log(`\nPopulation prompt:\n\n${result.prompt}`); + return { outcome: "paused", exitCode: 2, message: "Scaffold population is still required." }; + } + console.log(result.message); + console.log("Run `mex setup` to open the setup completion page."); + return { outcome: "complete", exitCode: 0, message: result.message }; + } + + const { createTerminalSetupUI } = await import("./terminal-ui.js"); + let view: TerminalSetupView = { + projectName: basename(projectRoot), mode, screen: "configure", startedAt: Date.now(), + completedSteps: [], detail: "Highlight a tool and press Enter to select it and begin. Space optionally selects additional tools.", + initialTool: selectedTools[0] ?? (options.tools?.length === 0 ? "continue" : undefined), + tools: (Object.keys(AI_TOOLS) as AiTool[]).map(id => ({ + id, name: AI_TOOLS[id].name, selected: selectedTools.includes(id), + cliAvailable: AI_TOOLS[id].cli !== null && isCliAvailable(AI_TOOLS[id].cli!), + })), + }; + let pending: ((action: TerminalSetupAction) => void) | undefined; + const update = (changes: Partial) => { + view = { ...view, ...changes }; + ui.update(view); + }; + const ui = createTerminalSetupUI(view, action => { + if (action.type === "cancel") { cancel(); return; } + if (action.type === "toggle" && view.screen === "configure") { + update({ tools: view.tools.map(tool => tool.id === action.tool ? { ...tool, selected: !tool.selected } : tool) }); + return; + } + pending?.(action); + }); + const choose = (): Promise => { + if (abort.signal.aborted) return Promise.resolve({ type: "cancel" }); + return new Promise(resolve => { + pending = action => { pending = undefined; resolve(action); }; + }); + }; + notifyCancel = () => { + update({ stopping: true, detail: "Stopping setup safely. Waiting for the active operation to settle…" }); + pending?.({ type: "cancel" }); + }; + let outcome: SetupRunResult | undefined; + let validated: HeadlessSetupResult | undefined; + let anchorNotes: readonly string[] = []; + let populationFailure: string | undefined; + try { + const action = await choose(); + if (action.type !== "start") throwIfSetupAborted(abort.signal); + const focusedTool = action.type === "start" ? action.tool : undefined; + if (focusedTool) { + update({ tools: view.tools.map(tool => tool.id === focusedTool ? { ...tool, selected: true } : tool) }); + } + const chosen = new Set(view.tools.filter(tool => tool.selected).map(tool => tool.id)); + // Enter explicitly chooses the preferred tool. Other selected integrations + // retain their saved order; the Continue row uses that order unchanged. + const tools = [...new Set([ + ...(focusedTool ? [focusedTool] : []), + ...selectedTools.filter(tool => chosen.has(tool)), + ...chosen, + ])]; + while (!outcome) { + try { + if (!validated) { + update({ screen: "progress", activeStep: undefined, completedSteps: [], error: undefined, stopping: false, + phaseStartedAt: Date.now(), lastActivityAt: Date.now(), progress: undefined }); + const result = await runHeadlessSetup({ + projectRoot, mode, tools, signal: abort.signal, + onProgress: progress => update({ + activeStep: progress.step, + completedSteps: view.activeStep ? [...new Set([...view.completedSteps, view.activeStep])] : view.completedSteps, + detail: progress.detail ?? progress.label, + phaseStartedAt: Date.now(), + lastActivityAt: Date.now(), + progress: undefined, + }), + onMessage: detail => update({ detail, lastActivityAt: Date.now() }), + onGraphProgress: progress => { + const activity = setupGraphActivity(progress); + update({ detail: activity.detail, progress: activity.progress, lastActivityAt: Date.now() }); + }, + onAnchorNotes: notes => { anchorNotes = notes; }, + populate: async input => { + let tool = selectSetupAgent(input.selectedTools); + let first = true; + let detail = tool + ? `Opening ${AI_TOOLS[tool].name} with your setup instructions. Exit the agent session to return to MEX.` + : "No selected Claude Code or Codex CLI is available. Use the manual prompt, then check again."; + while (!abort.signal.aborted) { + tool = selectSetupAgent(input.selectedTools); + update({ screen: "population", detail, populationAgent: tool ? AI_TOOLS[tool].name : null }); + const action = first && tool ? { type: "agent" as const } : await choose(); + first = false; + if (action.type === "cancel") throwIfSetupAborted(abort.signal); + if (action.type === "exit") return { tool, completed: false }; + if (action.type === "agent" && tool) { + const { launchInteractiveSetupPopulation } = await import("./interactive-population.js"); + await ui.suspend(); + nativeSession = true; + let launched; + try { + console.log(`\nOpening ${AI_TOOLS[tool].name} with your setup instructions. Exit the agent session to return to MEX.\n`); + launched = await launchInteractiveSetupPopulation(input); + } + finally { nativeSession = false; ui.resume(view); } + if (abort.signal.aborted) cancellationDetail = launched.message ?? ""; + throwIfSetupAborted(abort.signal); + populationFailure = launched.status === "failed" ? launched.message ?? "The agent failed." : undefined; + if (launched.status === "exited" && isScaffoldPopulated(mexDir)) { + update({ screen: "progress" }); + return { tool: launched.tool, completed: true }; + } + detail = launched.status === "exited" + ? "The agent exited, but required scaffold placeholders remain. Reopen the session or finish manually." + : launched.message ?? `The agent ${launched.status === "cancelled" ? "was interrupted" : "failed"}. Reopen it or use the manual prompt.`; + } else if (action.type === "prompt") { + await ui.suspend(); + // Ink releases its stdin reference on unmount. Readline alone + // does not restore it, so keep the process alive for this question. + stdin.ref(); + let rl: ReturnType | undefined; + const closeInput = () => cancel(); + try { + console.log(`\nCopy this prompt into your AI tool:\n\n${input.prompt}\n`); + rl = createInterface({ input: stdin, output: stdout }); + rl.once("SIGINT", closeInput); + rl.once("close", closeInput); + await rl.question("Press Enter to return to MEX…", { signal: abort.signal }); + } + finally { + rl?.off("SIGINT", closeInput); + rl?.off("close", closeInput); + rl?.close(); + ui.resume(view); + } + } else if (action.type === "check") { + if (isScaffoldPopulated(mexDir)) { + update({ screen: "progress" }); + return { tool, completed: true }; + } + detail = "Required scaffold placeholders remain. Finish population, then choose Check again."; + } + } + throwIfSetupAborted(abort.signal); + return { tool, completed: false }; + }, + }); + if (!result.ready) { + outcome = { outcome: populationFailure ? "failed" : "paused", exitCode: populationFailure ? 1 : 2, + message: `${populationFailure ? `Setup failed: ${populationFailure}` : "Setup paused at population."} ${CONTINUE}` }; + continue; + } + validated = result; + } + throwIfSetupAborted(abort.signal); + update({ screen: "progress", completedSteps: view.activeStep ? [...new Set([...view.completedSteps, view.activeStep])] : view.completedSteps, + activeStep: undefined, phaseStartedAt: Date.now(), lastActivityAt: Date.now(), progress: undefined, + detail: "Opening the setup completion page…", error: undefined }); + const { runSetupHubCommand } = await import("../hub/command.js"); + let listening = false; + await runSetupHubCommand({ + projectRoot, initialMode: mode, port: options.port, openBrowser: options.openBrowser !== false, + signal: abort.signal, + onListening: ({ bootstrapUrl }) => { listening = true; update({ screen: "hub", hubUrl: bootstrapUrl, + detail: mode === "code-repo" + ? "Continue in your browser to review the setup commit, finish optional details, and open Hub." + : "Agent memory is ready. Finish the optional details in your browser." }); }, + onMessage: detail => update({ detail }), + }); + if (!listening) throwIfSetupAborted(abort.signal); + outcome = { outcome: "complete", exitCode: 0, message: `${validated.message} Hub stopped. Run \`mex setup\` to reopen the completion page.` }; + } catch (error) { + if (abort.signal.aborted) throw error; + const detail = error instanceof SetupError ? error.userMessage : error instanceof Error ? error.message : String(error); + const rawDiagnostic = error instanceof Error && error.message !== detail ? error.message : undefined; + const diagnostic = rawDiagnostic ? `\nDiagnostics: ${rawDiagnostic}` : ""; + const recovery = `${validated ? "Setup is ready; the Hub could not start." : "Setup could not finish."} Resolve the problem and retry.`; + update({ screen: "error", error: detail, detail: rawDiagnostic ? `${rawDiagnostic}\n${recovery}` : recovery, stopping: false }); + const action = await choose(); + throwIfSetupAborted(abort.signal); + if (action.type !== "retry") outcome = { outcome: "failed", exitCode: 1, message: `${detail}${diagnostic}\n${CONTINUE}` }; + } + } + } finally { + notifyCancel = () => {}; + await ui.close(); + for (const note of anchorNotes) console.log(`Action needed: ${note}`); + } + console.log(outcome.message); + return outcome; + } catch (error) { + if (abort.signal.aborted) { + const message = `Setup cancelled. ${cancellationDetail ? `${cancellationDetail} ` : ""}${CONTINUE}`; + console.log(message); + return { outcome: "cancelled", exitCode: cancelCode, message }; + } + throw error; + } finally { + process.removeListener("SIGINT", interrupt); + process.removeListener("SIGTERM", terminate); + stdin.removeListener("end", endInput); + } +} + +function previewSetup(projectRoot: string, mode: "code-repo" | "agent-memory", tools: AiTool[], explicit: boolean): SetupRunResult { + console.log(`MEX setup · DRY RUN · ${mode === "agent-memory" ? "agent-memory workspace" : basename(projectRoot)}`); + console.log("No files will be created or modified."); + if (mode === "code-repo" && !existsSync(resolve(projectRoot, ".git"))) { + throw new SetupError("No Git repository found. Run `git init` first, then rerun setup."); + } + console.log(`Using ${explicit ? "requested" : "configured"} AI tools: ${tools.map(tool => AI_TOOLS[tool].name).join(", ") || "none"}`); + createSetupScaffold({ projectRoot, mode, dryRun: true, + onIgnore: message => console.log(message), + onFile: (file, action) => console.log(`${action === "skip" ? "Keep" : "Would create"} .mex/${file}`), + }); + ensureToolAnchors(projectRoot, setupTemplatesDirectory(), tools, true); + try { + const assets = installSetupAgentAssets({ projectRoot, selectedTools: tools, dryRun: true, checkIgnored: mode === "code-repo" }); + for (const action of assets?.actions ?? []) console.log(action.message); + for (const warning of assets?.warnings ?? []) console.log(warning.message); + if (assets?.conflicted) throw new SetupError("Official MEX agent assets have conflicts. Review `mex skills sync --dry-run` before applying setup."); + } catch (error) { throw classifySetupAgentAssetsError(error) ?? error; } + console.log("Would verify or assign the project identity."); + if (mode === "code-repo") console.log("Would build the code graph before population."); + console.log(isScaffoldPopulated(resolve(projectRoot, ".mex")) + ? "Would preserve and verify the populated scaffold." + : "Would populate the scaffold with a selected agent or the manual prompt."); + if (mode === "code-repo") console.log("Would capture grounding, migrate and index Wiki, then validate the result."); + console.log("Interactive setup would then open the Hub completion page."); + const message = "Dry run complete. Population and validation were not run."; + console.log(message); + return { outcome: "dry-run", exitCode: 0, message }; +} diff --git a/src/setup/wiki-finalize.ts b/src/setup/wiki-finalize.ts index a7d0d445..c9e66140 100644 --- a/src/setup/wiki-finalize.ts +++ b/src/setup/wiki-finalize.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import { throwIfSetupAborted } from "./phases.js"; import { createWikiEngine, type MigrationApplyResult, @@ -20,6 +21,7 @@ export interface SetupWikiFinalizationOptions { readOnly?: readonly string[]; onProgress?: (message: string) => void; onWarning?: (message: string) => void; + signal?: AbortSignal; } /** A compact, setup-oriented summary over the existing Wiki service results. */ @@ -48,6 +50,7 @@ export interface SetupWikiFinalizationResult { export async function finalizeSetupWiki( options: SetupWikiFinalizationOptions, ): Promise { + throwIfSetupAborted(options.signal); const projectRoot = resolve(options.projectRoot); const scaffoldRoot = resolve(options.scaffoldRoot); const engine = createWikiEngine({ @@ -101,7 +104,9 @@ export async function finalizeSetupWiki( }); options.onProgress?.("Planning Wiki migration..."); + throwIfSetupAborted(options.signal); const plan = await engine.planMigration(); + throwIfSetupAborted(options.signal); collectDiagnostics(plan.diagnostics); plannedEntities = plan.data.report.planned.length; filesScanned = plan.data.report.filesScanned; @@ -116,6 +121,7 @@ export async function finalizeSetupWiki( // validation supplies the blocking diagnostic that keeps setup from writing // other files around a scaffold which is already structurally unsafe. const preflight = await engine.validate({ projectRoot }); + throwIfSetupAborted(options.signal); // Every knowledge file outside the Wiki is either adopted by the migration // this run applies next or already reported above as one of its // abstentions, so telling the user to run `mex wiki migrate` here would be @@ -128,7 +134,9 @@ export async function finalizeSetupWiki( } options.onProgress?.("Applying Wiki migration..."); + throwIfSetupAborted(options.signal); const applied = await engine.applyMigration(plan.data); + throwIfSetupAborted(options.signal); collectDiagnostics(applied.diagnostics); migrated = migrationChangedFiles(applied).length > 0; if (!applied.data.applied || hasErrors(applied)) { @@ -136,7 +144,9 @@ export async function finalizeSetupWiki( } options.onProgress?.("Rebuilding Wiki index..."); - const rebuilt = await engine.rebuildIndex(); + throwIfSetupAborted(options.signal); + const rebuilt = await engine.rebuildIndex(options.signal ? { signal: options.signal } : undefined); + throwIfSetupAborted(options.signal); collectDiagnostics(rebuilt.diagnostics); indexedEntities = rebuilt.data.entityCount; if (hasErrors(rebuilt)) { @@ -144,7 +154,9 @@ export async function finalizeSetupWiki( } options.onProgress?.("Validating Wiki scaffold..."); + throwIfSetupAborted(options.signal); const validated = await engine.validate({ projectRoot }); + throwIfSetupAborted(options.signal); // An untyped context file was reported as a migration abstention above. A // file still not adopted after the migration is not expected, and is kept. collectDiagnostics(withoutAdoptionGaps(validated.diagnostics, ["KNOWLEDGE_UNTYPED"])); diff --git a/test/helpers/setup-pty.py b/test/helpers/setup-pty.py new file mode 100644 index 00000000..d7e7269e --- /dev/null +++ b/test/helpers/setup-pty.py @@ -0,0 +1,158 @@ +"""Run one bounded terminal acceptance plan using only the Python standard library. + +The plan arrives on stdin as JSON. Actions wait for output, send terminal input, +or signal only the CLI parent. Failure cleanup owns the entire PTY process group. +""" + +import errno +import fcntl +import json +import os +import pty +import re +import select +import signal +import socket +import struct +import sys +import termios +import time +import urllib.parse + + +def main(): + plan = json.load(sys.stdin) + child, master = pty.fork() + if child == 0: + try: + os.chdir(plan["cwd"]) + environment = os.environ.copy() + for name in plan.get("unsetEnv", []): + environment.pop(name, None) + environment.update(plan.get("env", {})) + fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", plan.get("rows", 40), plan.get("columns", 140), 0, 0)) + os.execvpe(plan["command"][0], plan["command"], environment) + except Exception as error: + os.write(2, str(error).encode()) + os._exit(127) + + output = bytearray() + events = [] + status = None + eof = False + cursor = 0 + failure = None + deadline = time.monotonic() + plan.get("timeoutMs", 20000) / 1000 + max_bytes = plan.get("maxBytes", 1024 * 1024) + + def poll(): + nonlocal status + if status is None: + finished, value = os.waitpid(child, os.WNOHANG) + if finished: + status = value + + def read_once(wait=0.05): + nonlocal eof + if not eof and select.select([master], [], [], wait)[0]: + try: + chunk = os.read(master, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + if len(output) > max_bytes: + raise RuntimeError("PTY output exceeded its byte limit") + else: + eof = True + elif eof: + time.sleep(min(wait, 0.01)) + poll() + + def plain(): + value = output.decode("utf-8", errors="replace") + value = re.sub(r"\x1b\][^\x07]*(?:\x07|\x1b\\)", "", value) + return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", value).replace("\r", "") + + def check_time(): + if time.monotonic() >= deadline: + raise RuntimeError("PTY scenario timed out") + + try: + for action in plan.get("actions", []): + event = {} + if "expect" in action: + pattern = action["expect"] if action.get("regex") else re.escape(action["expect"]) + while True: + match = re.search(pattern, plain()[cursor:]) + if match: + cursor += match.end() + event["match"] = match.group(0) + break + check_time() + if status is not None and eof: + raise RuntimeError("CLI exited before expected output: " + action["expect"]) + read_once() + for path in action.get("missing", []): + if os.path.lexists(os.path.join(plan["cwd"], path)): + raise RuntimeError("Setup wrote before confirmation: " + path) + if action.get("quietMs"): + start = len(output) + until = time.monotonic() + action["quietMs"] / 1000 + while time.monotonic() < until: + check_time() + read_once(min(0.05, max(0, until - time.monotonic()))) + event["quietOutput"] = output[start:].decode("utf-8", errors="replace") + if action.get("probe"): + address = urllib.parse.urlsplit(event["match"]) + with socket.create_connection((address.hostname, address.port), timeout=2): + event["connected"] = True + if "input" in action: + # A frame can be painted before React installs its input effect. + # Model a human keypress, rather than injecting into that gap. + until = time.monotonic() + action.get("settleMs", 100) / 1000 + while time.monotonic() < until: + check_time() + read_once(min(0.02, max(0, until - time.monotonic()))) + os.write(master, action["input"].encode()) + if "signal" in action: + os.kill(child, getattr(signal, action["signal"])) + events.append(event) + while status is None: + check_time() + read_once() + # Capture the closing summary without waiting on a potentially leaked child. + until = time.monotonic() + 0.2 + while not eof and time.monotonic() < until: + read_once() + except Exception as error: + failure = str(error) + try: + os.killpg(child, signal.SIGTERM) + except ProcessLookupError: + pass + until = time.monotonic() + 0.75 + while status is None and time.monotonic() < until: + read_once() + try: + os.killpg(child, signal.SIGKILL) + except ProcessLookupError: + pass + if status is None: + _, status = os.waitpid(child, 0) + finally: + os.close(master) + + json.dump({ + "exitCode": os.waitstatus_to_exitcode(status), + "output": plain(), + "raw": output.decode("utf-8", errors="replace"), + "events": events, + "error": failure, + }, sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/test/hub-e2e/setup-commit.spec.ts b/test/hub-e2e/setup-commit.spec.ts index 59fe42b9..d4e02aa6 100644 --- a/test/hub-e2e/setup-commit.spec.ts +++ b/test/hub-e2e/setup-commit.spec.ts @@ -22,6 +22,57 @@ const architectureAfter = architectureBefore.replace(architectureRemoved, `${arc interface CommitPreview { revision: string; files: Array<{ path: string; status: string }>; } test.describe("built setup commit checkpoint", () => { + test("terminal-finalized projects reach review, then completion after the canonical commit", async ({ page }) => { + const fixture = createFixture({ existingScaffold: true }); + let hub: ChildProcess | undefined; + try { + rmSync(join(fixture.project, ".mex/graph.db")); + rmSync(join(fixture.project, ".mex/wiki.db")); + const terminal = spawnSync(process.execPath, [join(repositoryRoot, "dist/cli.js"), "setup", "--cli", "--yes", "--tool", "none"], { + cwd: fixture.project, env: fixture.env, encoding: "utf8", timeout: 20_000, + }); + expect(terminal.status, terminal.stderr + terminal.stdout).toBe(0); + expect(terminal.stdout).toContain("Graph and Wiki are ready"); + assertUnrelatedPreserved(fixture); + hub = (await openFixtureHub(page, fixture)).hub; + await expect(page.getByRole("button", { name: "Review setup changes", exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Background session output", exact: true })).toHaveCount(0); + // Checking the outstanding commit is not an agent session. It must not + // manufacture an empty transcript, including after reconnecting the page. + await page.getByText("Commit manually", { exact: true }).click(); + await page.getByRole("button", { name: "Check commit and continue", exact: true }).click(); + await expect(page.getByRole("button", { name: "Review setup changes", exact: true })).toBeVisible(); + await expect.poll(async () => { + const response = await page.request.get(new URL("/api/v1/setup/run", page.url()).href); + expect(response.ok()).toBe(true); + const run = await response.json(); + return { status: run.status, transcriptId: run.transcriptId ?? null }; + }).toEqual({ status: "paused", transcriptId: null }); + await expect(page.getByRole("region", { name: "Background session output", exact: true })).toHaveCount(0); + await expect(page.getByText("This session produced no output.", { exact: true })).toHaveCount(0); + await page.reload(); + await expect(page.getByRole("button", { name: "Review setup changes", exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Background session output", exact: true })).toHaveCount(0); + expect(existsSync(join(fixture.root, "provider-invoked"))).toBe(false); + await stopHub(hub); + // An explicit fixture Git checkpoint models the user's manual commit path. + git(fixture, "add", "--", ".mex"); + git(fixture, "commit", "--quiet", "--only", "-m", "Accept terminal setup", "--", ".mex"); + assertUnrelatedPreserved(fixture); + const opened = await openFixtureHub(page, fixture, false); + hub = opened.hub; + await expect(page.getByRole("heading", { name: "You’re ready", exact: true })).toBeVisible(); + await expect(page.getByLabel("Email", { exact: true })).toBeVisible(); + await expect(page.getByRole("region", { name: "Background session output", exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Open Hub", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Overview", exact: true })).toBeVisible(); + expect(existsSync(join(fixture.root, "provider-invoked"))).toBe(false); + } finally { + try { if (hub) await stopHub(hub); } + finally { rmSync(fixture.root, { recursive: true, force: true }); } + } + }); + test("reviews and commits only setup files while preserving unrelated staged work", async ({ page }, testInfo) => { const fixture = createFixture(); let hub: ChildProcess | undefined; @@ -293,7 +344,7 @@ function gitBytes(fixture: GitFixture, ...args: string[]): Buffer { function git(fixture: GitFixture, ...args: string[]): string { return gitBytes(fixture, ...args).toString("utf8"); } function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -async function openFixtureHub(page: Page, fixture: Fixture) { +async function openFixtureHub(page: Page, fixture: Fixture, expectReview = true) { const hub = spawn(process.execPath, [join(repositoryRoot, "dist/cli.js"), "setup", "--no-open"], { cwd: fixture.project, env: fixture.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); @@ -305,7 +356,7 @@ async function openFixtureHub(page: Page, fixture: Fixture) { page.on("pageerror", (error) => pageErrors.push(error.message)); page.on("request", (request) => { if (new URL(request.url()).origin !== origin) externalRequests.push(request.url()); }); await page.goto(bootstrap); - await expect(page.getByRole("button", { name: "Review setup changes", exact: true })).toBeVisible(); + if (expectReview) await expect(page.getByRole("button", { name: "Review setup changes", exact: true })).toBeVisible(); return { hub, origin, pageErrors, externalRequests }; } catch (error) { await stopHub(hub); throw error; } } diff --git a/test/setup-cli-routing.test.ts b/test/setup-cli-routing.test.ts index 87642088..27b119e8 100644 --- a/test/setup-cli-routing.test.ts +++ b/test/setup-cli-routing.test.ts @@ -1,14 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ hub: vi.fn(), setup: vi.fn(), tui: vi.fn(), capture: vi.fn() })); +const mocks = vi.hoisted(() => ({ hub: vi.fn(), setup: vi.fn(), parseTools: vi.fn(), tui: vi.fn(), capture: vi.fn() })); vi.mock("../src/hub/command.js", () => ({ launchHub: mocks.hub })); -vi.mock("../src/setup/index.js", () => ({ runSetup: mocks.setup })); +vi.mock("../src/setup/index.js", () => ({ runSetup: mocks.setup, parseSetupTools: mocks.parseTools })); vi.mock("../src/tui.js", () => ({ launchTui: mocks.tui })); vi.mock("../src/telemetry/index.js", async (original) => ({ ...await original(), captureEvent: mocks.capture, flush: async () => {}, })); -beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); vi.stubEnv("MEX_TELEMETRY", "0"); }); +beforeEach(() => { + vi.resetModules(); vi.clearAllMocks(); vi.stubEnv("MEX_TELEMETRY", "0"); + mocks.setup.mockResolvedValue({ outcome: "complete", message: "Setup complete.", exitCode: 0 }); + mocks.parseTools.mockImplementation((tools: string[]) => tools.includes("none") ? [] : tools); +}); afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); process.exitCode = 0; }); describe("setup entry points", () => { @@ -30,7 +34,11 @@ describe("setup entry points", () => { it.each([["setup", "--cli"], ["setup", "--dry-run"], ["setup", "--cli", "--mode", "agent-memory"]])("keeps terminal setup for %j", async (...args) => { const { program } = await import("../src/cli.js"); await program.parseAsync(args, { from: "user" }); - expect(mocks.setup).toHaveBeenCalledExactlyOnceWith({ dryRun: args.includes("--dry-run") || undefined, mode: args.includes("--mode") ? "agent-memory" : undefined }); + expect(mocks.setup).toHaveBeenCalledExactlyOnceWith({ + dryRun: args.includes("--dry-run") || undefined, + mode: args.includes("--mode") ? "agent-memory" : undefined, + port: undefined, openBrowser: true, + }); expect(mocks.hub).not.toHaveBeenCalled(); }); @@ -41,11 +49,51 @@ describe("setup entry points", () => { expect(mocks.hub).not.toHaveBeenCalled(); }); - it("rejects browser flags with a terminal dry run before either action", async () => { + it.each(["--cli", "--dry-run", "--yes"])("passes finishing Hub options to terminal setup with %s", async (flag) => { + const { program } = await import("../src/cli.js"); + await program.parseAsync(["setup", flag, "--no-open", "--port", "48125"], { from: "user" }); + expect(mocks.setup).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ port: 48125, openBrowser: false })); + expect(mocks.hub).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + + it("retains explicit empty tool selection for terminal setup", async () => { + const { program } = await import("../src/cli.js"); + await program.parseAsync(["setup", "--tool", "none", "--no-open"], { from: "user" }); + expect(mocks.parseTools).toHaveBeenCalledExactlyOnceWith(["none"]); + expect(mocks.setup).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tools: [], openBrowser: false })); + expect(mocks.hub).not.toHaveBeenCalled(); + }); + + it.each([ + ["complete", 0], ["paused", 2], ["cancelled", 130], ["failed", 1], ["dry-run", 0], + ])("maps the %s result to its exit code", async (outcome, exitCode) => { + const { program } = await import("../src/cli.js"); + mocks.setup.mockResolvedValue({ outcome, exitCode, message: "Setup result." }); + await program.parseAsync(["setup", "--cli"], { from: "user" }); + expect(process.exitCode).toBe(exitCode); + }); + + it("reports thrown terminal setup failures with exit code one", async () => { + const { program } = await import("../src/cli.js"); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.setup.mockRejectedValue(new Error("Setup could not start.")); + await program.parseAsync(["setup", "--cli"], { from: "user" }); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith("Setup could not start."); + expect(mocks.hub).not.toHaveBeenCalled(); + }); + + it("prints recovery instructions before local diagnostics for expected terminal failures", async () => { const { program } = await import("../src/cli.js"); - vi.spyOn(console, "error").mockImplementation(() => {}); - await program.parseAsync(["setup", "--dry-run", "--no-open"], { from: "user" }); + const { SetupError } = await import("../src/setup/errors.js"); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.setup.mockRejectedValue(new SetupError("EACCES: /local/config", { userMessage: "Could not save configuration. Fix its permissions and retry setup." })); + await program.parseAsync(["setup", "--cli", "--yes"], { from: "user" }); + expect(error.mock.calls).toEqual([ + ["Could not save configuration. Fix its permissions and retry setup."], + ["Diagnostics: EACCES: /local/config"], + ]); expect(process.exitCode).toBe(1); - expect(mocks.hub).not.toHaveBeenCalled(); expect(mocks.setup).not.toHaveBeenCalled(); }); }); diff --git a/test/setup-terminal-pty.test.ts b/test/setup-terminal-pty.test.ts new file mode 100644 index 00000000..5c50cf4b --- /dev/null +++ b/test/setup-terminal-pty.test.ts @@ -0,0 +1,401 @@ +import { execFile, execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createConnection } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +const repo = fileURLToPath(new URL("..", import.meta.url)); +const cli = join(repo, "dist/cli.js"); +const harness = join(repo, "test/helpers/setup-pty.py"); +const python = process.platform === "win32" ? undefined : spawnSync("python3", ["-c", "import pty, termios, sys; print(sys.executable)"], { encoding: "utf8", timeout: 3000 }); +const pythonPath = python?.status === 0 ? python.stdout.trim() : undefined; +const skipReason = process.platform === "win32" ? "requires a POSIX PTY" : !pythonPath ? "requires Python 3 with pty/termios" : undefined; +const fixtures: Fixture[] = []; + +interface Fixture { root: string; project: string; record: string; env: NodeJS.ProcessEnv; cli?: string; workerRecord?: string } +interface Action { expect?: string; regex?: boolean; missing?: string[]; quietMs?: number; input?: string; signal?: string; probe?: boolean } +interface Result { exitCode: number; output: string; raw: string; error: string | null; events: { match?: string; quietOutput?: string; connected?: boolean }[] } +interface AgentRecord { pid: number; agent: string; stdinTTY: boolean; stdoutTTY: boolean; stderrTTY: boolean; canonical: boolean; echo: boolean; input?: string; argv: string[]; promptExists: boolean } + +beforeAll(() => { + if (!skipReason && !existsSync(cli)) throw new Error("Build dist/cli.js with npm run build before running the terminal PTY acceptance tests."); +}); +afterEach(() => { + for (const fixture of fixtures.splice(0)) { + if (existsSync(fixture.record)) { + const record = agentRecord(fixture); + // Only a failed acceptance can leave its fake agent behind. + if (isAlive(record.pid)) { try { process.kill(record.pid, "SIGKILL"); } catch {} } + } + if (fixture.workerRecord && existsSync(fixture.workerRecord)) { + const record = JSON.parse(readFileSync(fixture.workerRecord, "utf8")) as { pid: number }; + if (isAlive(record.pid)) { try { process.kill(record.pid, "SIGKILL"); } catch {} } + } + rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +describe.skipIf(Boolean(skipReason))(`built terminal setup acceptance${skipReason ? ` (${skipReason})` : ""}`, { timeout: 35_000 }, () => { + it("shows the ASCII banner and choices at 80x24 before any setup writes, then cancels cleanly", async () => { + const f = fixture("incomplete"); + const result = await terminal(f, [], [{ expect: "Choose your AI tools", missing: [".mex", ".agents", "AGENTS.md", ".gitignore"], input: "\u0003" }], { columns: 80, rows: 24 }); + expect(result.exitCode, result.output).toBe(130); + expect(result.output).toContain("__ __ _____ __ __"); + expect(result.output).toContain("Codex"); + expect(result.output).toContain("Space multi-select"); + expect(result.output).toContain("Setup cancelled."); + expect(readdirSync(f.project)).toEqual([]); + expect(existsSync(f.record)).toBe(false); + }); + + it("Ctrl+D on the configuration screen cancels without creating files", async () => { + const f = fixture("incomplete"); + const result = await terminal(f, [], [{ expect: "Choose your AI tools", missing: [".mex"], input: "\u0004" }]); + expect(result.exitCode, result.output).toBe(130); + expect(result.output).toContain("Setup cancelled."); + expect(readdirSync(f.project)).toEqual([]); + expect(existsSync(f.record)).toBe(false); + }); + + it("Ctrl+D at the manual prompt cancels instead of returning to a stranded recovery menu", async () => { + const f = fixture("incomplete"); + const result = await terminal(f, ["--tool", "none"], [ + { expect: "Choose your AI tools", input: "\r" }, + { expect: "> Show setup prompt", input: "\r" }, + { expect: "Press Enter to return to MEX", input: "\u0004" }, + ]); + expect(result.exitCode, result.output).toBe(130); + expect(result.output).toContain("Copy this prompt into your AI tool"); + expect(result.output).toContain("Setup cancelled."); + expect(existsSync(f.record)).toBe(false); + }); + + it.each([ + { description: "a fresh project", savedTools: [] }, + { description: "a project with Claude already selected", savedTools: ["claude"] }, + ])("Enter selects and starts highlighted Codex in $description without pressing Space", async ({ savedTools }) => { + const f = fixture("incomplete"); + if (savedTools.length) { + mkdirSync(join(f.project, ".mex")); + writeFileSync(join(f.project, ".mex/config.json"), JSON.stringify({ aiTools: savedTools, setupMode: "agent-memory" })); + } + const result = await terminal(f, [], [ + { expect: `> [${savedTools.length ? "x" : " "}] Claude Code`, input: "\u001b[B" }, + { expect: "> [ ] Cursor", input: "\u001b[B" }, + { expect: "> [ ] Windsurf", input: "\u001b[B" }, + { expect: "> [ ] Copilot", input: "\u001b[B" }, + { expect: "> [ ] OpenCode", input: "\u001b[B" }, + { expect: "> [ ] Codex", input: "\r" }, + { expect: "MEX_PTY_CHILD_READY", input: "selected with Enter\n" }, + { expect: "required scaffold placeholders remain", input: "\u001b[A" }, + { expect: "> Finish later", input: "\r" }, + ], { defaultTool: false }); + expect(result.exitCode, result.output).toBe(2); + expect(result.output).toContain("Opening Codex"); + expect(result.output).not.toContain("Opening Claude Code"); + expect(agentRecord(f)).toMatchObject({ agent: "codex", stdinTTY: true, canonical: true, input: "selected with Enter" }); + expect(JSON.parse(readFileSync(join(f.project, ".mex/config.json"), "utf8")).aiTools).toEqual(["codex", ...savedTools]); + expectPromptCleaned(f); + }); + + it("hands a cooked real terminal to Codex, accepts keyboard input, and recovers from incomplete exit zero", async () => { + const f = fixture("incomplete"); + const result = await terminal(f, [], [ + { expect: "Choose your AI tools", input: "\r" }, + { expect: "MEX_PTY_CHILD_READY", quietMs: 1250, input: "hello native agent\n" }, + { expect: "required scaffold placeholders remain", input: "\u001b[B" }, + { expect: "> Show setup prompt", input: "\u001b[B" }, + { expect: "> Check populated files", input: "\r" }, + { expect: "Required scaffold placeholders remain", input: "\u001b[B" }, + { expect: "> Finish later", input: "\r" }, + ]); + expect(result.exitCode, result.output).toBe(2); + expect(result.events[1].quietOutput).toBe(""); + expect(agentRecord(f)).toMatchObject({ stdinTTY: true, stdoutTTY: true, stderrTTY: true, canonical: true, echo: true, input: "hello native agent", promptExists: true }); + expect(agentRecord(f).argv.join(" ")).toContain("Read the full setup population prompt"); + const nativeOutput = result.raw.split("MEX_PTY_CHILD_READY")[1]?.split("MEX_PTY_CHILD_DONE")[0]; + expect(nativeOutput).toContain("hello native agent"); + expect(nativeOutput).not.toContain("PROJECT MEMORY / SETUP"); + expect(nativeOutput).not.toContain("\u001b[?1049h"); + expect(result.output).toContain("Run `mex setup --cli` to continue"); + expect(JSON.parse(readFileSync(join(f.project, ".mex/config.json"), "utf8")).aiTools).toEqual(["codex"]); + expectPromptCleaned(f); + }); + + it("returns to recovery after a nonzero native agent exit and reports failure on Finish later", async () => { + const f = fixture("failed"); + const result = await terminal(f, [], [ + { expect: "Choose your AI tools", input: "\r" }, + { expect: "MEX_PTY_CHILD_READY", input: "finish\n" }, + { expect: "Codex stopped before a clean exit", input: "\u001b[A" }, + { expect: "> Finish later", input: "\r" }, + ]); + expect(result.exitCode, result.output).toBe(1); + expect(result.output).toContain("Show setup prompt"); + expect(result.output).toContain("Run `mex setup --cli` to continue"); + expectPromptCleaned(f); + }); + + it("validates populated files, exposes the finishing Hub URL, and closes its listener with q", async () => { + const f = fixture("complete"); + const result = await terminal(f, [], [ + { expect: "Choose your AI tools", input: "\r" }, + { expect: "MEX_PTY_CHILD_READY", input: "populate\n" }, + { expect: "http://127\\.0\\.0\\.1:[0-9]+/#token=[A-Za-z0-9_-]+", regex: true, probe: true, input: "q" }, + ]); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain("Continue in your browser"); + expect(result.events[2].connected).toBe(true); + expect(result.output).toContain("Hub stopped"); + expect(existsSync(join(f.root, "browser-open"))).toBe(false); + const address = new URL(result.events[2].match!); + expect(await canConnect(Number(address.port))).toBe(false); + expect(readFileSync(join(f.project, ".mex/AGENTS.md"), "utf8")).not.toContain("[YYYY-MM-DD]"); + expect(existsSync(join(f.project, ".mex/graph.db"))).toBe(false); + expect(existsSync(join(f.project, ".mex/wiki.db"))).toBe(false); + expectPromptCleaned(f); + }); + + it("SIGTERM stops the CLI and its active native child before removing the private prompt", async () => { + const f = fixture("hold"); + const result = await terminal(f, [], [ + { expect: "Choose your AI tools", input: "\r" }, + { expect: "MEX_PTY_CHILD_READY", signal: "SIGTERM" }, + ]); + expect(result.exitCode, result.output).toBe(143); + expect(result.output).toContain("Setup cancelled."); + expect(isAlive(agentRecord(f).pid)).toBe(false); + expectPromptCleaned(f); + }); + + it("--yes remains plain in a real terminal, writes scaffold files, and never starts an agent", async () => { + const f = fixture("complete"); + const result = await terminal(f, ["--yes"]); + expect(result.exitCode, result.output).toBe(2); + expect(result.output).toContain("Setup paused"); + expect(result.output).toContain("Population prompt:"); + expect(result.output).not.toContain("Choose your AI tools"); + expect(result.raw).not.toContain("\u001b[?1049h"); + expect(existsSync(join(f.project, ".mex/config.json"))).toBe(true); + expect(existsSync(f.record)).toBe(false); + }); + + it("shows real code graph parsing progress before pausing for manual population", async () => { + const f = fixture("incomplete"); + codeRepository(f); + const result = await terminal(f, ["--mode", "code-repo", "--tool", "none"], [ + { expect: "Choose your AI tools", input: "\r" }, + { expect: "Parsing source files" }, + { expect: "files parsed" }, + { expect: "> Show setup prompt", input: "\u001b[A" }, + { expect: "> Finish later", input: "\r" }, + ]); + expect(result.exitCode, result.output).toBe(2); + expect(result.output).toContain("Code graph"); + expect(result.output).toMatch(/\d+\s*\/\s*20\s+files parsed/); + expect(result.output).not.toContain("Setup needs attention"); + expect(existsSync(join(f.project, ".mex/graph.db"))).toBe(true); + expect(existsSync(join(f.project, ".mex/wiki.db"))).toBe(false); + expect(existsSync(f.record)).toBe(false); + expect(() => execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.project, stdio: "ignore" })).toThrow(); + }); + + it("prints real graph phases and final file counts in plain code-repository setup", async () => { + const f = fixture("incomplete"); + codeRepository(f); + const result = await terminal(f, ["--mode", "code-repo", "--tool", "none", "--yes"]); + expect(result.exitCode, result.output).toBe(2); + expect(result.output).toContain("Parsing source files: 0 / 20 files parsed"); + expect(result.output).toContain("Parsing source files: 20 / 20 files parsed"); + expect(result.output).toContain("Resolving code references"); + expect(result.output).toContain("Validating the rebuilt graph"); + expect(result.output).toContain("Saving the verified graph"); + expect(result.output).toContain("Population prompt:"); + expect(result.output).not.toContain("Choose your AI tools"); + expect(existsSync(join(f.project, ".mex/graph.db"))).toBe(true); + expect(existsSync(f.record)).toBe(false); + }); + + it("shows a graph worker heap-limit failure and retains its diagnostics after leaving the HUD", async () => { + const f = fixture("incomplete"); + codeRepository(f, 3); + // Keep this copied runtime outside the indexed project. Only its worker is + // replaced; the actual built CLI, supervisor, setup engine, and HUD run. + const runtime = join(f.root, "runtime"); + mkdirSync(runtime); + cpSync(join(repo, "dist"), join(runtime, "dist"), { recursive: true }); + symlinkSync(join(repo, "node_modules"), join(runtime, "node_modules"), "dir"); + symlinkSync(join(repo, "templates"), join(runtime, "templates"), "dir"); + cpSync(join(repo, "package.json"), join(runtime, "package.json")); + f.cli = join(runtime, "dist/cli.js"); + f.workerRecord = join(f.root, "graph-worker.json"); + f.env.MEX_PTY_GRAPH_RECORD = f.workerRecord; + writeFileSync(join(runtime, "dist/graph-candidate.js"), String.raw` +import { writeFileSync, writeSync } from "node:fs"; +process.once("message", request => { + writeFileSync(process.env.MEX_PTY_GRAPH_RECORD, JSON.stringify({ pid: process.pid, workspace: request.workspace, candidatePath: request.candidatePath })); + writeFileSync(request.candidatePath, "unpublished partial candidate\n"); + process.send({ type: "progress", progress: { phase: "parse", completed: 1, total: 3 } }, () => { + writeSync(2, "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\nMEX_PTY_HEAP_SENTINEL\n"); + process.exit(17); + }); +}); +process.send({ type: "ready" }); +`); + + const result = await terminal(f, ["--mode", "code-repo", "--tool", "none"], [ + { expect: "Choose your AI tools", input: "\r" }, + { expect: "heap limit" }, + { expect: "d diagnostics", input: "d" }, + { expect: "Details" }, + { expect: "MEX_PTY_HEAP_SENTINEL", input: "d" }, + { expect: "> Retry setup", input: "\u001b[B" }, + { expect: "> Exit setup", input: "\r" }, + ], { columns: 80, rows: 24 }); + + expect(result.exitCode, result.output).toBe(1); + expect(result.output).toContain("Setup needs attention"); + expect(result.output).toContain("d diagnostics"); + expect(result.output).toContain("NODE_OPTIONS=--max-old-space-size"); + // Assertions after the final alternate-screen exit prove these details + // survive teardown, rather than existing only in a discarded HUD frame. + const exitScreen = "\u001b[?1049l"; + expect(result.raw).toContain(exitScreen); + const restoredAt = result.raw.lastIndexOf(exitScreen); + expect(result.raw.slice(0, restoredAt)).toContain("JavaScript heap limit"); + expect(result.raw.slice(0, restoredAt)).toContain("d diagnostics"); + const summary = result.raw.slice(restoredAt + exitScreen.length); + expect(summary).toContain("JavaScript heap limit"); + expect(summary).toContain("last progress: parse 1/3 files"); + expect(summary).toContain("MEX_PTY_HEAP_SENTINEL"); + expect(summary).toContain("FATAL ERROR: Reached heap limit"); + expect(existsSync(f.record)).toBe(false); + expect(existsSync(join(f.project, ".mex/graph.db"))).toBe(false); + const worker = JSON.parse(readFileSync(f.workerRecord, "utf8")) as { pid: number; workspace: string; candidatePath: string }; + expect(isAlive(worker.pid)).toBe(false); + expect(existsSync(worker.workspace)).toBe(false); + expect(existsSync(worker.candidatePath)).toBe(false); + expect(readdirSync(join(f.project, ".mex")).filter(name => name.startsWith("graph.db"))).toEqual([]); + }); + + it("piped terminal setup uses the plain path without waiting or launching an agent", () => { + const f = fixture("complete"); + const result = spawnSync(process.execPath, [cli, ...args()], { cwd: f.project, env: f.env, encoding: "utf8", timeout: 20_000, maxBuffer: 1024 * 1024 }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(2); + expect(result.stdout).toContain("Setup paused"); + expect(result.stdout).not.toContain("Choose your AI tools"); + expect(existsSync(join(f.project, ".mex/config.json"))).toBe(true); + expect(existsSync(f.record)).toBe(false); + }); + + it("--dry-run remains read-only in a real terminal and never starts an agent", async () => { + const f = fixture("complete"); + const result = await terminal(f, ["--dry-run"]); + expect(result.exitCode, result.output).toBe(0); + expect(result.output).toContain("DRY RUN"); + expect(result.output).toContain("Dry run complete"); + expect(result.output).not.toContain("Choose your AI tools"); + expect(result.raw).not.toContain("\u001b[?1049h"); + expect(readdirSync(f.project)).toEqual([]); + expect(existsSync(f.record)).toBe(false); + }); +}); + +function args(extra: string[] = [], defaultTool = true): string[] { + return ["setup", "--cli", ...(extra.includes("--mode") ? [] : ["--mode", "agent-memory"]), ...(!defaultTool || extra.includes("--tool") ? [] : ["--tool", "codex"]), "--no-open", ...extra]; +} + +function codeRepository(f: Fixture, files = 20): void { + execFileSync("git", ["-c", "init.templateDir=", "init", "--quiet"], { cwd: f.project, stdio: "pipe", env: f.env }); + mkdirSync(join(f.project, "src")); + for (let index = 0; index < files; index++) { + writeFileSync(join(f.project, "src", `module-${index}.ts`), `export function calculate${index}(value: number): number { return value + ${index}; }\n`); + } +} + +function fixture(mode: "incomplete" | "failed" | "complete" | "hold"): Fixture { + const root = mkdtempSync(join(tmpdir(), "mex-setup-pty-")); + const project = join(root, "project"); + const bin = join(root, "bin"); + const record = join(root, "agent.json"); + mkdirSync(project); mkdirSync(bin); + // Both launchable agents are fake, including when a selection regression + // chooses Claude instead of the highlighted Codex integration. + for (const command of ["codex", "claude"]) { + const executable = join(bin, command); + writeFileSync(executable, `#!${pythonPath}\n${FAKE_AGENT}`); + chmodSync(executable, 0o755); + } + // A --no-open regression must never launch the developer's actual browser. + for (const command of ["open", "xdg-open"]) { + const launcher = join(bin, command); + writeFileSync(launcher, `#!${pythonPath}\nimport os, pathlib\npathlib.Path(os.environ["MEX_PTY_BROWSER_RECORD"]).write_text("unexpected browser launch")\n`); + chmodSync(launcher, 0o755); + } + const env: NodeJS.ProcessEnv = { ...process.env, PATH: [bin, dirname(process.execPath), process.env.PATH].join(":"), TERM: "xterm-256color", MEX_TELEMETRY: "0", DO_NOT_TRACK: "1", MEX_PTY_FAKE_MODE: mode, MEX_PTY_RECORD: record, MEX_PTY_BROWSER_RECORD: join(root, "browser-open") }; + delete env.CI; delete env.NO_COLOR; delete env.FORCE_COLOR; + const f = { root, project, record, env }; + fixtures.push(f); + return f; +} + +function terminal(f: Fixture, extra: string[] = [], actions: Action[] = [], options: { columns?: number; rows?: number; defaultTool?: boolean } = {}): Promise { + return new Promise((resolve, reject) => { + const child = execFile(pythonPath!, [harness], { encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) { reject(new Error(`PTY harness failed: ${error.message}\n${stderr}\n${stdout.slice(-4000)}`)); return; } + try { + const result = JSON.parse(stdout) as Result; + if (result.error) throw new Error(`${result.error} (CLI exit ${result.exitCode})\nCompleted actions: ${JSON.stringify(result.events)}\n${result.output.slice(-4000)}`); + resolve(result); + } catch (error) { reject(error); } + }); + child.stdin!.end(JSON.stringify({ command: [process.execPath, f.cli ?? cli, ...args(extra, options.defaultTool !== false)], cwd: f.project, env: f.env, unsetEnv: ["CI", "NO_COLOR", "FORCE_COLOR"], columns: options.columns ?? 140, rows: options.rows ?? 40, actions, timeoutMs: 25_000 })); + }); +} + +function agentRecord(f: Fixture): AgentRecord { return JSON.parse(readFileSync(f.record, "utf8")); } +function expectPromptCleaned(f: Fixture): void { + const local = join(f.project, ".mex/local"); + expect(existsSync(local) ? readdirSync(local).filter(name => name.startsWith("setup-population-")) : []).toEqual([]); +} +function isAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } +function canConnect(port: number): Promise { + return new Promise(resolve => { + const socket = createConnection({ host: "127.0.0.1", port }); + const finish = (connected: boolean) => { socket.destroy(); resolve(connected); }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(1000, () => finish(false)); + }); +} + +const FAKE_AGENT = String.raw` +import json, os, pathlib, re, signal, sys, termios + +target = pathlib.Path(os.environ["MEX_PTY_RECORD"]) +mode = os.environ["MEX_PTY_FAKE_MODE"] +flags = termios.tcgetattr(0)[3] +pointer = re.search(r"\x60([^\x60]+prompt\.md)\x60", " ".join(sys.argv[1:])) +record = { + "pid": os.getpid(), "agent": pathlib.Path(sys.argv[0]).name, "argv": sys.argv[1:], + "stdinTTY": os.isatty(0), "stdoutTTY": os.isatty(1), "stderrTTY": os.isatty(2), + "canonical": bool(flags & termios.ICANON), "echo": bool(flags & termios.ECHO), + "promptExists": bool(pointer and pathlib.Path(pointer.group(1)).is_file()), +} +target.write_text(json.dumps(record)) +print("MEX_PTY_CHILD_READY", flush=True) +if mode == "hold": + signal.pause() +else: + record["input"] = sys.stdin.readline().rstrip("\r\n") + target.write_text(json.dumps(record)) + if mode == "complete": + for path in pathlib.Path(".mex").rglob("*.md"): + content = path.read_text().replace("[Project Name]", "PTY fixture").replace("[Agent / Workspace Name]", "PTY fixture").replace("[YYYY-MM-DD]", "2026-01-01") + path.write_text(content) + print("MEX_PTY_CHILD_DONE", flush=True) + sys.exit(7 if mode == "failed" else 0) +`; From 8fc1449118ff936a9083422696474c1d79d6bbc0 Mon Sep 17 00:00:00 2001 From: theDakshJaitly Date: Fri, 9 Oct 2026 23:53:56 +0530 Subject: [PATCH 2/8] chore(release): prepare 0.8.4 with terminal setup by default --- .mex/ROUTER.md | 19 +- .mex/context/setup.md | 4 +- .mex/patterns/dogfood-mex-setup.md | 12 +- CHANGELOG.md | 34 ++- COMPATIBILITY.md | 102 +++++++- README.es.md | 57 +++-- README.md | 69 +++--- README.pt-BR.md | 57 +++-- README.zh-CN.md | 57 +++-- RELEASE_NOTES.md | 217 +++++++++-------- docs/code-graph-support.md | 2 +- docs/design/0.8.4-release-plan.md | 98 ++++++++ package-lock.json | 4 +- package.json | 2 +- scripts/hub-pack-smoke.mjs | 298 +++++++++++------------- src/cli.ts | 79 ++++--- src/setup/__tests__/contact.test.ts | 5 +- src/setup/__tests__/entry.test.ts | 134 +++++++++++ src/setup/__tests__/terminal-ui.test.ts | 28 ++- src/setup/entry.ts | 26 +++ src/setup/index.ts | 13 +- src/setup/terminal-ui.ts | 25 +- src/setup/terminal.ts | 4 +- templates/SETUP.md | 18 +- test/fixtures/capabilities/not-git.json | 2 +- test/fixtures/capabilities/ready.json | 2 +- test/hub-e2e/setup-commit.spec.ts | 2 +- test/setup-cli-routing.test.ts | 51 +++- test/setup-terminal-pty.test.ts | 19 +- test/telemetry-delivery.test.ts | 5 +- test/wiki-architecture.test.ts | 8 + 31 files changed, 981 insertions(+), 472 deletions(-) create mode 100644 docs/design/0.8.4-release-plan.md create mode 100644 src/setup/__tests__/entry.test.ts create mode 100644 src/setup/entry.ts diff --git a/.mex/ROUTER.md b/.mex/ROUTER.md index 903b2e1d..880d969d 100644 --- a/.mex/ROUTER.md +++ b/.mex/ROUTER.md @@ -30,7 +30,19 @@ Then read this file fully before doing anything else in this session. ## Current Project State **Working:** -- Local terminal setup work replaces the old prompts with an ASCII MEX HUD, +- Release preparation for 0.8.4 is on `codex/release-0.8.4`, based on main + `54f7d7a`, and includes the terminal setup overhaul and default-routing work. + Current package metadata, capability fixtures, README installation examples, + release notes, and upgrade guidance target 0.8.4. Historical release records + retain their versions. Teams must upgrade together before sharing new `mh2:` + groundings; older clients cannot read them. Preparation and PR creation are + authorized; publication is still pending. Verification and known follow-ups + are tracked in `docs/design/0.8.4-release-plan.md`. +- Local terminal setup is the default for `mex setup` and bare `mex` in + incomplete projects, with the lowercase ASCII `mex` banner in cyan. + `setup --browser` explicitly opens browser setup; `--cli` remains supported. + Bare `mex` still opens the Hub for established code projects, preserving + missing-index recovery. The HUD replaces the old prompts with integration selection before writes, native Claude/Codex terminal handoff, and explicit complete/paused/failed/cancelled outcomes. Enter selects the highlighted tool and starts; Space optionally toggles extra integrations, @@ -68,7 +80,7 @@ Then read this file fully before doing anything else in this session. approving review before normal merge. The maintainer will publish after merge. Verification and remaining publication steps are tracked in `docs/design/0.8.3-release-plan.md`. -- 0.8.2 setup work on `codex/0.8.2-hub-setup` makes `mex setup` and bare +- 0.8.2 setup work on `codex/0.8.2-hub-setup` originally made `mex setup` and bare `mex` browser entry points; `setup --cli` retains terminal setup and `setup --dry-run` remains a read-only terminal preview. Setup commits now lead to a completion guide with explicit **Open Hub**, version-pinned optional @@ -84,7 +96,8 @@ Then read this file fully before doing anything else in this session. frozen. The release plan records the deterministic calibration and CI evidence. This is branch implementation, not a published release; verification and outstanding release gates are in `docs/design/0.8.2-release-plan.md`. -- Incomplete checkouts open a Hub setup wizard from `mex`, `mex setup`, or `mex hub` instead of the +- Incomplete checkouts can open a Hub setup wizard explicitly with + `mex setup --browser` or `mex hub` instead of the full dashboard. Setup begins on a welcome screen, then runs the same ordered `mex setup` steps through a headless engine, pauses at population with a copyable prompt when no selected CLI is available. Claude/Codex population diff --git a/.mex/context/setup.md b/.mex/context/setup.md index c6f243f2..79fcead4 100644 --- a/.mex/context/setup.md +++ b/.mex/context/setup.md @@ -77,9 +77,9 @@ relations: 2. `npm run typecheck` 3. `npm test` 4. `npm run build` -5. For exact-checkout browser setup, run `node dist/cli.js setup`. Use `node dist/cli.js setup --cli` for the terminal HUD. It hands the terminal to a selected native Claude/Codex session, restores the HUD, checks population, then finalizes grounding and Wiki. Manual population offers Check populated files or a resumable exit. `--yes`, pipes, and CI use plain output with no agent or Hub launch; exit 2 means population is still required. +5. For exact-checkout terminal setup, run `node dist/cli.js setup` (`--cli` remains supported). The HUD hands the terminal to a selected native Claude/Codex session, restores it, checks population, then finalizes grounding and Wiki. Manual population offers Check populated files or a resumable exit. Use `node dist/cli.js setup --browser` for browser setup or to reopen its commit/completion page. `--yes`, pipes, and CI use plain output with no agent or Hub launch; exit 2 means population is still required. 6. Review the canonical scaffold and selected agent files in setup, then explicitly commit them through the Hub action or your Git client. Keep generated databases and `.mex/local/` ignored. The completion guide precedes **Open Hub** and offers optional version-pinned global install and contact details. -7. Bare `node dist/cli.js` opens the Hub or setup; `node dist/cli.js hub` remains supported. Verify any installed global CLI matches the checkout. `mex tui` retains the terminal dashboard. +7. Bare `node dist/cli.js` opens the Hub for established code projects, including missing-index recovery, and terminal setup otherwise. `node dist/cli.js hub` explicitly opens the browser. Verify any installed global CLI matches the checkout. `mex tui` retains the terminal dashboard.