diff --git a/.claude/skills/wizard-development/references/ARCHITECTURE.md b/.claude/skills/wizard-development/references/ARCHITECTURE.md index 98c560a44..f13384acc 100644 --- a/.claude/skills/wizard-development/references/ARCHITECTURE.md +++ b/.claude/skills/wizard-development/references/ARCHITECTURE.md @@ -170,3 +170,50 @@ tool names or discovery. Context-mill supplies skills and flow/task prompts. instrumentation. The linear sequence creates the benchmark pipeline; there is no pipeline construction in the compatibility `agent-runner.ts`. Inspect the actual consumer before extending instrumentation to another sequence or harness. + +## Surfaces and the control API + +The tree is three surfaces plus a composition root, each with a `README.md` that +lists what it owns and may import: + +| Surface | Owns | Imports | +| ----------- | ------------------------------------------------------------------------------- | --------------------------------------------------- | +| `src/store` | state, session, `WizardUI`, programs as data, tool behavior, the control API | `@env` | +| `src/agent` | one independent agent run: switchboard, sequences, harnesses, gateway | `@env`, `@store`, `@store/types`, `@store/programs` | +| `src/tui` | Ink screens, `UiStore`, program presentation, Ink-free console renderers | `@env`, `@store`, `@store/types`, `@store/programs` | +| `src/cli` | argv, command tree, runners that sequence runs and pass context, `ControlHooks` | every surface, through its public entries only | + +Cross-surface imports go through `@store`, `@store/types`, `@store/programs`, +`@agent`, `@agent/types`, `@tui`, `@tui/types`, and `@tui/console`. +`src/__tests__/architecture` enforces the matrix and the public-entry rule; +`tsc -b tsconfig.solution.json` mirrors it with project references. + +`--control-socket ` serves an HTTP/1.1 API over a unix socket from +`src/store/control`: state with long polling, actions that call one store setter +each, credentials, run arming on the TUI surface, detection and independent runs +on the headless surface, shutdown. The store owns the server; +`src/cli/control-hooks.ts` does the work that needs the agent. Every +`POST /runs` is one independent run with a clean run state and its own task +stream session. Published builds keep the server for headless runs and refuse +the flag on the TUI, whose bundle never contains it. The full route table and +the run instructions live in +[`e2e-harness/ARCHITECTURE.md`](../../../../e2e-harness/ARCHITECTURE.md). + +Run it: + +```bash +# headless, every build; the key travels in the environment +POSTHOG_WIZARD_API_KEY=phx_... WIZARD_CI_GATEWAY_TOKEN_FILE=/path/to/token \ + npx tsx bin.ts --headless-DONOTUSE-EXPERIMENTAL --control-socket /tmp/w/w.sock \ + --project-id --region us --install-dir /tmp/app +curl -s --unix-socket /tmp/w/w.sock -X POST -H 'content-type: application/json' -d '{}' http://localhost/detect +curl -s --unix-socket /tmp/w/w.sock -X POST -H 'content-type: application/json' \ + -d '{"programId":"posthog-integration"}' http://localhost/runs +curl -s --unix-socket /tmp/w/w.sock 'http://localhost/state?wait=60000&since=0' | jq '.state.run, .state.tasks' +curl -s --unix-socket /tmp/w/w.sock http://localhost/runs +curl -s --unix-socket /tmp/w/w.sock -X POST http://localhost/shutdown + +# the same sequence, scripted +POSTHOG_WIZARD_API_KEY=phx_... WIZARD_CI_GATEWAY_TOKEN_FILE=/path/to/token \ + npx tsx scripts/controlled-headless-smoke.no-jest.ts --app /tmp/app --project-id posthog-integration +``` diff --git a/.github/workflows/surfaces.yml b/.github/workflows/surfaces.yml index 173edd1d5..7848ced63 100644 --- a/.github/workflows/surfaces.yml +++ b/.github/workflows/surfaces.yml @@ -81,7 +81,15 @@ jobs: run: npx tsx scripts/chunk-manifest.no-jest.ts dist > chunk-manifest.ci.json + - name: Chunk layout matches the committed fixtures + run: | + status=0 + diff scripts/__fixtures__/chunk-manifest.prod.json chunk-manifest.prod.json || status=1 + diff scripts/__fixtures__/chunk-manifest.ci.json chunk-manifest.ci.json || status=1 + exit $status - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + # A failed comparison still uploads: the artifact is how the fixtures get refreshed. + if: always() with: name: chunk-manifests-${{ github.sha }} path: chunk-manifest.*.json diff --git a/e2e-harness/__tests__/control-actions-parity.test.ts b/e2e-harness/__tests__/control-actions-parity.test.ts new file mode 100644 index 000000000..e99c31f61 --- /dev/null +++ b/e2e-harness/__tests__/control-actions-parity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + ACTION_REGISTRY, + NO_ACTION_SCREENS as HARNESS_NO_ACTION, +} from '@e2e-harness/action-registry'; +import { Interrupt } from '@store'; +import { actionsFor, NO_ACTION_SCREENS } from '@store/control'; +import { flowFor, PROGRAM_REGISTRY } from '@store/programs'; + +/** The store adds the pick the e2e host used to inject through raw setters. */ +const STORE_ONLY: Record = { + 'self-driving-integration-detect': ['pick_integration_target'], + 'error-tracking-detect': ['pick_integration_target'], +}; + +describe('control actions parity with the e2e action registry', () => { + it('offers every harness action on every flow, plus only the documented picks', () => { + const drift: string[] = []; + for (const config of PROGRAM_REGISTRY) { + const { flow } = flowFor(config.id); + const screens = new Set([ + ...flow.steps.flatMap((s) => (s.screenId ? [s.screenId] : [])), + ...Object.values(Interrupt), + ]); + for (const screen of screens) { + const harness = ( + ( + ACTION_REGISTRY as Record | undefined> + )[screen] ?? [] + ).map((a) => a.id); + const store = actionsFor(flow, screen).map((a) => a.id); + for (const id of harness) { + if (!store.includes(id)) + drift.push(`${config.id}:${screen} lost ${id}`); + } + for (const id of store) { + if ( + !harness.includes(id) && + !(STORE_ONLY[screen] ?? []).includes(id) + ) { + drift.push(`${config.id}:${screen} added ${id}`); + } + } + } + } + expect(drift).toEqual([]); + }); + + it('keeps every no-action screen the harness lists, minus the detect screens the picks now cover', () => { + for (const screen of NO_ACTION_SCREENS) { + expect(HARNESS_NO_ACTION.has(screen as never), screen).toBe(true); + } + }); +}); diff --git a/scripts/README.md b/scripts/README.md index 725eda866..8477856e2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,18 +12,19 @@ real ink render) and is driven purely by store state manipulation; a PTY parent ([`e2e-harness/tui-capture.ts`](../e2e-harness/tui-capture.ts), node-pty + `@xterm/headless`) captures the real rendered screen. -| Script | What it does | Needs | -| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| **`tui-host.no-jest.ts`** | Real TUI host: `MODE=fixed` follows a profile; `MODE=serve` accepts socket commands. | `APP_DIR`, `PROJECT_ID`, key for a full run, `SNAP_CTRL`; run under a PTY, with `CONTROL_SOCK` in serve mode | -| **`tui-snapshots.no-jest.ts`** | Runs the fixed host and saves colored `SNAP_OUT/NN-.ans` frames, including within-screen progress. | `SNAP_OUT`, `APP_DIR`, `PROJECT_ID`, `POSTHOG_KEY_FILE` or `POSTHOG_PERSONAL_API_KEY` | -| **`wizard-ci-mcp.no-jest.ts`** | Stdio MCP server: `open_app`, `read_state`, `perform_action`, `render_screen`, `run_agent`. Screen output is plain text. | Spawns the host; `open_app` requires `appDir` and `projectId`, with optional `keyFile`, `apiKey`, `region` | -| **`chunk-manifest.no-jest.ts`** | Prints a structural manifest of `dist/`: per chunk, the source files it contains and the chunks it imports, hash suffixes stripped. Baselines live in `scripts/__fixtures__/chunk-manifest.{prod,ci}.json`. | A built `dist/` | -| **`wizard-ci-explore.no-jest.ts`** | `pnpm wizard-ci-explore`: opens an app, confirms setup, reads state, prints one frame, and exits. It does not run the agent. | `APP_DIR`, `PROJECT_ID`; optional `POSTHOG_KEY_FILE` | +| Script | What it does | Needs | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **`tui-host.no-jest.ts`** | Real TUI host: `MODE=fixed` follows a profile; `MODE=serve` accepts socket commands. | `APP_DIR`, `PROJECT_ID`, key for a full run, `SNAP_CTRL`; run under a PTY, with `CONTROL_SOCK` in serve mode | +| **`tui-snapshots.no-jest.ts`** | Runs the fixed host and saves colored `SNAP_OUT/NN-.ans` frames, including within-screen progress. | `SNAP_OUT`, `APP_DIR`, `PROJECT_ID`, `POSTHOG_KEY_FILE` or `POSTHOG_PERSONAL_API_KEY` | +| **`wizard-ci-mcp.no-jest.ts`** | Stdio MCP server: `open_app`, `read_state`, `perform_action`, `render_screen`, `run_agent`. Screen output is plain text. | Spawns the host; `open_app` requires `appDir` and `projectId`, with optional `keyFile`, `apiKey`, `region` | +| **`chunk-manifest.no-jest.ts`** | Prints a structural manifest of `dist/`, keyed by source-module group rather than chunk file name: which sources share a chunk and which groups each imports. Baselines live in `scripts/__fixtures__/chunk-manifest.{prod,ci}.json`; CI diffs a fresh build against them. | A built `dist/` | +| **`controlled-headless-smoke.no-jest.ts`** | Drives a headless run over its control socket: detect, one independent run per program named, the run ledger, shutdown. Prints every request and a redacted view of every response. | `POSTHOG_WIZARD_API_KEY`, `WIZARD_CI_GATEWAY_TOKEN_FILE`; `--app`, `--project-id`; optional `--region`, `--bin dist/bin.js` | +| **`wizard-ci-explore.no-jest.ts`** | `pnpm wizard-ci-explore`: opens an app, confirms setup, reads state, prints one frame, and exits. It does not run the agent. | `APP_DIR`, `PROJECT_ID`; optional `POSTHOG_KEY_FILE` | > You usually don't call these directly — `pnpm wizard-ci-snapshots` (in > [wizard-workbench](https://github.com/PostHog/wizard-workbench)) orchestrates -> the snapshot route; the MCP server is registered in this repo's `.mcp.json` and -> used via the `exploring-the-wizard` skill. +> the snapshot route; the MCP server is registered in this repo's `.mcp.json` +> and used via the `exploring-the-wizard` skill. `PROGRAM`, `SNAP_HARNESS`, `SNAP_SEQUENCE`, `SNAP_MODEL`, and `E2E_ASK` are host environment inputs, inherited from the launcher; they are not MCP tool @@ -35,11 +36,11 @@ before choosing credentials or an EU project. ## Credentials for full agent runs Full TUI host and snapshot runs require both the personal API key (or -`POSTHOG_KEY_FILE`) and `WIZARD_CI_GATEWAY_TOKEN_FILE`, plus `PROJECT_ID`. -For MCP runs, pass the personal key through `open_app` and set the gateway -token file path in the server environment before launch. Restart the server -after changing it; the gateway path is not an MCP tool argument. -Detection-only exploration does not need either secret. See +`POSTHOG_KEY_FILE`) and `WIZARD_CI_GATEWAY_TOKEN_FILE`, plus `PROJECT_ID`. For +MCP runs, pass the personal key through `open_app` and set the gateway token +file path in the server environment before launch. Restart the server after +changing it; the gateway path is not an MCP tool argument. Detection-only +exploration does not need either secret. See [local credential setup](../docs/local-dev.md#credentials-for-local-ci-and-headless-runs). ## Background @@ -47,5 +48,5 @@ Detection-only exploration does not need either secret. See The control plane lives in [`e2e-harness/`](../e2e-harness/) — out of `src/`, so none of it ships in prod. `WizardCiDriver` (read/act over the store), the screen→action registry, the e2e profiles, and `tui-capture` (real-TUI PTY -capture). See [`ARCHITECTURE.md`](../e2e-harness/ARCHITECTURE.md) for how the two -routes drive these (env strip, scoped project id, gotchas). +capture). See [`ARCHITECTURE.md`](../e2e-harness/ARCHITECTURE.md) for how the +two routes drive these (env strip, scoped project id, gotchas). diff --git a/scripts/__fixtures__/chunk-manifest.ci.json b/scripts/__fixtures__/chunk-manifest.ci.json index 49af3ed2a..2e2ade035 100644 --- a/scripts/__fixtures__/chunk-manifest.ci.json +++ b/scripts/__fixtures__/chunk-manifest.ci.json @@ -1,49 +1,43 @@ { - "add-mcp-server-to-clients.js": { + "bin.ts": { "sources": [ + "bin.ts" + ], + "imports": [ + "src/cli/commands/ai-observability.ts" + ] + }, + "src/agent/agent-interface.ts": { + "sources": [ + "src/agent/agent-interface.ts", + "src/agent/bash-fence.ts", + "src/agent/commandments.ts", + "src/agent/gateway/gateway-session.ts", + "src/agent/output-signals.ts", + "src/agent/runner/harness/pi/gateway.ts", + "src/agent/runner/harness/pi/runtime-notes.ts", + "src/agent/runner/switchboard/commandments.ts", + "src/agent/stored-login.ts", + "src/agent/tools/mcp.ts", + "src/agent/triage-provider.ts" + ], + "imports": [ + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", "src/store/agent-protocol/agent-signals.ts", - "src/store/api.ts", - "src/store/host-resolution.ts", - "src/store/services/oauth/program-scopes.ts", - "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/codex.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/cursor.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/opencode.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/visual-studio-code.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/zed.ts", - "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", - "src/store/services/steps/add-mcp-server-to-clients/index.ts", - "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/results.ts", - "src/store/shared/bounded-fs.ts", - "src/store/shared/errors/agent-map.ts", - "src/store/shared/errors/auth.ts", - "src/store/shared/errors/catalog.ts", - "src/store/shared/errors/codes.ts", - "src/store/shared/errors/detect-map.ts", - "src/store/shared/errors/emit.ts", - "src/store/shared/errors/run-failure.ts", - "src/store/shared/errors/sanitize.ts", - "src/store/shared/errors/skill-map.ts", - "src/store/shared/links.ts", - "src/store/shared/oauth-errors.ts", - "src/store/shared/oauth.ts", - "src/store/shared/package-manager.ts", - "src/store/shared/project-resolution.ts", - "src/store/shared/provisioning.ts", - "src/store/shared/semver.ts", - "src/store/shared/setup-utils.ts", - "src/store/shared/telemetry.ts", - "src/store/shared/urls.ts", - "src/store/shared/wizard-abort.ts" + "src/store/auth-session-state.ts" + ] + }, + "src/agent/agent-prompt-loader.ts": { + "sources": [ + "src/agent/agent-prompt-loader.ts" ], "imports": [ - "analytics.js" + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/auth-session-state.ts" ] }, - "agent.js": { + "src/agent/agent-prompt.ts": { "sources": [ "src/agent/agent-prompt.ts", "src/agent/detection/agentic.ts", @@ -80,105 +74,107 @@ "src/agent/runner/switchboard/sequence.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent-interface.js", - "agent-prompt-loader.js", - "analytics.js", - "pi.js", - "programs.js", - "queue-tools.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/agent-prompt-loader.ts", + "src/agent/aio-capture.ts", + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/programs/ai-opt-in-gate.ts" ] }, - "agent-interface.js": { + "src/agent/aio-capture.ts": { "sources": [ - "src/agent/agent-interface.ts", - "src/agent/bash-fence.ts", - "src/agent/commandments.ts", - "src/agent/gateway/gateway-session.ts", - "src/agent/output-signals.ts", - "src/agent/runner/harness/pi/gateway.ts", - "src/agent/runner/harness/pi/runtime-notes.ts", - "src/agent/runner/switchboard/commandments.ts", - "src/agent/stored-login.ts", - "src/agent/tools/mcp.ts", - "src/agent/triage-provider.ts" + "src/agent/aio-capture.ts", + "src/agent/runner/harness/pi/completion.ts", + "src/agent/runner/harness/pi/index.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "queue-tools.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/runner/harness/pi/mcp.ts", + "src/agent/runner/harness/pi/security.ts", + "src/agent/runner/harness/pi/subagent.ts", + "src/agent/runner/harness/pi/task.ts", + "src/agent/runner/harness/pi/tasks.ts", + "src/agent/runner/harness/pi/tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts" ] }, - "agent-prompt-loader.js": { + "src/agent/runner/harness/pi/mcp.ts": { "sources": [ - "src/agent/agent-prompt-loader.ts" + "src/agent/runner/harness/pi/mcp.ts" ], - "imports": [ - "analytics.js", - "queue-tools.js", - "store.js" - ] + "imports": [] }, - "analytics.js": { + "src/agent/runner/harness/pi/orchestrator-tools.ts": { "sources": [ - "src/env.ts", - "src/store/local-dev.ts", - "src/store/session/wizard-session.ts", - "src/store/shared/analytics.ts", - "src/store/shared/ci-flag-overrides.ts", - "src/store/shared/constants.ts", - "src/store/shared/debug.ts", - "src/store/shared/paths.ts", - "src/store/shared/version.ts", - "src/store/ui/index.ts", - "src/store/ui/null-ui.ts" + "src/agent/runner/harness/pi/orchestrator-tools.ts" ], "imports": [ - "analytics.js" + "src/agent/runner/sequence/orchestrator/queue-tools.ts" ] }, - "bin.js": { + "src/agent/runner/harness/pi/security.ts": { "sources": [ - "bin.ts" + "src/agent/runner/harness/pi/security.ts" ], "imports": [ - "main.js" + "src/agent/agent-interface.ts", + "src/store/auth-session-state.ts" ] }, - "ci-install.js": { + "src/agent/runner/harness/pi/subagent.ts": { "sources": [ - "src/cli/commands/basic-integration/ci-install.ts" + "src/agent/runner/harness/pi/subagent.ts" + ], + "imports": [] + }, + "src/agent/runner/harness/pi/task.ts": { + "sources": [ + "src/agent/runner/harness/pi/task.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "console.js", - "runners.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/agent-prompt-loader.ts", + "src/agent/aio-capture.ts", + "src/agent/runner/harness/pi/mcp.ts", + "src/agent/runner/harness/pi/orchestrator-tools.ts", + "src/agent/runner/harness/pi/security.ts", + "src/agent/runner/harness/pi/tools.ts", + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts" ] }, - "console.js": { + "src/agent/runner/harness/pi/tasks.ts": { "sources": [ - "src/tui/console/headless-ui.ts", - "src/tui/console/logging-ui.ts" + "src/agent/runner/harness/pi/tasks.ts" + ], + "imports": [] + }, + "src/agent/runner/harness/pi/tools.ts": { + "sources": [ + "src/agent/runner/harness/pi/tools.ts" ], "imports": [ - "analytics.js", - "store.js" + "src/agent/aio-capture.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/auth-session-state.ts" ] }, - "interactive.js": { + "src/agent/runner/sequence/orchestrator/queue-tools.ts": { "sources": [ - "src/cli/commands/basic-integration/interactive.ts" + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/agent/runner/sequence/orchestrator/queue.ts", + "src/agent/runner/switchboard/models.ts" ], "imports": [ - "runners.js", - "store.js" + "src/store/auth-session-state.ts" ] }, - "main.js": { + "src/cli/commands/ai-observability.ts": { "sources": [ "src/cli/commands/ai-observability.ts", "src/cli/commands/audit.ts", @@ -214,106 +210,56 @@ "src/cli/wizard.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent.js", - "analytics.js", - "ci-install.js", - "console.js", - "interactive.js", - "non-interactive.js", - "playground.js", - "runners.js", - "store.js", - "tui.js" - ] - }, - "mcp.js": { - "sources": [ - "src/agent/runner/harness/pi/mcp.ts" - ], - "imports": [ - "analytics.js" + "src/agent/agent-prompt.ts", + "src/cli/commands/basic-integration/ci-install.ts", + "src/cli/commands/basic-integration/interactive.ts", + "src/cli/commands/basic-integration/non-interactive.ts", + "src/cli/commands/basic-integration/playground.ts", + "src/cli/control-hooks.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/tui/App.tsx" ] }, - "non-interactive.js": { + "src/cli/commands/basic-integration/ci-install.ts": { "sources": [ - "src/cli/commands/basic-integration/non-interactive.ts" + "src/cli/commands/basic-integration/ci-install.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js" + "src/cli/control-hooks.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "orchestrator-tools.js": { + "src/cli/commands/basic-integration/interactive.ts": { "sources": [ - "src/agent/runner/harness/pi/orchestrator-tools.ts" + "src/cli/commands/basic-integration/interactive.ts" ], "imports": [ - "analytics.js", - "queue-tools.js" - ] - }, - "package-json.js": { - "sources": [], - "imports": [ - "package-json.js" + "src/cli/control-hooks.ts", + "src/store/auth-session-state.ts" ] }, - "pi.js": { + "src/cli/commands/basic-integration/non-interactive.ts": { "sources": [ - "src/agent/aio-capture.ts", - "src/agent/runner/harness/pi/completion.ts", - "src/agent/runner/harness/pi/index.ts" + "src/cli/commands/basic-integration/non-interactive.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent-interface.js", - "analytics.js", - "mcp.js", - "security.js", - "store.js", - "subagent.js", - "task.js", - "tasks.js", - "tools.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "playground.js": { + "src/cli/commands/basic-integration/playground.ts": { "sources": [ "src/cli/commands/basic-integration/playground.ts" ], "imports": [ - "analytics.js", - "tui.js" - ] - }, - "programs.js": { - "sources": [ - "src/store/programs/ai-opt-in-gate.ts", - "src/store/programs/audit/ledger-watcher.ts", - "src/store/programs/error-tracking-upload-source-maps/detect-agentic.ts", - "src/store/programs/flow-for.ts", - "src/store/programs/run-config.ts" - ], - "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "store.js" - ] - }, - "queue-tools.js": { - "sources": [ - "src/agent/runner/sequence/orchestrator/queue-tools.ts", - "src/agent/runner/sequence/orchestrator/queue.ts", - "src/agent/runner/switchboard/models.ts" - ], - "imports": [ - "analytics.js", - "store.js" + "src/tui/App.tsx" ] }, - "runners.js": { + "src/cli/control-hooks.ts": { "sources": [ + "src/cli/control-hooks.ts", "src/cli/runners/resolve-no-telemetry.ts", "src/cli/runners/run-non-interactive.ts", "src/cli/runners/run-wizard-ci.ts", @@ -321,89 +267,275 @@ "src/cli/runners/run-wizard.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent.js", - "analytics.js", - "console.js", - "programs.js", - "store.js", - "tui.js" + "src/agent/agent-prompt.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/control/actions.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/tui/App.tsx" ] }, - "security.js": { + "src/store/agent-protocol/agent-env-isolation.ts": { "sources": [ - "src/agent/runner/harness/pi/security.ts" + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-phase.ts", + "src/store/agent-protocol/mcp-prompt.ts", + "src/store/agent-protocol/token-pricing.ts", + "src/store/file-watcher.ts", + "src/store/mcp-project-profile.ts", + "src/store/mcp-role-prompts.copy.json", + "src/store/mcp-role-prompts.ts", + "src/store/mcp-seed-events.ts", + "src/store/safe-tools.ts", + "src/store/services/claude-settings.ts", + "src/store/services/steps/add-mcp-server-to-clients/browser-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/login-client.ts", + "src/store/session/secret-vault.ts", + "src/store/shared/clipboard.ts", + "src/store/shared/custom-headers.ts", + "src/store/shared/env-api-key.ts", + "src/store/shared/environment.ts", + "src/store/shared/terminal-bell.ts", + "src/store/state/store.ts", + "src/store/task-stream/audit-areas.ts", + "src/store/task-stream/destinations/file.ts", + "src/store/task-stream/destinations/posthog.ts", + "src/store/task-stream/event-plan-watcher.ts", + "src/store/task-stream/task-stream-push.ts", + "src/store/tools/handoff.ts", + "src/store/ui/store-ui.ts", + "src/store/ui/wizard-ui.ts", + "src/store/wizard-spellbook.ts" ], "imports": [ - "agent-interface.js", - "analytics.js", - "store.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "steps.js": { + "src/store/agent-protocol/agent-signals.ts": { "sources": [ - "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts", - "src/store/services/steps/upload-environment-variables/index.ts", - "src/store/services/steps/upload-environment-variables/providers/vercel.ts" + "src/store/agent-protocol/agent-signals.ts", + "src/store/api.ts", + "src/store/host-resolution.ts", + "src/store/services/oauth/program-scopes.ts", + "src/store/shared/bounded-fs.ts", + "src/store/shared/errors/agent-map.ts", + "src/store/shared/errors/auth.ts", + "src/store/shared/errors/catalog.ts", + "src/store/shared/errors/codes.ts", + "src/store/shared/errors/detect-map.ts", + "src/store/shared/errors/emit.ts", + "src/store/shared/errors/run-failure.ts", + "src/store/shared/errors/sanitize.ts", + "src/store/shared/errors/skill-map.ts", + "src/store/shared/links.ts", + "src/store/shared/oauth-errors.ts", + "src/store/shared/oauth.ts", + "src/store/shared/package-manager.ts", + "src/store/shared/project-resolution.ts", + "src/store/shared/provisioning.ts", + "src/store/shared/semver.ts", + "src/store/shared/setup-utils.ts", + "src/store/shared/telemetry.ts", + "src/store/shared/urls.ts", + "src/store/shared/wizard-abort.ts" ], - "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js" - ] + "imports": [] }, - "store.js": { - "sources": [], + "src/store/auth-session-state.ts": { + "sources": [ + "src/store/auth-session-state.ts", + "src/store/control/params.ts", + "src/store/detection/agentic.ts", + "src/store/detection/context.ts", + "src/store/detection/features.ts", + "src/store/detection/framework.ts", + "src/store/detection/package-manager.ts", + "src/store/detection/project-scope.ts", + "src/store/fetch-retry.ts", + "src/store/framework-config.ts", + "src/store/frameworks/android/android-wizard-agent.ts", + "src/store/frameworks/android/utils.ts", + "src/store/frameworks/angular/angular-wizard-agent.ts", + "src/store/frameworks/angular/utils.ts", + "src/store/frameworks/astro/astro-wizard-agent.ts", + "src/store/frameworks/astro/utils.ts", + "src/store/frameworks/django/django-wizard-agent.ts", + "src/store/frameworks/django/utils.ts", + "src/store/frameworks/elixir/elixir-wizard-agent.ts", + "src/store/frameworks/fastapi/fastapi-wizard-agent.ts", + "src/store/frameworks/fastapi/utils.ts", + "src/store/frameworks/flask/flask-wizard-agent.ts", + "src/store/frameworks/flask/utils.ts", + "src/store/frameworks/flutter/flutter-wizard-agent.ts", + "src/store/frameworks/go/go-wizard-agent.ts", + "src/store/frameworks/java/java-wizard-agent.ts", + "src/store/frameworks/javascript-node/javascript-node-wizard-agent.ts", + "src/store/frameworks/javascript-web/javascript-web-wizard-agent.ts", + "src/store/frameworks/javascript-web/utils.ts", + "src/store/frameworks/kmp/kmp-wizard-agent.ts", + "src/store/frameworks/laravel/laravel-wizard-agent.ts", + "src/store/frameworks/laravel/utils.ts", + "src/store/frameworks/nextjs/nextjs-wizard-agent.ts", + "src/store/frameworks/nextjs/utils.ts", + "src/store/frameworks/nuxt/nuxt-wizard-agent.ts", + "src/store/frameworks/python/python-wizard-agent.ts", + "src/store/frameworks/python/utils.ts", + "src/store/frameworks/rails/rails-wizard-agent.ts", + "src/store/frameworks/rails/utils.ts", + "src/store/frameworks/react-native/react-native-wizard-agent.ts", + "src/store/frameworks/react-native/utils.ts", + "src/store/frameworks/react-router/react-router-wizard-agent.ts", + "src/store/frameworks/react-router/utils.ts", + "src/store/frameworks/ruby/ruby-wizard-agent.ts", + "src/store/frameworks/ruby/utils.ts", + "src/store/frameworks/rust/rust-wizard-agent.ts", + "src/store/frameworks/svelte/svelte-wizard-agent.ts", + "src/store/frameworks/swift/swift-wizard-agent.ts", + "src/store/frameworks/swift/utils.ts", + "src/store/frameworks/tanstack-router/tanstack-router-wizard-agent.ts", + "src/store/frameworks/tanstack-router/utils.ts", + "src/store/frameworks/tanstack-start/tanstack-start-wizard-agent.ts", + "src/store/frameworks/tanstack-start/utils.ts", + "src/store/frameworks/vue/vue-wizard-agent.ts", + "src/store/health-checks/endpoints.ts", + "src/store/health-checks/readiness.ts", + "src/store/programs/agent-skill/index.ts", + "src/store/programs/agent-skill/steps.ts", + "src/store/programs/ai-observability/index.ts", + "src/store/programs/audit/detect.ts", + "src/store/programs/audit/index.ts", + "src/store/programs/audit/seed.ts", + "src/store/programs/audit/types.ts", + "src/store/programs/error-tracking-upload-source-maps/detect.ts", + "src/store/programs/error-tracking-upload-source-maps/index.ts", + "src/store/programs/error-tracking-upload-source-maps/prompt.ts", + "src/store/programs/error-tracking-upload-source-maps/steps.ts", + "src/store/programs/error-tracking/detect-agentic.ts", + "src/store/programs/error-tracking/index.ts", + "src/store/programs/events-audit/constants.ts", + "src/store/programs/events-audit/index.ts", + "src/store/programs/events-audit/seed.ts", + "src/store/programs/events-audit/steps.ts", + "src/store/programs/mcp-analytics/index.ts", + "src/store/programs/mcp/index.ts", + "src/store/programs/metrics/index.ts", + "src/store/programs/migration/index.ts", + "src/store/programs/migration/steps.ts", + "src/store/programs/posthog-doctor/fetch.ts", + "src/store/programs/posthog-doctor/index.ts", + "src/store/programs/posthog-doctor/kind-metadata.ts", + "src/store/programs/posthog-doctor/steps.ts", + "src/store/programs/posthog-doctor/types.ts", + "src/store/programs/posthog-integration/constants.ts", + "src/store/programs/posthog-integration/detect.ts", + "src/store/programs/posthog-integration/handoff.ts", + "src/store/programs/posthog-integration/index.ts", + "src/store/programs/posthog-integration/steps.ts", + "src/store/programs/program-registry.ts", + "src/store/programs/replay-vision/index.ts", + "src/store/programs/revenue-analytics/detect.ts", + "src/store/programs/revenue-analytics/index.ts", + "src/store/programs/revenue-analytics/steps.ts", + "src/store/programs/self-driving/detect-agentic.ts", + "src/store/programs/self-driving/detect.ts", + "src/store/programs/self-driving/index.ts", + "src/store/programs/self-driving/pricing.ts", + "src/store/programs/self-driving/prompt.ts", + "src/store/programs/self-driving/step-keys.ts", + "src/store/programs/self-driving/steps.ts", + "src/store/programs/shared/control-actions.ts", + "src/store/programs/shared/health-check-step.ts", + "src/store/programs/shared/package-scanning.ts", + "src/store/programs/shared/posthog-cli-preinstall.ts", + "src/store/programs/slack/index.ts", + "src/store/programs/warehouse-source/detect.ts", + "src/store/programs/warehouse-source/index.ts", + "src/store/programs/warehouse-source/steps.ts", + "src/store/programs/web-analytics-doctor/detect.ts", + "src/store/programs/web-analytics-doctor/index.ts", + "src/store/programs/web-analytics-doctor/steps.ts", + "src/store/registry.ts", + "src/store/security/yara-hooks.ts", + "src/store/security/yara-policy.ts", + "src/store/services/authenticate.ts", + "src/store/services/steps/install-cli-steering/index.ts", + "src/store/services/warehouse-sources/detect.ts", + "src/store/services/warehouse-sources/registry.ts", + "src/store/session/ask-policy.ts", + "src/store/session/wizard-ask-bridge.ts", + "src/store/shared/atomic-ledger.ts", + "src/store/shared/env-scan.ts", + "src/store/shared/headless-mode.ts", + "src/store/skill-install.ts", + "src/store/state/flow-resolution.ts", + "src/store/state/run-failure.ts", + "src/store/tools/tools.ts" + ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "store.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts" ] }, - "subagent.js": { + "src/store/control/actions.ts": { "sources": [ - "src/agent/runner/harness/pi/subagent.ts" + "src/store/control/actions.ts", + "src/store/control/driver.ts", + "src/store/control/marker.ts", + "src/store/control/runs.ts", + "src/store/control/server.ts", + "src/store/control/state.ts" ], "imports": [ - "analytics.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "task.js": { + "src/store/programs/ai-opt-in-gate.ts": { "sources": [ - "src/agent/runner/harness/pi/task.ts" + "src/store/programs/ai-opt-in-gate.ts", + "src/store/programs/audit/ledger-watcher.ts", + "src/store/programs/error-tracking-upload-source-maps/detect-agentic.ts", + "src/store/programs/flow-for.ts", + "src/store/programs/run-config.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent-interface.js", - "agent-prompt-loader.js", - "analytics.js", - "mcp.js", - "orchestrator-tools.js", - "pi.js", - "queue-tools.js", - "security.js", - "store.js", - "tools.js" + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "tasks.js": { + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts": { "sources": [ - "src/agent/runner/harness/pi/tasks.ts" + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/codex.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/cursor.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/opencode.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/visual-studio-code.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/zed.ts", + "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", + "src/store/services/steps/add-mcp-server-to-clients/index.ts", + "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/results.ts" ], "imports": [ - "analytics.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "tools.js": { + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts": { "sources": [ - "src/agent/runner/harness/pi/tools.ts" + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts", + "src/store/services/steps/upload-environment-variables/index.ts", + "src/store/services/steps/upload-environment-variables/providers/vercel.ts" ], "imports": [ - "analytics.js", - "pi.js", - "store.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "tui.js": { + "src/tui/App.tsx": { "sources": [ "src/tui/App.tsx", "src/tui/components/LearnCard.tsx", @@ -586,10 +718,11 @@ "src/tui/ui-store.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "programs.js", - "store.js" + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] } } diff --git a/scripts/__fixtures__/chunk-manifest.prod.json b/scripts/__fixtures__/chunk-manifest.prod.json index 49af3ed2a..3e48dd1ac 100644 --- a/scripts/__fixtures__/chunk-manifest.prod.json +++ b/scripts/__fixtures__/chunk-manifest.prod.json @@ -1,49 +1,43 @@ { - "add-mcp-server-to-clients.js": { + "bin.ts": { "sources": [ + "bin.ts" + ], + "imports": [ + "src/cli/commands/ai-observability.ts" + ] + }, + "src/agent/agent-interface.ts": { + "sources": [ + "src/agent/agent-interface.ts", + "src/agent/bash-fence.ts", + "src/agent/commandments.ts", + "src/agent/gateway/gateway-session.ts", + "src/agent/output-signals.ts", + "src/agent/runner/harness/pi/gateway.ts", + "src/agent/runner/harness/pi/runtime-notes.ts", + "src/agent/runner/switchboard/commandments.ts", + "src/agent/stored-login.ts", + "src/agent/tools/mcp.ts", + "src/agent/triage-provider.ts" + ], + "imports": [ + "src/agent/runner/sequence/orchestrator/queue-tools.ts", "src/store/agent-protocol/agent-signals.ts", - "src/store/api.ts", - "src/store/host-resolution.ts", - "src/store/services/oauth/program-scopes.ts", - "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/codex.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/cursor.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/opencode.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/visual-studio-code.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/zed.ts", - "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", - "src/store/services/steps/add-mcp-server-to-clients/index.ts", - "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/results.ts", - "src/store/shared/bounded-fs.ts", - "src/store/shared/errors/agent-map.ts", - "src/store/shared/errors/auth.ts", - "src/store/shared/errors/catalog.ts", - "src/store/shared/errors/codes.ts", - "src/store/shared/errors/detect-map.ts", - "src/store/shared/errors/emit.ts", - "src/store/shared/errors/run-failure.ts", - "src/store/shared/errors/sanitize.ts", - "src/store/shared/errors/skill-map.ts", - "src/store/shared/links.ts", - "src/store/shared/oauth-errors.ts", - "src/store/shared/oauth.ts", - "src/store/shared/package-manager.ts", - "src/store/shared/project-resolution.ts", - "src/store/shared/provisioning.ts", - "src/store/shared/semver.ts", - "src/store/shared/setup-utils.ts", - "src/store/shared/telemetry.ts", - "src/store/shared/urls.ts", - "src/store/shared/wizard-abort.ts" + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" + ] + }, + "src/agent/agent-prompt-loader.ts": { + "sources": [ + "src/agent/agent-prompt-loader.ts" ], "imports": [ - "analytics.js" + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/auth-session-state.ts" ] }, - "agent.js": { + "src/agent/agent-prompt.ts": { "sources": [ "src/agent/agent-prompt.ts", "src/agent/detection/agentic.ts", @@ -80,105 +74,110 @@ "src/agent/runner/switchboard/sequence.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent-interface.js", - "agent-prompt-loader.js", - "analytics.js", - "pi.js", - "programs.js", - "queue-tools.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/agent-prompt-loader.ts", + "src/agent/aio-capture.ts", + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, - "agent-interface.js": { + "src/agent/aio-capture.ts": { "sources": [ - "src/agent/agent-interface.ts", - "src/agent/bash-fence.ts", - "src/agent/commandments.ts", - "src/agent/gateway/gateway-session.ts", - "src/agent/output-signals.ts", - "src/agent/runner/harness/pi/gateway.ts", - "src/agent/runner/harness/pi/runtime-notes.ts", - "src/agent/runner/switchboard/commandments.ts", - "src/agent/stored-login.ts", - "src/agent/tools/mcp.ts", - "src/agent/triage-provider.ts" + "src/agent/aio-capture.ts", + "src/agent/runner/harness/pi/completion.ts", + "src/agent/runner/harness/pi/index.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "queue-tools.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/runner/harness/pi/mcp.ts", + "src/agent/runner/harness/pi/security.ts", + "src/agent/runner/harness/pi/subagent.ts", + "src/agent/runner/harness/pi/task.ts", + "src/agent/runner/harness/pi/tasks.ts", + "src/agent/runner/harness/pi/tools.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, - "agent-prompt-loader.js": { + "src/agent/runner/harness/pi/mcp.ts": { "sources": [ - "src/agent/agent-prompt-loader.ts" + "src/agent/runner/harness/pi/mcp.ts" ], - "imports": [ - "analytics.js", - "queue-tools.js", - "store.js" - ] + "imports": [] }, - "analytics.js": { + "src/agent/runner/harness/pi/orchestrator-tools.ts": { "sources": [ - "src/env.ts", - "src/store/local-dev.ts", - "src/store/session/wizard-session.ts", - "src/store/shared/analytics.ts", - "src/store/shared/ci-flag-overrides.ts", - "src/store/shared/constants.ts", - "src/store/shared/debug.ts", - "src/store/shared/paths.ts", - "src/store/shared/version.ts", - "src/store/ui/index.ts", - "src/store/ui/null-ui.ts" + "src/agent/runner/harness/pi/orchestrator-tools.ts" ], "imports": [ - "analytics.js" + "src/agent/runner/sequence/orchestrator/queue-tools.ts" ] }, - "bin.js": { + "src/agent/runner/harness/pi/security.ts": { "sources": [ - "bin.ts" + "src/agent/runner/harness/pi/security.ts" ], "imports": [ - "main.js" + "src/agent/agent-interface.ts", + "src/store/auth-session-state.ts" ] }, - "ci-install.js": { + "src/agent/runner/harness/pi/subagent.ts": { "sources": [ - "src/cli/commands/basic-integration/ci-install.ts" + "src/agent/runner/harness/pi/subagent.ts" + ], + "imports": [] + }, + "src/agent/runner/harness/pi/task.ts": { + "sources": [ + "src/agent/runner/harness/pi/task.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "console.js", - "runners.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/agent-prompt-loader.ts", + "src/agent/aio-capture.ts", + "src/agent/runner/harness/pi/mcp.ts", + "src/agent/runner/harness/pi/orchestrator-tools.ts", + "src/agent/runner/harness/pi/security.ts", + "src/agent/runner/harness/pi/tools.ts", + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, - "console.js": { + "src/agent/runner/harness/pi/tasks.ts": { "sources": [ - "src/tui/console/headless-ui.ts", - "src/tui/console/logging-ui.ts" + "src/agent/runner/harness/pi/tasks.ts" + ], + "imports": [] + }, + "src/agent/runner/harness/pi/tools.ts": { + "sources": [ + "src/agent/runner/harness/pi/tools.ts" ], "imports": [ - "analytics.js", - "store.js" + "src/agent/aio-capture.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, - "interactive.js": { + "src/agent/runner/sequence/orchestrator/queue-tools.ts": { "sources": [ - "src/cli/commands/basic-integration/interactive.ts" + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/agent/runner/sequence/orchestrator/queue.ts", + "src/agent/runner/switchboard/models.ts" ], "imports": [ - "runners.js", - "store.js" + "src/store/auth-session-state.ts" ] }, - "main.js": { + "src/cli/commands/ai-observability.ts": { "sources": [ "src/cli/commands/ai-observability.ts", "src/cli/commands/audit.ts", @@ -214,106 +213,56 @@ "src/cli/wizard.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent.js", - "analytics.js", - "ci-install.js", - "console.js", - "interactive.js", - "non-interactive.js", - "playground.js", - "runners.js", - "store.js", - "tui.js" - ] - }, - "mcp.js": { - "sources": [ - "src/agent/runner/harness/pi/mcp.ts" - ], - "imports": [ - "analytics.js" + "src/agent/agent-prompt.ts", + "src/cli/commands/basic-integration/ci-install.ts", + "src/cli/commands/basic-integration/interactive.ts", + "src/cli/commands/basic-integration/non-interactive.ts", + "src/cli/commands/basic-integration/playground.ts", + "src/cli/control-hooks.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", + "src/tui/App.tsx" ] }, - "non-interactive.js": { + "src/cli/commands/basic-integration/ci-install.ts": { "sources": [ - "src/cli/commands/basic-integration/non-interactive.ts" + "src/cli/commands/basic-integration/ci-install.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js" + "src/cli/control-hooks.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "orchestrator-tools.js": { + "src/cli/commands/basic-integration/interactive.ts": { "sources": [ - "src/agent/runner/harness/pi/orchestrator-tools.ts" + "src/cli/commands/basic-integration/interactive.ts" ], "imports": [ - "analytics.js", - "queue-tools.js" + "src/cli/control-hooks.ts", + "src/store/auth-session-state.ts" ] }, - "package-json.js": { - "sources": [], - "imports": [ - "package-json.js" - ] - }, - "pi.js": { + "src/cli/commands/basic-integration/non-interactive.ts": { "sources": [ - "src/agent/aio-capture.ts", - "src/agent/runner/harness/pi/completion.ts", - "src/agent/runner/harness/pi/index.ts" + "src/cli/commands/basic-integration/non-interactive.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent-interface.js", - "analytics.js", - "mcp.js", - "security.js", - "store.js", - "subagent.js", - "task.js", - "tasks.js", - "tools.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "playground.js": { + "src/cli/commands/basic-integration/playground.ts": { "sources": [ "src/cli/commands/basic-integration/playground.ts" ], "imports": [ - "analytics.js", - "tui.js" - ] - }, - "programs.js": { - "sources": [ - "src/store/programs/ai-opt-in-gate.ts", - "src/store/programs/audit/ledger-watcher.ts", - "src/store/programs/error-tracking-upload-source-maps/detect-agentic.ts", - "src/store/programs/flow-for.ts", - "src/store/programs/run-config.ts" - ], - "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "store.js" - ] - }, - "queue-tools.js": { - "sources": [ - "src/agent/runner/sequence/orchestrator/queue-tools.ts", - "src/agent/runner/sequence/orchestrator/queue.ts", - "src/agent/runner/switchboard/models.ts" - ], - "imports": [ - "analytics.js", - "store.js" + "src/tui/App.tsx" ] }, - "runners.js": { + "src/cli/control-hooks.ts": { "sources": [ + "src/cli/control-hooks.ts", "src/cli/runners/resolve-no-telemetry.ts", "src/cli/runners/run-non-interactive.ts", "src/cli/runners/run-wizard-ci.ts", @@ -321,89 +270,238 @@ "src/cli/runners/run-wizard.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent.js", - "analytics.js", - "console.js", - "programs.js", - "store.js", - "tui.js" + "src/agent/agent-prompt.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/control/actions.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", + "src/tui/App.tsx" ] }, - "security.js": { + "src/store/agent-protocol/agent-signals.ts": { "sources": [ - "src/agent/runner/harness/pi/security.ts" + "src/store/agent-protocol/agent-signals.ts", + "src/store/api.ts", + "src/store/host-resolution.ts", + "src/store/services/oauth/program-scopes.ts", + "src/store/shared/bounded-fs.ts", + "src/store/shared/errors/agent-map.ts", + "src/store/shared/errors/auth.ts", + "src/store/shared/errors/catalog.ts", + "src/store/shared/errors/codes.ts", + "src/store/shared/errors/detect-map.ts", + "src/store/shared/errors/emit.ts", + "src/store/shared/errors/run-failure.ts", + "src/store/shared/errors/sanitize.ts", + "src/store/shared/errors/skill-map.ts", + "src/store/shared/links.ts", + "src/store/shared/oauth-errors.ts", + "src/store/shared/oauth.ts", + "src/store/shared/package-manager.ts", + "src/store/shared/project-resolution.ts", + "src/store/shared/provisioning.ts", + "src/store/shared/semver.ts", + "src/store/shared/setup-utils.ts", + "src/store/shared/telemetry.ts", + "src/store/shared/urls.ts", + "src/store/shared/wizard-abort.ts" ], - "imports": [ - "agent-interface.js", - "analytics.js", - "store.js" - ] + "imports": [] }, - "steps.js": { + "src/store/auth-session-state.ts": { "sources": [ - "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts", - "src/store/services/steps/upload-environment-variables/index.ts", - "src/store/services/steps/upload-environment-variables/providers/vercel.ts" + "src/store/auth-session-state.ts", + "src/store/control/params.ts", + "src/store/detection/agentic.ts", + "src/store/detection/context.ts", + "src/store/detection/features.ts", + "src/store/detection/framework.ts", + "src/store/detection/package-manager.ts", + "src/store/detection/project-scope.ts", + "src/store/fetch-retry.ts", + "src/store/framework-config.ts", + "src/store/frameworks/android/android-wizard-agent.ts", + "src/store/frameworks/android/utils.ts", + "src/store/frameworks/angular/angular-wizard-agent.ts", + "src/store/frameworks/angular/utils.ts", + "src/store/frameworks/astro/astro-wizard-agent.ts", + "src/store/frameworks/astro/utils.ts", + "src/store/frameworks/django/django-wizard-agent.ts", + "src/store/frameworks/django/utils.ts", + "src/store/frameworks/elixir/elixir-wizard-agent.ts", + "src/store/frameworks/fastapi/fastapi-wizard-agent.ts", + "src/store/frameworks/fastapi/utils.ts", + "src/store/frameworks/flask/flask-wizard-agent.ts", + "src/store/frameworks/flask/utils.ts", + "src/store/frameworks/flutter/flutter-wizard-agent.ts", + "src/store/frameworks/go/go-wizard-agent.ts", + "src/store/frameworks/java/java-wizard-agent.ts", + "src/store/frameworks/javascript-node/javascript-node-wizard-agent.ts", + "src/store/frameworks/javascript-web/javascript-web-wizard-agent.ts", + "src/store/frameworks/javascript-web/utils.ts", + "src/store/frameworks/kmp/kmp-wizard-agent.ts", + "src/store/frameworks/laravel/laravel-wizard-agent.ts", + "src/store/frameworks/laravel/utils.ts", + "src/store/frameworks/nextjs/nextjs-wizard-agent.ts", + "src/store/frameworks/nextjs/utils.ts", + "src/store/frameworks/nuxt/nuxt-wizard-agent.ts", + "src/store/frameworks/python/python-wizard-agent.ts", + "src/store/frameworks/python/utils.ts", + "src/store/frameworks/rails/rails-wizard-agent.ts", + "src/store/frameworks/rails/utils.ts", + "src/store/frameworks/react-native/react-native-wizard-agent.ts", + "src/store/frameworks/react-native/utils.ts", + "src/store/frameworks/react-router/react-router-wizard-agent.ts", + "src/store/frameworks/react-router/utils.ts", + "src/store/frameworks/ruby/ruby-wizard-agent.ts", + "src/store/frameworks/ruby/utils.ts", + "src/store/frameworks/rust/rust-wizard-agent.ts", + "src/store/frameworks/svelte/svelte-wizard-agent.ts", + "src/store/frameworks/swift/swift-wizard-agent.ts", + "src/store/frameworks/swift/utils.ts", + "src/store/frameworks/tanstack-router/tanstack-router-wizard-agent.ts", + "src/store/frameworks/tanstack-router/utils.ts", + "src/store/frameworks/tanstack-start/tanstack-start-wizard-agent.ts", + "src/store/frameworks/tanstack-start/utils.ts", + "src/store/frameworks/vue/vue-wizard-agent.ts", + "src/store/health-checks/endpoints.ts", + "src/store/health-checks/readiness.ts", + "src/store/programs/agent-skill/index.ts", + "src/store/programs/agent-skill/steps.ts", + "src/store/programs/ai-observability/index.ts", + "src/store/programs/audit/detect.ts", + "src/store/programs/audit/index.ts", + "src/store/programs/audit/seed.ts", + "src/store/programs/audit/types.ts", + "src/store/programs/error-tracking-upload-source-maps/detect.ts", + "src/store/programs/error-tracking-upload-source-maps/index.ts", + "src/store/programs/error-tracking-upload-source-maps/prompt.ts", + "src/store/programs/error-tracking-upload-source-maps/steps.ts", + "src/store/programs/error-tracking/detect-agentic.ts", + "src/store/programs/error-tracking/index.ts", + "src/store/programs/events-audit/constants.ts", + "src/store/programs/events-audit/index.ts", + "src/store/programs/events-audit/seed.ts", + "src/store/programs/events-audit/steps.ts", + "src/store/programs/mcp-analytics/index.ts", + "src/store/programs/mcp/index.ts", + "src/store/programs/metrics/index.ts", + "src/store/programs/migration/index.ts", + "src/store/programs/migration/steps.ts", + "src/store/programs/posthog-doctor/fetch.ts", + "src/store/programs/posthog-doctor/index.ts", + "src/store/programs/posthog-doctor/kind-metadata.ts", + "src/store/programs/posthog-doctor/steps.ts", + "src/store/programs/posthog-doctor/types.ts", + "src/store/programs/posthog-integration/constants.ts", + "src/store/programs/posthog-integration/detect.ts", + "src/store/programs/posthog-integration/handoff.ts", + "src/store/programs/posthog-integration/index.ts", + "src/store/programs/posthog-integration/steps.ts", + "src/store/programs/program-registry.ts", + "src/store/programs/replay-vision/index.ts", + "src/store/programs/revenue-analytics/detect.ts", + "src/store/programs/revenue-analytics/index.ts", + "src/store/programs/revenue-analytics/steps.ts", + "src/store/programs/self-driving/detect-agentic.ts", + "src/store/programs/self-driving/detect.ts", + "src/store/programs/self-driving/index.ts", + "src/store/programs/self-driving/pricing.ts", + "src/store/programs/self-driving/prompt.ts", + "src/store/programs/self-driving/step-keys.ts", + "src/store/programs/self-driving/steps.ts", + "src/store/programs/shared/control-actions.ts", + "src/store/programs/shared/health-check-step.ts", + "src/store/programs/shared/package-scanning.ts", + "src/store/programs/shared/posthog-cli-preinstall.ts", + "src/store/programs/slack/index.ts", + "src/store/programs/warehouse-source/detect.ts", + "src/store/programs/warehouse-source/index.ts", + "src/store/programs/warehouse-source/steps.ts", + "src/store/programs/web-analytics-doctor/detect.ts", + "src/store/programs/web-analytics-doctor/index.ts", + "src/store/programs/web-analytics-doctor/steps.ts", + "src/store/registry.ts", + "src/store/security/yara-hooks.ts", + "src/store/security/yara-policy.ts", + "src/store/services/authenticate.ts", + "src/store/services/steps/install-cli-steering/index.ts", + "src/store/services/warehouse-sources/detect.ts", + "src/store/services/warehouse-sources/registry.ts", + "src/store/session/ask-policy.ts", + "src/store/session/wizard-ask-bridge.ts", + "src/store/shared/atomic-ledger.ts", + "src/store/shared/env-scan.ts", + "src/store/shared/headless-mode.ts", + "src/store/skill-install.ts", + "src/store/state/flow-resolution.ts", + "src/store/state/run-failure.ts", + "src/store/tools/tools.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js" - ] - }, - "store.js": { - "sources": [], - "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "store.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts" ] }, - "subagent.js": { + "src/store/control/actions.ts": { "sources": [ - "src/agent/runner/harness/pi/subagent.ts" + "src/store/control/actions.ts", + "src/store/control/driver.ts", + "src/store/control/marker.ts", + "src/store/control/runs.ts", + "src/store/control/server.ts", + "src/store/control/state.ts" ], "imports": [ - "analytics.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "task.js": { + "src/store/programs/ai-opt-in-gate.ts": { "sources": [ - "src/agent/runner/harness/pi/task.ts" + "src/store/programs/ai-opt-in-gate.ts", + "src/store/programs/audit/ledger-watcher.ts", + "src/store/programs/error-tracking-upload-source-maps/detect-agentic.ts", + "src/store/programs/flow-for.ts", + "src/store/programs/run-config.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "agent-interface.js", - "agent-prompt-loader.js", - "analytics.js", - "mcp.js", - "orchestrator-tools.js", - "pi.js", - "queue-tools.js", - "security.js", - "store.js", - "tools.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, - "tasks.js": { + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts": { "sources": [ - "src/agent/runner/harness/pi/tasks.ts" + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/codex.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/cursor.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/opencode.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/visual-studio-code.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/zed.ts", + "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", + "src/store/services/steps/add-mcp-server-to-clients/index.ts", + "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/results.ts" ], "imports": [ - "analytics.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "tools.js": { + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts": { "sources": [ - "src/agent/runner/harness/pi/tools.ts" + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts", + "src/store/services/steps/upload-environment-variables/index.ts", + "src/store/services/steps/upload-environment-variables/providers/vercel.ts" ], "imports": [ - "analytics.js", - "pi.js", - "store.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "tui.js": { + "src/tui/App.tsx": { "sources": [ "src/tui/App.tsx", "src/tui/components/LearnCard.tsx", @@ -586,10 +684,10 @@ "src/tui/ui-store.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "programs.js", - "store.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] } } diff --git a/scripts/chunk-manifest.no-jest.ts b/scripts/chunk-manifest.no-jest.ts index 623c2e064..37caf9c74 100644 --- a/scripts/chunk-manifest.no-jest.ts +++ b/scripts/chunk-manifest.no-jest.ts @@ -1,7 +1,9 @@ /** - * Structural manifest of the built bundle: for every chunk in dist/, the - * source files it contains (from its sourcemap) and the chunks it imports. - * Hash suffixes are stripped so the output is stable across builds. + * Structural manifest of the built bundle, keyed by content rather than by + * chunk file name: every chunk that carries source files becomes a group named + * after its first source; imports point at the groups they reach, with empty + * facade chunks resolved through. Chunk names and hashes vary by platform; + * which modules share a chunk and who imports whom does not. * * tsx scripts/chunk-manifest.no-jest.ts [distDir] > manifest.json */ @@ -16,8 +18,12 @@ const stripHash = (file: string): string => const importRe = /(?:from\s*|import\s*\(\s*)["']\.\/([^"']+\.js)["']/g; -const manifest: Record = {}; +interface Chunk { + sources: string[]; + imports: string[]; +} +const chunks: Record = {}; for (const file of fs .readdirSync(dist) .filter((f) => f.endsWith('.js')) @@ -34,7 +40,32 @@ for (const file of fs : []; const imports = new Set(); for (const m of code.matchAll(importRe)) imports.add(stripHash(m[1])); - manifest[stripHash(file)] = { sources, imports: [...imports].sort() }; + chunks[stripHash(file)] = { sources, imports: [...imports].sort() }; +} + +/** The groups a chunk reaches: itself when it carries sources, else what it re-exports. */ +function groupsOf(name: string, seen = new Set()): string[] { + const chunk = chunks[name]; + if (!chunk || seen.has(name)) return []; + seen.add(name); + if (chunk.sources.length) return [chunk.sources[0]]; + return chunk.imports.flatMap((i) => groupsOf(i, seen)); +} + +const manifest: Record = {}; +for (const [name, chunk] of Object.entries(chunks)) { + if (!chunk.sources.length) continue; + const imports = new Set(); + for (const i of chunk.imports) for (const g of groupsOf(i)) imports.add(g); + imports.delete(chunk.sources[0]); + manifest[chunk.sources[0]] = { + sources: chunk.sources, + imports: [...imports].sort(), + }; + void name; } -process.stdout.write(JSON.stringify(manifest, null, 2) + '\n'); +const sorted = Object.fromEntries( + Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b)), +); +process.stdout.write(JSON.stringify(sorted, null, 2) + '\n'); diff --git a/scripts/controlled-headless-smoke.no-jest.ts b/scripts/controlled-headless-smoke.no-jest.ts new file mode 100644 index 000000000..318050691 --- /dev/null +++ b/scripts/controlled-headless-smoke.no-jest.ts @@ -0,0 +1,151 @@ +/** + * Drive a published-style headless run over its control socket: detect, then + * one independent run per program named on the command line, then shutdown. + * Prints every request and a redacted view of every response. + * + * POSTHOG_WIZARD_API_KEY=… npx tsx scripts/controlled-headless-smoke.no-jest.ts \ + * --app /tmp/app --project-id 228144 [--region us] [--bin dist/bin.js] posthog-integration [metrics …] + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { HEADLESS_FLAG } from '@env'; +import { RunPhase } from '@store'; +import { ControlClient } from '@store/control'; +import type { ControlState } from '@store/types'; + +async function waitForSocket(p: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (fs.existsSync(p)) return; + await new Promise((r) => setTimeout(r, 150)); + } + throw new Error(`the wizard did not open ${p} within ${timeoutMs}ms`); +} + +const args = process.argv.slice(2); +const opt = (name: string, fallback?: string): string | undefined => { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : fallback; +}; +const programs = args.filter( + (a, i) => !a.startsWith('--') && (i === 0 || !args[i - 1].startsWith('--')), +); +const app = opt('app'); +const projectId = opt('project-id'); +const region = opt('region', 'us')!; +const bin = opt('bin', 'bin.ts')!; +if (!app || !projectId || programs.length === 0) { + process.stderr.write( + 'usage: --app --project-id [--region us|eu] [--bin dist/bin.js] [program…]\n', + ); + process.exit(2); +} +if (!process.env.POSTHOG_WIZARD_API_KEY) { + process.stderr.write( + 'POSTHOG_WIZARD_API_KEY must be set in the environment\n', + ); + process.exit(2); +} + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-ctl-')); +const socketPath = path.join(dir, 'w.sock'); +const cmd = bin.endsWith('.ts') + ? path.join(process.cwd(), 'node_modules/.bin/tsx') + : 'node'; +const argv = [ + bin, + `--${HEADLESS_FLAG}`, + '--control-socket', + socketPath, + '--project-id', + projectId, + '--region', + region, + '--install-dir', + app, +]; +const log = (line: string) => process.stdout.write(`${line}\n`); +log( + `$ ${cmd === 'node' ? 'node' : 'npx tsx'} ${argv.join( + ' ', + )} # POSTHOG_WIZARD_API_KEY in env`, +); +const env = { ...process.env }; +for (const k of Object.keys(env)) + if (/^(CLAUDE|ANTHROPIC|AI_AGENT)/.test(k)) delete env[k]; +const child = spawn(cmd, argv, { + cwd: process.cwd(), + env, + stdio: ['ignore', 'pipe', 'pipe'], +}); +const wizardOut: string[] = []; +child.stdout.on('data', (d: Buffer) => wizardOut.push(d.toString())); +child.stderr.on('data', (d: Buffer) => wizardOut.push(d.toString())); +const exit = new Promise((resolve) => + child.once('exit', (code) => resolve(code)), +); + +const brief = (s: ControlState) => ({ + version: s.version, + screen: s.currentScreen, + runPhase: s.session.runPhase, + integration: s.session.integration, + detectionComplete: s.session.detectionComplete, + hasCredentials: s.session.hasCredentials, + tasks: s.tasks.map((t) => `${t.status}:${t.label}`), + dashboardUrl: s.session.dashboardUrl, + notebookUrl: s.session.notebookUrl, + outro: s.session.outroData, +}); + +async function main(): Promise { + await waitForSocket(socketPath, 120_000); + const client = new ControlClient(socketPath); + log(`GET /health -> ${JSON.stringify(await client.health())}`); + log(`GET /state -> ${JSON.stringify(brief(await client.state()))}`); + log(`POST /detect {} -> ${JSON.stringify(brief(await client.detect({})))}`); + for (const programId of programs) { + const record = await client.startRun({ programId }); + log(`POST /runs {"programId":"${programId}"} -> ${JSON.stringify(record)}`); + let state = await client.state(); + while (state.session.runPhase === RunPhase.Running) { + state = await client.waitForChange(state.version, 60_000); + const last = state.tasks + .filter((t) => t.status !== 'pending') + .slice(-1)[0]; + log( + ` GET /state?wait=60000&since=${state.version} -> screen=${ + state.currentScreen + } runPhase=${state.session.runPhase} tasks=${ + state.tasks.filter((t) => t.status === 'completed').length + }/${state.tasks.length}${ + last ? ` last=${last.status}:${last.label}` : '' + }`, + ); + } + log(` final -> ${JSON.stringify(brief(state))}`); + } + log(`GET /runs -> ${JSON.stringify(await client.runs(), null, 2)}`); + log( + `POST /shutdown -> ${JSON.stringify( + await client.shutdown().then(() => ({ ok: true })), + )}`, + ); + const code = await exit; + log(`wizard exit code: ${code}`); + log(`socket removed: ${!fs.existsSync(socketPath)}`); + const console = wizardOut + .join('') + .replace(/phx_[A-Za-z0-9_]+/g, 'phx_') + .trim(); + if (console) log(`wizard console:\n${console}`); + process.exit(code === 0 ? 0 : 1); +} + +main().catch((e: unknown) => { + process.stderr.write(`smoke failed: ${(e as Error)?.stack ?? String(e)}\n`); + child.kill('SIGKILL'); + process.exit(1); +}); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 241bcab22..32ca4e36b 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -101,3 +101,43 @@ if ! echo "$hl_output" | grep -qi 'Headless mode requires --api-key'; then echo "$hl_output" | head -5 >&2 exit 1 fi + +# ── 5. Control server pruned from the TUI path ─────────────────────────────── +# The control API ships for headless runs only. Exactly one chunk carries the +# server; the TUI entry chunk and bin.js never import it; the published binary +# refuses --control-socket without the headless flag and accepts it with it. +CONTROL_MARKER='wizard-control-server' +TUI_MARKER='wizard-tui-entry' +control_chunks=$(grep -l "$CONTROL_MARKER" ./dist/*.js || true) +control_count=$(printf '%s\n' "$control_chunks" | grep -c . || true) +if [ "$control_count" -ne 1 ]; then + echo "Smoke test failed: expected exactly one chunk with $CONTROL_MARKER, found $control_count" >&2 + exit 1 +fi +control_file=$(basename "$control_chunks") +for tui_chunk in $(grep -l "$TUI_MARKER" ./dist/*.js || true); do + if grep -q "$control_file" "$tui_chunk"; then + echo "Smoke test failed: TUI chunk $tui_chunk imports the control server chunk" >&2 + exit 1 + fi +done +if grep -q "$control_file" "$DIST_BIN"; then + echo 'Smoke test failed: bin.js imports the control server chunk' >&2 + exit 1 +fi +if ! grep -l "$control_file" ./dist/*.js | grep -qv "$control_chunks"; then + echo 'Smoke test failed: nothing imports the control server chunk (headless path dead)' >&2 + exit 1 +fi +cs_output=$(node "$DIST_BIN" --control-socket /tmp/wizard-smoke-probe.sock --install-dir /tmp/wizard-smoke-probe 2>&1) && cs_exit=0 || cs_exit=$? +if [ "$cs_exit" -eq 0 ] || ! echo "$cs_output" | grep -q 'only available with the experimental headless flag'; then + echo 'Smoke test failed: --control-socket without the headless flag must be refused' >&2 + echo "$cs_output" | head -5 >&2 + exit 1 +fi +cs_hl=$(node "$DIST_BIN" "$HEADLESS_FLAG" --control-socket /tmp/wizard-smoke-probe.sock --install-dir /tmp/wizard-smoke-probe 2>&1) || true +if ! echo "$cs_hl" | grep -qi 'Headless mode requires --api-key'; then + echo 'Smoke test failed: headless --control-socket did not reach the headless path' >&2 + echo "$cs_hl" | head -5 >&2 + exit 1 +fi diff --git a/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap b/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap index 971f551b1..21fb952fb 100644 --- a/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap +++ b/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap @@ -192,6 +192,9 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "runAgent", "runMcpPromptViaSdk", ], + "@agent/types": [ + "RunAgent", + ], "@store": [ "ApiError", "CLI_STEERING_TARGETS", @@ -203,6 +206,7 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "PostHogDestination", "RunPhase", "Sequence", + "StoreUI", "TaskStreamPush", "VERSION", "WIZARD_TOOL_NAMES", @@ -230,6 +234,7 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "initLocalDev", "installOrUpdatePostHogCli", "installSteeringSnippet", + "isControlledTui", "isHeadless", "isNonInteractiveEnvironment", "isRunFailure", @@ -241,6 +246,7 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "recoverOrphanedSettingsBackups", "regionOption", "removeMCPServerFromClientsStep", + "resolveInstallDir", "runCleanups", "setDetectionAgent", "setEntryCommand", @@ -279,12 +285,17 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "CliEntry", "CliSteeringTarget", "CloudRegion", + "ControlHooks", + "ControlServerHandle", + "DetectRequest", "ErrorCode", "Harness", "OutroData", "ProgramConfig", + "ProgramId", "ProvisioningResult", "RunPhase", + "RunRequest", "Sequence", "TaskStreamPush", "WizardSession", diff --git a/src/__tests__/architecture/boundary-contracts.test.ts b/src/__tests__/architecture/boundary-contracts.test.ts index ba06863e5..b232217a4 100644 --- a/src/__tests__/architecture/boundary-contracts.test.ts +++ b/src/__tests__/architecture/boundary-contracts.test.ts @@ -67,4 +67,16 @@ describe('boundary contracts', () => { expect(consumed(roots)).toMatchSnapshot(); }, ); + + it.each(CONSUMERS.filter(([name]) => name !== 'cli'))( + '%s never names the control API', + (_name, roots) => { + const list: string[] = []; + for (const r of roots) files(r, list); + const offenders = list.filter((f) => + fs.readFileSync(f, 'utf8').includes('@store/control'), + ); + expect(offenders).toEqual([]); + }, + ); }); diff --git a/src/__tests__/architecture/import-boundaries.test.ts b/src/__tests__/architecture/import-boundaries.test.ts index 54f07dbf1..d49aad172 100644 --- a/src/__tests__/architecture/import-boundaries.test.ts +++ b/src/__tests__/architecture/import-boundaries.test.ts @@ -70,6 +70,15 @@ export const PUBLIC_ENTRIES: Record< /** The msw hook behind `NODE_ENV === 'test'`; tsdown inlines it away in published builds. */ const TEST_ONLY_EDGES = new Set(['bin.ts -> e2e-tests/mocks/server.ts']); +/** The only files that may load the control server, and only lazily. */ +const CONTROL_IMPORTERS = new Set([ + 'src/cli/runners/run-wizard.ts', + 'src/cli/runners/run-non-interactive.ts', +]); +const CONTROL_ENTRY = 'src/store/control/index.ts'; +const STATIC_CONTROL_IMPORT = + /(?:import|export)\s+(?:type\s+)?[^'"]*?from\s*['"]@store\/control['"]/; + /** Console renderers ship in headless builds and must stay Ink free. */ const INK_FREE_PREFIX = 'src/tui/console/'; @@ -318,6 +327,13 @@ function analyze(): Analysis { edges.add(key); const to = classifySurface(target); + if (target === CONTROL_ENTRY && from !== 'store') { + if (STATIC_CONTROL_IMPORT.test(text)) + violations.set(key, 'control-static-import'); + else if (!CONTROL_IMPORTERS.has(file)) + violations.set(key, 'control-importer'); + continue; + } if (to === 'harness') { if (!TEST_ONLY_EDGES.has(key)) violations.set(key, 'harness'); } else if (!allowed.includes(to)) diff --git a/src/__tests__/architecture/startup-graph.test.ts b/src/__tests__/architecture/startup-graph.test.ts index b9421600d..7ec8ec97d 100644 --- a/src/__tests__/architecture/startup-graph.test.ts +++ b/src/__tests__/architecture/startup-graph.test.ts @@ -97,6 +97,13 @@ describe('cli startup graph', () => { expect(ui).toEqual([]); }); + it('never loads the control server statically', () => { + const control = [...closure.files] + .map(rel) + .filter((f) => f === 'src/store/control/server.ts'); + expect(control).toEqual([]); + }); + it('does not load the agent runner before a command runs', () => { const agent = [...closure.files] .map(rel) diff --git a/src/cli/README.md b/src/cli/README.md index b89be84d7..69a9c4028 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -10,6 +10,18 @@ surfaces' public entries. Holds no domain logic. preflight. - `wizard.ts`, `commands/`: yargs surface. `runners/`: TUI and headless runners that sequence independent agent runs and pass context between them. +- `control-hooks.ts`: what the store's control server asks the composition root + to do: resolve credentials, release the run, detect, start one independent run + with explicit context, shut down. `--control-socket` attaches the server in + `run-non-interactive.ts` (headless, every build) and in `run-wizard.ts` (TUI, + dev builds only). Published TUI runs refuse the flag unless the headless flag + is present. +- Every `POST /runs` is one independent run: the hook clears the previous run's + state and gives the run its own task stream session; credentials and framework + context persist. A headless run with `WIZARD_CI_GATEWAY_TOKEN_FILE` in its + environment uses that bearer and never mints. + `scripts/controlled-headless-smoke.no-jest.ts` drives the surface end to end + and prints every request and response. ## May import diff --git a/src/cli/__tests__/control-hooks.test.ts b/src/cli/__tests__/control-hooks.test.ts new file mode 100644 index 000000000..640017e69 --- /dev/null +++ b/src/cli/__tests__/control-hooks.test.ts @@ -0,0 +1,306 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const projectData = vi.hoisted(() => vi.fn()); +const cleanups = vi.hoisted(() => vi.fn()); +const preRun = vi.hoisted(() => vi.fn()); +vi.mock('@store', async (importOriginal) => ({ + ...(await importOriginal()), + getOrAskForProjectData: projectData, + runCleanups: cleanups, +})); +vi.mock('@store/programs', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getProgramConfig: (id: string) => { + const config = original.getProgramConfig(id as never); + // Metrics stands in for a program with a ciPreRun and a skill of its own. + return id === original.Program.Metrics + ? { ...config, ciPreRun: preRun, skillId: 'metrics-skill' } + : config; + }, + }; +}); +vi.mock('@store/shared/analytics', () => ({ + analytics: { + setTag: vi.fn(), + wizardCapture: vi.fn(), + captureException: vi.fn(), + }, + sessionProperties: () => ({}), +})); + +import { + buildSession, + getUI, + HostResolution, + OutroKind, + RunPhase, + StoreUI, + setUI, +} from '@store'; +import { Program } from '@store/programs'; +import type { WizardSession } from '@store/types'; +import { createControlHooks } from '../control-hooks.js'; +import { fakeRunAgent, fakeStartTUI } from '../testing/fake-surfaces.js'; + +const dirs: string[] = []; +afterEach(() => { + for (const d of dirs.splice(0)) { + fs.rmSync(d, { recursive: true, force: true }); + } + vi.clearAllMocks(); +}); + +function setup(program = Program.PostHogIntegration) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wz-hooks-')); + dirs.push(dir); + const { store } = fakeStartTUI(program); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: dir, + ci: true, + apiKey: 'phx_key', + projectId: '42', + }); + store.setFrameworkContext('shared', 'live'); + const agent = fakeRunAgent(); + const shutdown = vi.fn(() => Promise.resolve()); + const streams: Array<{ + programId: string; + attach: ReturnType; + shutdown: ReturnType; + }> = []; + const runStream = vi.fn((config: { id: string }) => { + const stream = { + programId: config.id, + attach: vi.fn(), + shutdown: vi.fn(() => Promise.resolve()), + }; + streams.push(stream); + return stream; + }); + const hooks = createControlHooks({ + store, + programId: program, + runAgent: agent.runAgent, + runStream, + shutdown, + }); + return { store, hooks, agent, shutdown, streams, dir }; +} + +describe('credentials', () => { + it('resolves the API key host side and commits the project credentials', async () => { + const { store, hooks } = setup(); + projectData.mockResolvedValueOnce({ + accessToken: 'phx_key', + projectApiKey: 'phc_project', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 42, + }); + await hooks.setCredentials(); + expect(projectData).toHaveBeenCalledWith( + expect.objectContaining({ + ci: true, + signup: false, + apiKey: 'phx_key', + projectId: 42, + programId: Program.PostHogIntegration, + }), + ); + expect(store.session.credentials).toMatchObject({ + projectApiKey: 'phc_project', + projectId: 42, + }); + }); +}); + +describe('one independent run', () => { + it('scopes the run session and passes the request context explicitly', async () => { + const { store, hooks, agent, dir } = setup(); + await hooks.startRun({ + programId: Program.Audit, + installDir: 'apps/web', + frameworkContext: { picked: 'yes' }, + skillId: 'audit-events', + }); + expect(agent.calls).toEqual([ + { + programId: Program.Audit, + installDir: path.join(dir, 'apps/web'), + frameworkContextKeys: ['shared', 'picked'], + skillId: 'audit-events', + composed: true, + }, + ]); + // The live session is untouched: the next run starts from the same place. + expect(store.session.installDir).toBe(dir); + expect(store.session.frameworkContext).toEqual({ shared: 'live' }); + }); + + it('keeps an absolute install dir and falls back to the live one', async () => { + const { hooks, agent, dir } = setup(); + await hooks.startRun({ + programId: Program.PostHogIntegration, + installDir: '/abs/app', + }); + await hooks.startRun({ programId: Program.PostHogIntegration }); + expect(agent.calls.map((c) => c.installDir)).toEqual(['/abs/app', dir]); + }); + + it('picks the skill from the request, then the program, then the live session', async () => { + const { store, hooks, agent } = setup(); + store.session = { ...store.session, skillId: 'live-skill' }; + await hooks.startRun({ programId: Program.Audit, skillId: 'requested' }); + await hooks.startRun({ programId: Program.Metrics }); + await hooks.startRun({ programId: Program.PostHogIntegration }); + expect(agent.calls.map((c) => c.skillId)).toEqual([ + 'requested', + 'metrics-skill', + 'live-skill', + ]); + }); + + it('gives each run its own stream and a clean run state, and restores the user files', async () => { + const { store, hooks, streams } = setup(); + store.setDashboardUrl('https://us.posthog.com/project/1/dashboard/9'); + store.setTasks([{ label: 'stale', status: 'completed' } as never]); + store.setRunPhase(RunPhase.Completed); + await hooks.startRun({ programId: Program.Metrics }); + await hooks.startRun({ programId: Program.Audit }); + expect(streams.map((s) => s.programId)).toEqual([ + Program.Metrics, + Program.Audit, + ]); + for (const stream of streams) { + expect(stream.attach).toHaveBeenCalledTimes(1); + expect(stream.shutdown).toHaveBeenCalledWith(2000); + } + expect(cleanups).toHaveBeenCalledTimes(2); + expect(store.session.dashboardUrl).toBeNull(); + expect(store.tasks).toEqual([]); + expect(store.session.runPhase).toBe(RunPhase.Completed); + }); + + it('is in flight before the agent starts and completed after it returns', async () => { + const { store } = setup(); + let seen: RunPhase | null = null; + const hooks = createControlHooks({ + store, + programId: Program.PostHogIntegration, + runAgent: () => { + seen = store.session.runPhase; + return Promise.resolve(); + }, + shutdown: () => Promise.resolve(), + }); + await hooks.startRun({ programId: Program.PostHogIntegration }); + expect(seen).toBe(RunPhase.Running); + expect(store.session.runPhase).toBe(RunPhase.Completed); + }); + + it('records an error outro when the agent throws without one, and still closes the stream', async () => { + const { store, streams } = setup(); + const failing = createControlHooks({ + store, + programId: Program.PostHogIntegration, + runAgent: () => Promise.reject(new Error('gateway refused')), + runStream: () => { + const stream = { + programId: 'x', + attach: vi.fn(), + shutdown: vi.fn(() => Promise.resolve()), + }; + streams.push(stream); + return stream; + }, + shutdown: () => Promise.resolve(), + }); + await expect( + failing.startRun({ programId: Program.PostHogIntegration }), + ).rejects.toThrow('gateway refused'); + expect(store.session.runPhase).toBe(RunPhase.Error); + expect(store.session.outroData).toEqual({ + kind: OutroKind.Error, + message: 'gateway refused', + }); + expect(streams[0].shutdown).toHaveBeenCalledTimes(1); + expect(cleanups).toHaveBeenCalledTimes(1); + }); + + it("keeps the agent's own error outro when it already set one", async () => { + const { store } = setup(); + const hooks = createControlHooks({ + store, + programId: Program.PostHogIntegration, + runAgent: () => { + store.setOutroData({ + kind: OutroKind.Error, + message: 'agent said so', + errorCode: 'PHW_AGENT_YARA_VIOLATION' as never, + }); + store.setRunPhase(RunPhase.Error); + return Promise.reject(new Error('aborted')); + }, + shutdown: () => Promise.resolve(), + }); + await expect( + hooks.startRun({ programId: Program.PostHogIntegration }), + ).rejects.toThrow('aborted'); + expect(store.session.outroData).toMatchObject({ + message: 'agent said so', + errorCode: 'PHW_AGENT_YARA_VIOLATION', + }); + }); +}); + +describe('detection', () => { + it("runs the launched program's ready hooks when it declares no ciPreRun", async () => { + // Audit declares no ciPreRun, so detection is the store's onReady walk. + const { store, hooks } = setup(Program.Audit); + const ready = vi.spyOn(store, 'runReadyHooks'); + await hooks.detect({}); + expect(ready).toHaveBeenCalledTimes(1); + expect(preRun).not.toHaveBeenCalled(); + }); + + it('publishes both what ciPreRun wrote directly and what it committed through setters', async () => { + const { store, hooks } = setup(Program.Metrics); + preRun.mockImplementationOnce(async (session: WizardSession) => { + getUI().setCredentials({ + accessToken: 'phx_x', + projectApiKey: 'phc_x', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 9, + }); + session.typescript = true; + session.integration = 'javascript_node' as never; + await Promise.resolve(); + }); + await hooks.detect({}); + expect(preRun).toHaveBeenCalledTimes(1); + expect(store.session.credentials?.projectId).toBe(9); + expect(store.session.typescript).toBe(true); + expect(store.session.integration).toBe('javascript_node'); + expect(store.session.detectionComplete).toBe(true); + }); + + it('switches program and install dir on request', async () => { + const { store, hooks, dir } = setup(); + fs.mkdirSync(path.join(dir, 'sub')); + await hooks.detect({ programId: Program.Audit, installDir: 'sub' }); + expect(store.activeProgram).toBe(Program.Audit); + expect(store.session.installDir).toBe(path.join(dir, 'sub')); + }); +}); + +describe('shutdown', () => { + it('defers to the runner', async () => { + const { hooks, shutdown } = setup(); + await hooks.shutdown(); + expect(shutdown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/__tests__/control-refusal.test.ts b/src/cli/__tests__/control-refusal.test.ts new file mode 100644 index 000000000..0657a5ef0 --- /dev/null +++ b/src/cli/__tests__/control-refusal.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@env', async (importOriginal) => ({ + ...(await importOriginal()), + IS_PRODUCTION_BUILD: true, +})); +vi.mock('@store/shared/errors/emit', () => ({ emitWizardError: vi.fn() })); + +import { HEADLESS_FLAG } from '@env'; +import { emitWizardError } from '@store/shared/errors/emit'; +import { basicIntegrationCommand } from '../commands/basic-integration/index.js'; +import { + CONTROL_SOCKET_UNAVAILABLE, + controlFlagRefusal, + E2E_ASK_UNAVAILABLE, + Wizard, +} from '../wizard.js'; + +class Exit extends Error {} + +describe('published-build control flag refusal', () => { + it.each([ + [ + '--control-socket alone', + ['--control-socket', '/tmp/c.sock'], + {}, + CONTROL_SOCKET_UNAVAILABLE, + ], + [ + '--control-socket= alone', + ['--control-socket=/tmp/c.sock'], + {}, + CONTROL_SOCKET_UNAVAILABLE, + ], + [ + 'env alone', + [], + { POSTHOG_WIZARD_CONTROL_SOCKET: '/tmp/c.sock' }, + CONTROL_SOCKET_UNAVAILABLE, + ], + [ + '--control-socket with headless', + [`--${HEADLESS_FLAG}`, '--control-socket', '/tmp/c.sock'], + {}, + null, + ], + [ + 'env with headless', + [`--${HEADLESS_FLAG}`], + { POSTHOG_WIZARD_CONTROL_SOCKET: '/tmp/c.sock' }, + null, + ], + ['--e2e-ask', ['--e2e-ask'], {}, E2E_ASK_UNAVAILABLE], + [ + '--e2e-ask env', + [], + { POSTHOG_WIZARD_E2E_ASK: 'true' }, + E2E_ASK_UNAVAILABLE, + ], + [ + '--e2e-ask even with headless', + [`--${HEADLESS_FLAG}`, '--e2e-ask'], + {}, + E2E_ASK_UNAVAILABLE, + ], + ['nothing', ['--install-dir', '/tmp/app'], {}, null], + ] as const)('%s', (_label, args, env, expected) => { + expect(controlFlagRefusal(args, env)).toBe(expected); + }); +}); + +describe('published-build init', () => { + const argv = process.argv; + let stderr: string[]; + beforeEach(() => { + stderr = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Exit('exit'); + }); + delete process.env.POSTHOG_WIZARD_CONTROL_SOCKET; + }); + afterEach(() => { + process.argv = argv; + vi.restoreAllMocks(); + vi.mocked(emitWizardError).mockClear(); + }); + + it('prints the refusal and emits the machine readable error', () => { + process.argv = [ + 'node', + 'wizard', + '--control-socket', + '/tmp/c.sock', + '--install-dir', + '/tmp/app', + ]; + expect(() => Wizard.use(basicIntegrationCommand).init()).toThrow(Exit); + expect(stderr.join('')).toContain(CONTROL_SOCKET_UNAVAILABLE); + expect(emitWizardError).toHaveBeenCalledWith( + expect.objectContaining({ message: CONTROL_SOCKET_UNAVAILABLE }), + ); + }); +}); diff --git a/src/cli/__tests__/control-surface.test.ts b/src/cli/__tests__/control-surface.test.ts new file mode 100644 index 000000000..ee0178071 --- /dev/null +++ b/src/cli/__tests__/control-surface.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Arguments } from 'yargs'; + +const runners = vi.hoisted(() => ({ + runWizard: vi.fn(), + runWizardCI: vi.fn(), + runWizardHeadless: vi.fn(), +})); +vi.mock('../runners/index.js', () => runners); + +import { HEADLESS_FLAG } from '@env'; +import { posthogIntegrationConfig } from '@store/programs'; +import { dispatchProgram } from '../commands/factories/shared.js'; +import { GLOBAL_OPTIONS } from '../wizard.js'; + +const argv = (extra: Record): Arguments => + ({ _: [], $0: 'wizard', ...extra } as Arguments); + +describe('control socket flag', () => { + it('is a hidden global string option', () => { + expect(GLOBAL_OPTIONS['control-socket']).toMatchObject({ + type: 'string', + hidden: true, + }); + }); +}); + +describe('surface dispatch', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.clearAllMocks()); + + it.each([ + [ + 'headless + socket', + { [HEADLESS_FLAG]: true, controlSocket: '/tmp/c.sock' }, + 'runWizardHeadless', + ], + ['headless alone', { [HEADLESS_FLAG]: true }, 'runWizardHeadless'], + ['ci + socket', { ci: true, controlSocket: '/tmp/c.sock' }, 'runWizard'], + ['ci alone', { ci: true }, 'runWizardCI'], + ['socket alone', { controlSocket: '/tmp/c.sock' }, 'runWizard'], + ['no flags', {}, 'runWizard'], + ] as const)('%s routes to %s', (_label, flags, runner) => { + dispatchProgram(posthogIntegrationConfig, argv(flags)); + for (const name of [ + 'runWizard', + 'runWizardCI', + 'runWizardHeadless', + ] as const) { + expect(runners[name], name).toHaveBeenCalledTimes( + name === runner ? 1 : 0, + ); + } + expect(runners[runner]).toHaveBeenCalledWith( + posthogIntegrationConfig, + expect.objectContaining(flags), + ); + }); +}); diff --git a/src/cli/commands/basic-integration/index.ts b/src/cli/commands/basic-integration/index.ts index 84cbaf0a6..d22278d9c 100644 --- a/src/cli/commands/basic-integration/index.ts +++ b/src/cli/commands/basic-integration/index.ts @@ -2,6 +2,7 @@ import { isNonInteractiveEnvironment, setEntryCommand, headlessOption, + isControlledTui, isHeadless, regionOption, } from '@store'; @@ -64,6 +65,10 @@ export const basicIntegrationCommand: Command = { const { runHeadlessInstall } = await import('./ci-install.js'); return runHeadlessInstall(argv); } + if (isControlledTui(argv)) { + const { runInteractive } = await import('./interactive.js'); + return runInteractive(argv); + } if (argv.ci) { const { runCIInstall } = await import('./ci-install.js'); return runCIInstall(argv); diff --git a/src/cli/commands/factories/shared.ts b/src/cli/commands/factories/shared.ts index 97c775ee5..ead5b4149 100644 --- a/src/cli/commands/factories/shared.ts +++ b/src/cli/commands/factories/shared.ts @@ -5,7 +5,12 @@ import { runWizardCI, runWizardHeadless, } from '../../runners/index.js'; -import { isHeadless, ErrorCodes, emitWizardError } from '@store'; +import { + isControlledTui, + isHeadless, + ErrorCodes, + emitWizardError, +} from '@store'; import type { ProgramConfig } from '@store/types'; import { skillProgramOptions } from '../skill-program-options.js'; @@ -45,6 +50,8 @@ export function dispatchProgram(config: ProgramConfig, argv: Arguments): void { // Same non-interactive pipeline `--ci` uses; validation (api-key, // install-dir, region) is owned by runNonInteractive. runWizardHeadless(config, options); + } else if (isControlledTui(options)) { + runWizard(config, options); } else if (options.ci) { runWizardCI(config, options); } else { diff --git a/src/cli/control-hooks.ts b/src/cli/control-hooks.ts new file mode 100644 index 000000000..dd9e0cbc8 --- /dev/null +++ b/src/cli/control-hooks.ts @@ -0,0 +1,144 @@ +import type { RunAgent } from '@agent/types'; +import { + getOrAskForProjectData, + logToFile, + OutroKind, + resolveInstallDir, + runCleanups, + RunPhase, +} from '@store'; +import { flowFor, getProgramConfig, runConfigFor } from '@store/programs'; +import type { + ControlHooks, + DetectRequest, + ProgramConfig, + ProgramId, + RunRequest, + WizardSession, + WizardStore, +} from '@store/types'; + +/** The task stream one independent run publishes to; a new session per run. */ +export interface RunStream { + attach(): void; + shutdown(timeoutMs: number): Promise; +} + +export interface ControlHookDeps { + store: WizardStore; + /** The program this process launched with. */ + programId: ProgramId; + runAgent: RunAgent; + /** Builds the stream a run publishes to; absent means the run publishes nothing. */ + runStream?: (config: ProgramConfig, session: WizardSession) => RunStream; + /** Flush and exit; the runner owns the exact steps. */ + shutdown: () => Promise; +} + +/** The keys `draft` changed against `before`. */ +function changedKeys( + before: WizardSession, + draft: WizardSession, +): Partial { + const was = before as unknown as Record; + const out: Record = {}; + for (const [key, value] of Object.entries(draft)) { + if (value !== was[key]) out[key] = value; + } + return out as Partial; +} + +/** The composition root's side of the control API: every run independent, context merged here only. */ +export function createControlHooks(deps: ControlHookDeps): ControlHooks { + const { store } = deps; + return { + async setCredentials() { + const s = store.session; + const d = await getOrAskForProjectData({ + signup: false, + ci: true, + apiKey: s.apiKey, + projectId: s.projectId, + baseUrl: s.baseUrl, + programId: store.activeProgram, + }); + store.setCredentials({ + accessToken: d.accessToken, + projectApiKey: d.projectApiKey, + host: d.host, + projectId: d.projectId, + }); + }, + + async detect(req: DetectRequest) { + const programId = req.programId ?? deps.programId; + if (programId !== store.activeProgram) { + store.switchProgram(flowFor(programId).flow); + } + if (req.installDir) { + store.session = { + ...store.session, + installDir: resolveInstallDir( + store.session.installDir, + req.installDir, + ), + }; + } + const config = getProgramConfig(programId); + if (config.ciPreRun) { + // ciPreRun writes to the object it is handed while its setters commit to the store. + const before = store.session; + const draft: WizardSession = { ...before }; + await config.ciPreRun(draft); + store.session = { ...store.session, ...changedKeys(before, draft) }; + store.setDetectionComplete(); + } else { + await store.runReadyHooks(); + } + }, + + async startRun(req: RunRequest) { + const config = getProgramConfig(req.programId); + const live = store.session; + const runSession: WizardSession = { + ...live, + installDir: resolveInstallDir(live.installDir, req.installDir), + frameworkContext: { + ...live.frameworkContext, + ...(req.frameworkContext ?? {}), + }, + skillId: req.skillId ?? config.skillId ?? live.skillId, + programLabel: config.id, + }; + logToFile(`[control] run ${config.id} in ${runSession.installDir}`); + // One session per run: a clean run state and its own stream; credentials and context persist. + store.resetRunState(); + store.setRunPhase(RunPhase.Running); + const stream = deps.runStream?.(config, runSession); + stream?.attach(); + try { + await deps.runAgent(runConfigFor(config), runSession, { + composed: true, + }); + // Headless renderers never flip the phase; settle it so the ledger records a completed run. + if (store.session.runPhase === RunPhase.Running) { + store.setRunPhase(RunPhase.Completed); + } + } catch (err) { + if (store.session.runPhase !== RunPhase.Error) { + store.setOutroData({ + kind: OutroKind.Error, + message: err instanceof Error ? err.message : String(err), + }); + store.setRunPhase(RunPhase.Error); + } + throw err; + } finally { + runCleanups(); + await stream?.shutdown(2000); + } + }, + + shutdown: deps.shutdown, + }; +} diff --git a/src/cli/runners/run-non-interactive.ts b/src/cli/runners/run-non-interactive.ts index 54c479685..80ea2c19b 100644 --- a/src/cli/runners/run-non-interactive.ts +++ b/src/cli/runners/run-non-interactive.ts @@ -16,6 +16,7 @@ import type { Sequence, CloudRegion, ProgramConfig, + WizardSession, WizardStore, TaskStreamPush, OutroData, @@ -23,7 +24,9 @@ import type { } from '@store/types'; import { LoggingUI } from '@tui/console'; import { runConfigFor, getAuditChecks, flowFor } from '@store/programs'; +import { IS_PRODUCTION_BUILD, runtimeEnv } from '@env'; import { resolveNoTelemetry } from './resolve-no-telemetry.js'; +import { createControlHooks } from '../control-hooks.js'; import { join } from 'node:path'; /** @@ -181,6 +184,9 @@ export function runNonInteractive( // dumps locally and pushes nothing. Telemetry consent gates the push only. let store: WizardStore | null = null; let taskStream: TaskStreamPush | null = null; + let runStream: + | ((config: ProgramConfig, runSession: WizardSession) => TaskStreamPush) + | null = null; { const { WizardStore } = await import('@store'); const { HeadlessUI } = await import('@tui/console'); @@ -194,7 +200,9 @@ export function runNonInteractive( const posthogDestination = mode === 'headless' && !session.noTelemetry ? new PostHogDestination({ - getCredentials: () => session.credentials, + // The store forks the session on its first commit; read the live one. + getCredentials: () => + store?.session.credentials ?? session.credentials, onError: (e) => logToFile('[headless task-stream]', e.message), }) : null; @@ -205,22 +213,41 @@ export function runNonInteractive( const headlessStore = new WizardStore(flowFor(config.id).flow); store = headlessStore; + // A controlled run answers the agent's questions over the socket, so the + // ask bridge stays wired despite `ci`. + if (options.controlSocket) session.e2eAsk = true; headlessStore.session = session; - setUI(new HeadlessUI(headlessStore)); - taskStream = new TaskStreamPush({ - store: headlessStore, - programId: config.streamWorkflowId ?? config.id, - destinations, - eventPlanPath: config.eventPlanFile - ? join(session.installDir, config.eventPlanFile) - : undefined, - auditChecks: config.auditLedgerFile - ? () => getAuditChecks(headlessStore.session) - : undefined, - enabled: destinations.length > 0, - }); - taskStream.attach(); - headlessStore.setRunPhase(RunPhase.Running); + if (options.controlSocket) { + const { StoreUI } = await import('@store'); + setUI(new StoreUI(headlessStore)); + } else { + setUI(new HeadlessUI(headlessStore)); + } + const streamFor = ( + runConfig: ProgramConfig, + runSession: WizardSession, + ): TaskStreamPush => + new TaskStreamPush({ + store: headlessStore, + programId: runConfig.streamWorkflowId ?? runConfig.id, + skillId: runSession.skillId ?? undefined, + destinations, + eventPlanPath: runConfig.eventPlanFile + ? join(runSession.installDir, runConfig.eventPlanFile) + : undefined, + auditChecks: runConfig.auditLedgerFile + ? () => getAuditChecks(headlessStore.session) + : undefined, + enabled: destinations.length > 0, + }); + if (options.controlSocket) { + // Every POST /runs is one independent run with its own stream session. + runStream = streamFor; + } else { + taskStream = streamFor(config, session); + taskStream.attach(); + headlessStore.setRunPhase(RunPhase.Running); + } if (fileDestination) { logToFile(`[task-stream] ${mode} dump: ${fileDestination.path}`); } @@ -239,13 +266,56 @@ export function runNonInteractive( }; try { - if (mode === 'ci') { + // An issued gateway bearer replaces the mint for `--ci` and, in dev builds, for a harness-driven headless run. + if ( + mode === 'ci' || + (!IS_PRODUCTION_BUILD && runtimeEnv('WIZARD_CI_GATEWAY_TOKEN_FILE')) + ) { const { configureGatewayFromCIEnvironment } = await import('@agent'); configureGatewayFromCIEnvironment( Number(session.projectId), session.region ?? 'us', ); } + + // Controlled headless: nothing runs until the parent asks. Detection, + // independent runs, and the exit all arrive over the socket. + if (options.controlSocket && store) { + const controlledStore = store; + const { attachControlServer } = await import('@store/control'); + const { runAgent } = await import('@agent'); + const { VERSION } = await import('@store'); + let release: () => void = () => undefined; + const served = new Promise((resolve) => { + release = resolve; + }); + const handle = await attachControlServer(controlledStore, { + socketPath: options.controlSocket as string, + surface: 'headless', + version: VERSION, + program: config.id, + hooks: createControlHooks({ + store: controlledStore, + programId: config.id, + runAgent, + runStream: runStream ?? undefined, + shutdown: () => { + release(); + return Promise.resolve(); + }, + }), + }); + process.once('SIGINT', release); + process.once('SIGTERM', release); + logToFile(`[control] serving ${config.id} on ${handle.socketPath}`); + await served; + process.off('SIGINT', release); + process.off('SIGTERM', release); + await handle.close(); + // Each run shut its own stream; nothing else holds the loop, so the process ends with status 0. + return; + } + if (config.ciPreRun) { await config.ciPreRun(session); } else { diff --git a/src/cli/runners/run-wizard.ts b/src/cli/runners/run-wizard.ts index aabad35df..032e22616 100644 --- a/src/cli/runners/run-wizard.ts +++ b/src/cli/runners/run-wizard.ts @@ -28,6 +28,9 @@ import type { TaskStreamPush as TaskStreamPushClass, } from '@store/types'; import type { TuiHandle } from '@tui/types'; +import type { ControlServerHandle, CloudRegion } from '@store/types'; +import { IS_PRODUCTION_BUILD } from '@env'; +import { createControlHooks } from '../control-hooks.js'; import { resolveNoTelemetry } from './resolve-no-telemetry.js'; import { join } from 'node:path'; @@ -96,6 +99,7 @@ export function runWizard( options: Record, ): void { let tui: TuiHandle | null = null; + let control: ControlServerHandle | null = null; let taskStream: TaskStreamPushClass | null = null; let onSignal: (() => void) | null = null; let exitInProgress = false; @@ -136,7 +140,12 @@ export function runWizard( localMcp: options.localMcp as boolean | undefined, localPosthog: options.localPosthog as boolean | undefined, installDir, - ci: false, + // A controlled TUI (`--ci --control-socket`) authenticates with the API + // key; every other TUI run goes through OAuth. + ci: options.ci === true && Boolean(options.controlSocket), + e2eAsk: options.e2eAsk === true, + controlSocket: options.controlSocket as string | undefined, + region: options.region as CloudRegion | undefined, signup: options.signup as boolean | undefined, apiKey: options.apiKey as string | undefined, projectId: options.projectId as string | undefined, @@ -184,6 +193,7 @@ export function runWizard( } process.exit(130); }; + void control?.close(); const stream = taskStream; if (!stream) { teardown(); @@ -199,8 +209,40 @@ export function runWizard( process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal); + // Dev only: the parent reads state, commits actions, and releases the run + // over the socket. Rolldown folds this branch out of published builds, so + // a shipped TUI never carries the server. + if (!IS_PRODUCTION_BUILD && options.controlSocket) { + const { attachControlServer } = await import('@store/control'); + control = await attachControlServer(activeTui.store, { + socketPath: options.controlSocket as string, + surface: 'tui', + version: WIZARD_VERSION, + program: config.id, + hooks: createControlHooks({ + store: activeTui.store, + programId: config.id, + runAgent: async (...args) => { + const { runAgent } = await import('@agent'); + return runAgent(...args); + }, + shutdown: async () => { + exitInProgress = true; + runCleanups(); + await taskStream?.shutdown(2000); + await control?.close(); + activeTui.unmount(); + process.exit(0); + }, + }), + }); + } + for (;;) { await activeTui.store.runReadyHooks(); + // Gates latch, so the parent may confirm setup and release the run in + // either order. Without a parent nothing waits here. + if (control) await activeTui.store.waitUntil((s) => s.runRequested); // Settle the pre-run screens; `integration-check` is a no-op gate here. await activeTui.store.getGate('intro'); @@ -247,6 +289,14 @@ export function runWizard( await activeTui.store.getGate('health-check'); const skipAgent = config.run == null; + if (session.ci && !skipAgent) { + // API-key sessions carry no OAuth token; the gateway bearer comes from the CI environment. + const { configureGatewayFromCIEnvironment } = await import('@agent'); + configureGatewayFromCIEnvironment( + Number(session.projectId), + session.region ?? 'us', + ); + } const shown = (s: ProgramConfig['steps'][number]) => !s.show || s.show(activeTui.store.session); @@ -307,6 +357,7 @@ export function runWizard( exitInProgress = true; await activeStream.shutdown(2000); + await control?.close(); process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); if (runFailed) await analytics.shutdown('error'); @@ -332,6 +383,7 @@ export function runWizard( // ignore } } + await control?.close(); if (tui) { try { tui.unmount(); diff --git a/src/cli/testing/fake-surfaces.ts b/src/cli/testing/fake-surfaces.ts index 9633e9cc1..062e26b56 100644 --- a/src/cli/testing/fake-surfaces.ts +++ b/src/cli/testing/fake-surfaces.ts @@ -8,6 +8,7 @@ export interface RunAgentCall { programId: string; installDir: string; frameworkContextKeys: string[]; + skillId: string | null; composed: boolean; } @@ -19,6 +20,7 @@ export function fakeRunAgent(): { runAgent: RunAgent; calls: RunAgentCall[] } { programId: config.id, installDir: session.installDir, frameworkContextKeys: Object.keys(session.frameworkContext), + skillId: session.skillId, composed: options.composed ?? false, }); return Promise.resolve(); diff --git a/src/cli/wizard.ts b/src/cli/wizard.ts index aa15d05ee..1a0823686 100644 --- a/src/cli/wizard.ts +++ b/src/cli/wizard.ts @@ -1,7 +1,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import type { Argv } from 'yargs'; -import { IS_PRODUCTION_BUILD } from '@env'; +import { HEADLESS_FLAG, IS_PRODUCTION_BUILD } from '@env'; import { Harness, Sequence, @@ -80,6 +80,14 @@ export const GLOBAL_OPTIONS = { type: 'boolean' as const, hidden: true, }, + // Always declared so the published headless path accepts it; published TUI + // runs refuse it in init(). HTTP/1.1 over the unix socket at this path. + 'control-socket': { + describe: + 'Serve the control API over this unix socket path\nenv: POSTHOG_WIZARD_CONTROL_SOCKET', + type: 'string' as const, + hidden: true, + }, }; export class Wizard { @@ -109,6 +117,15 @@ export class Wizard { type: 'boolean', hidden: true, }) + // Keeps wizard_ask wired in a --ci session so a controlling parent + // answers the agent's questions; see shouldDisableAsk. + .option('e2e-ask', { + default: false, + describe: + 'Answer wizard_ask over the control socket in a --ci run\nenv: POSTHOG_WIZARD_E2E_ASK', + type: 'boolean', + hidden: true, + }) // Runner overrides — dev/test only, same lifecycle as --ci. .option('harness', { describe: @@ -288,6 +305,16 @@ export class Wizard { process.exit(1); } + const controlRefusal = controlFlagRefusal(args, process.env); + if (controlRefusal) { + process.stderr.write(`\n\x1b[1;91m✖ ${controlRefusal}\x1b[0m\n\n`); + emitWizardError({ + code: ErrorCodes.CliFlagUnavailable, + message: controlRefusal, + }); + process.exit(1); + } + // `--local-mcp` used to be declared unconditionally, so published builds // accepted it and quietly aimed the run at localhost. Reject explicitly. const argvHasLocalTarget = args.some((a) => @@ -316,6 +343,37 @@ export class Wizard { } } +export const CONTROL_SOCKET_UNAVAILABLE = + '--control-socket is only available with the experimental headless flag in published builds.'; +export const E2E_ASK_UNAVAILABLE = + '--e2e-ask is not available in published builds.'; + +const hasFlag = (args: readonly string[], flag: string): boolean => + args.some( + (a) => + a === `--${flag}` || a === `--no-${flag}` || a.startsWith(`--${flag}=`), + ); +const hasEnv = (env: NodeJS.ProcessEnv, key: string): boolean => + env[key] != null && env[key] !== ''; + +/** + * Published builds: the control socket ships for headless runs only, and + * `--e2e-ask` never ships. Returns the refusal to print, or null to proceed. + */ +export function controlFlagRefusal( + args: readonly string[], + env: NodeJS.ProcessEnv, +): string | null { + const wantsControl = + hasFlag(args, 'control-socket') || + hasEnv(env, 'POSTHOG_WIZARD_CONTROL_SOCKET'); + if (wantsControl && !hasFlag(args, HEADLESS_FLAG)) + return CONTROL_SOCKET_UNAVAILABLE; + if (hasFlag(args, 'e2e-ask') || hasEnv(env, 'POSTHOG_WIZARD_E2E_ASK')) + return E2E_ASK_UNAVAILABLE; + return null; +} + /** Excludes bare `local`: `wizard mcp add --local` stays available in prod. */ const LOCAL_TARGET_FLAGS = [ 'local-dev', diff --git a/src/store/README.md b/src/store/README.md index 1f6b8c6ba..92a66a7e9 100644 --- a/src/store/README.md +++ b/src/store/README.md @@ -12,6 +12,16 @@ Render-agnostic state and the contract between the agent and whatever renders. - `tools/`: wizard tool behavior shared by every harness facade. - `detection/`, `frameworks`, `services/`, `security/`, `task-stream/`, `shared/`. +- `control/`: the control API. An HTTP/1.1 server over a unix socket that + mirrors one store. `GET /state` is the committed session whitelist + (`CONTROL_SESSION_KEYS`, credentials as a flag), the run atoms, and the + actions legal on the current screen; `?wait=&since=` blocks on the store + version. `POST /actions/` is one store setter; `POST /run` is + `requestRun`. `POST /credentials`, `POST /detect`, `POST /runs` (headless + surface), and `POST /shutdown` call `ControlHooks` the cli implements, because + the store never authenticates or runs agents. Generic actions live in + `control/actions.ts`; a program adds its own through + `FlowStep.controlActions`. ## Never contains @@ -25,6 +35,9 @@ Ink, console output, or any import of `@agent`, `@tui`, or `@cli`. - `index.ts`: runtime API. `types.ts`: every type another surface consumes. - `programs/index.ts`: program registry and definitions. +- `control/index.ts`: the control server and client. Loaded only through a + dynamic import from the two cli runners, so a published TUI never carries it; + `scripts/smoke-test.sh` audits the built chunks. - Agent implementations arrive by injection: `setUI`, `setDetectionAgent`, `setMcpPromptRunner`. diff --git a/src/store/control/__tests__/actions.test.ts b/src/store/control/__tests__/actions.test.ts new file mode 100644 index 000000000..3c2027978 --- /dev/null +++ b/src/store/control/__tests__/actions.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it } from 'vitest'; +import { ERROR_TRACKING_PROJECT_PATH_KEY } from '../../programs/error-tracking/detect-agentic.js'; +import { SOURCE_MAPS_CONTEXT_KEYS } from '../../programs/error-tracking-upload-source-maps/detect.js'; +import { flowFor } from '../../programs/flow-for.js'; +import { Program, PROGRAM_REGISTRY } from '../../programs/program-registry.js'; +import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '../../programs/self-driving/detect.js'; +import { + buildSession, + McpOutcome, + ScanConsent, +} from '../../session/wizard-session.js'; +import type { Flow } from '../../state/flow.js'; +import { Interrupt } from '../../state/interrupts.js'; +import { WizardStore } from '../../state/store.js'; +import { createControlledStore, createTestStore } from '../../testing/index.js'; +import { setUI } from '../../ui/index.js'; +import { StoreUI } from '../../ui/store-ui.js'; +import { + actionsFor, + GENERIC_ACTIONS, + NO_ACTION_SCREENS, + UnknownActionError, +} from '../actions.js'; +import { BadParamError, MissingParamError } from '../params.js'; +import { ControlDriver } from '../driver.js'; + +function storeFor(program = Program.PostHogIntegration): WizardStore { + const store = createTestStore(program); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: '/tmp/control-actions', + ci: true, + }); + return store; +} + +function apply(store: WizardStore, screen: string, id: string, params = {}) { + const action = actionsFor(store.flow, screen).find((a) => a.id === id); + if (!action) throw new Error(`no ${id} on ${screen}`); + action.apply(store, params); +} + +describe('generic actions', () => { + it('every intro confirms setup', () => { + for (const screen of ['intro', 'audit-intro', 'self-driving-intro']) { + const store = storeFor(); + apply(store, screen, 'confirm_setup'); + expect(store.session.setupConfirmed).toBe(true); + } + }); + + it.each([ + [ + 'health-check', + 'dismiss_outage', + {}, + (s: WizardStore) => s.session.outageDismissed === true, + ], + [ + 'setup', + 'choose', + { key: 'router', value: 'app' }, + (s: WizardStore) => s.session.frameworkContext.router === 'app', + ], + [ + 'outro', + 'dismiss_outro', + {}, + (s: WizardStore) => s.session.outroDismissed, + ], + [ + 'audit-outro', + 'dismiss_outro', + {}, + (s: WizardStore) => s.session.outroDismissed, + ], + [ + 'source-maps-outro', + 'dismiss_outro', + {}, + (s: WizardStore) => s.session.outroDismissed, + ], + [ + 'mint-failure', + 'continue_setup', + {}, + (s: WizardStore) => s.session.mintHandoff === 'continue', + ], + [ + 'mint-failure', + 'dismiss_outro', + {}, + (s: WizardStore) => s.session.mintHandoff === 'exit', + ], + [ + 'mcp', + 'set_mcp_outcome', + { outcome: 'installed', clients: ['cursor'] }, + (s: WizardStore) => + s.session.mcpComplete && + s.session.mcpOutcome === McpOutcome.Installed && + s.session.mcpInstalledClients[0] === 'cursor', + ], + [ + 'mcp-add', + 'set_mcp_outcome', + { outcome: 'skipped' }, + (s: WizardStore) => s.session.mcpOutcome === McpOutcome.Skipped, + ], + [ + 'mcp-remove', + 'set_mcp_outcome', + {}, + (s: WizardStore) => s.session.mcpOutcome === McpOutcome.Skipped, + ], + [ + 'mcp-suggested-prompts', + 'dismiss', + {}, + (s: WizardStore) => s.session.mcpSuggestedPromptsDismissed, + ], + [ + 'slack-connect', + 'dismiss_slack', + {}, + (s: WizardStore) => s.session.slackStepDismissed, + ], + [ + 'slack-connect', + 'set_slack_connected', + { connected: true }, + (s: WizardStore) => s.session.slackConnected === true, + ], + [ + 'keep-skills', + 'keep_skills', + { kept: false }, + (s: WizardStore) => s.session.skillsComplete, + ], + ] as const)( + '%s / %s commits through its setter', + (screen, id, params, check) => { + const store = storeFor(); + apply(store, screen, id, params); + expect(check(store)).toBe(true); + }, + ); + + it('answers and cancels a pending wizard_ask', async () => { + const store = storeFor(); + const asked = store.requestQuestion({ + id: 'q', + subject: 'test', + questions: [{ id: 'color', question: 'Which?', kind: 'text' }], + } as never); + apply(store, Interrupt.WizardAsk, 'answer_question', { + answers: { color: 'red' }, + }); + await expect(asked).resolves.toEqual({ color: 'red' }); + + const cancelled = store.requestQuestion({ + id: 'q2', + subject: 'test', + questions: [{ id: 'size', question: 'How big?', kind: 'text' }], + } as never); + apply(store, Interrupt.WizardAsk, 'cancel_question'); + await expect(cancelled).resolves.toEqual({ size: '__cancelled__' }); + }); + + it('resolves a task notice, defaulting to keep', async () => { + const store = storeFor(); + const kept = store.showTaskNotice({ + title: 't', + items: [], + prompt: 'p', + } as never); + apply(store, Interrupt.TaskNotice, 'resolve_notice'); + await expect(kept).resolves.toBe(true); + const skipped = store.showTaskNotice({ + title: 't', + items: [], + prompt: 'p', + } as never); + apply(store, Interrupt.TaskNotice, 'resolve_notice', { keep: false }); + await expect(skipped).resolves.toBe(false); + }); + + it('resolves the port conflict and the manual auth code overlays', async () => { + const store = storeFor(); + void store.showPortConflict({ + command: 'x', + pid: '1', + port: 8010, + user: 'u', + }); + expect(store.hasInterrupt).toBe(true); + apply(store, Interrupt.PortConflict, 'resolve_port_conflict'); + expect(store.hasInterrupt).toBe(false); + + const code = store.waitForManualAuthCode(); + store.showManualAuthCode(); + apply(store, Interrupt.ManualAuthCode, 'submit_auth_code', { code: 'abc' }); + await expect(code).resolves.toBe('abc'); + store.showManualAuthCode(); + apply(store, Interrupt.ManualAuthCode, 'dismiss_auth_code'); + expect(store.hasInterrupt).toBe(false); + }); + + it('backs up and fixes a settings override through the store callback', async () => { + const store = storeFor(); + const fix = vi.fn(() => true); + const settled = store.showSettingsOverride( + [ + { + source: 'project', + writable: true, + keys: ['apiKeyHelper'], + path: '/p', + }, + ] as never, + fix, + ); + apply(store, Interrupt.SettingsOverride, 'backup_and_fix'); + expect(fix).toHaveBeenCalledTimes(1); + await expect(settled).resolves.toBeUndefined(); + }); + + it('rejects a missing required param', () => { + const store = storeFor(); + expect(() => apply(store, 'setup', 'choose', { key: 'router' })).toThrow( + MissingParamError, + ); + expect(() => + apply(store, Interrupt.ManualAuthCode, 'submit_auth_code', {}), + ).toThrow(MissingParamError); + }); +}); + +describe('program actions', () => { + it('self-driving picks the integration target and answers its check', () => { + const store = storeFor(Program.SelfDriving); + apply(store, 'self-driving-integration-check', 'set_integrate', { + integrate: true, + }); + expect(store.session.integrate).toBe(true); + apply(store, 'self-driving-integration-detect', 'pick_integration_target', { + path: 'apps/web', + integration: 'nextjs', + }); + expect( + store.session.frameworkContext[SELF_DRIVING_INTEGRATE_PATH_KEY], + ).toBe('apps/web'); + expect(store.session.integration).toBe('nextjs'); + apply(store, 'self-driving-handoff', 'confirm_self_driving_handoff'); + expect(store.session.selfDrivingHandoffConfirmed).toBe(true); + apply(store, 'self-driving-github', 'set_github_connected', { + connected: true, + }); + expect(store.session.githubConnected).toBe(true); + }); + + it('self-driving declines GitHub with the required-connection outro', () => { + const store = storeFor(Program.SelfDriving); + apply(store, 'self-driving-github', 'decline_github'); + expect(store.session.githubDeclined).toBe(true); + expect(store.session.outroData?.kind).toBe('cancel'); + }); + + it('error-tracking picks the project the run is scoped to', () => { + const store = storeFor(Program.ErrorTracking); + apply(store, 'error-tracking-detect', 'pick_integration_target', { + path: '.', + integration: 'django', + }); + expect( + store.session.frameworkContext[ERROR_TRACKING_PROJECT_PATH_KEY], + ).toBe('.'); + expect(store.session.integration).toBe('django'); + }); + + it('rejects an unknown framework id', () => { + const store = storeFor(Program.ErrorTracking); + expect(() => + apply(store, 'error-tracking-detect', 'pick_integration_target', { + path: '.', + integration: 'cobol', + }), + ).toThrow(BadParamError); + }); + + it('source-maps commits the pick the detect screen would', () => { + const store = storeFor(Program.ErrorTrackingUploadSourceMaps); + apply(store, 'source-maps-detect', 'pick_source_maps_project', { + variant: 'node', + path: '.', + }); + const ctx = store.session.frameworkContext; + expect(ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedVariant]).toBe('node'); + expect(ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedPath]).toBe('.'); + expect(typeof ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName]).toBe( + 'string', + ); + expect(() => + apply(store, 'source-maps-detect', 'pick_source_maps_project', { + variant: 'node', + }), + ).toThrow(MissingParamError); + }); + + it('a program action wins over a generic one with the same id', () => { + const own = { + id: 'confirm_setup', + description: 'own', + apply: () => undefined, + }; + const flow: Flow = { + programId: Program.PostHogIntegration, + skillId: null, + steps: [ + { + id: 'intro', + label: 'Intro', + screenId: 'intro', + controlActions: [own], + }, + ], + }; + const actions = actionsFor(flow, 'intro'); + expect(actions.map((a) => a.id)).toEqual(['confirm_setup']); + expect(actions[0]).toBe(own); + }); +}); + +describe('param validation', () => { + it('confirm_setup on the default intro follows the sharing toggle like Enter does', () => { + // An interactive session starts undecided; a ci session is granted up front. + const undecided = createControlledStore(undefined, { ci: false }); + expect(undecided.session.scanConsent).toBe(ScanConsent.Undecided); + apply(undecided, 'intro', 'confirm_setup'); + expect(undecided.session.scanConsent).toBe(ScanConsent.Granted); + expect(undecided.session.setupConfirmed).toBe(true); + + const declined = createControlledStore(undefined, { ci: false }); + declined.declineSharing(); + apply(declined, 'intro', 'confirm_setup'); + expect(declined.session.scanConsent).toBe(ScanConsent.Declined); + + const explicit = storeFor(); + apply(explicit, 'intro', 'confirm_setup', { share: false }); + expect(explicit.session.scanConsent).toBe(ScanConsent.Declined); + expect(() => + apply(storeFor(), 'intro', 'confirm_setup', { share: 'yes' }), + ).toThrow(BadParamError); + + const other = createControlledStore(Program.Audit, { ci: false }); + apply(other, 'audit-intro', 'confirm_setup'); + expect(other.session.scanConsent).toBe(ScanConsent.Undecided); + expect(other.session.setupConfirmed).toBe(true); + }); + + it('passes booleans through and rejects anything else', () => { + const store = storeFor(); + const skills = vi.spyOn(store, 'setSkillsComplete'); + apply(store, 'keep-skills', 'keep_skills', { kept: false }); + expect(skills).toHaveBeenCalledWith(false); + apply(store, 'slack-connect', 'set_slack_connected', { connected: false }); + expect(store.session.slackConnected).toBe(false); + expect(() => + apply(store, 'slack-connect', 'set_slack_connected', { + connected: 'false', + }), + ).toThrow(BadParamError); + expect(() => + apply(store, 'keep-skills', 'keep_skills', { kept: 1 }), + ).toThrow(BadParamError); + }); + + it('rejects an unknown MCP outcome and a non-string client list', () => { + const store = storeFor(); + expect(() => + apply(store, 'mcp', 'set_mcp_outcome', { outcome: 'maybe' }), + ).toThrow(BadParamError); + expect(() => + apply(store, 'mcp', 'set_mcp_outcome', { clients: [1] }), + ).toThrow(BadParamError); + expect(store.session.mcpComplete).toBe(false); + }); + + it('requires an answers object and a non-empty string value', () => { + const store = storeFor(); + void store.requestQuestion({ + id: 'q', + subject: 'test', + questions: [{ id: 'color', question: 'Which?', kind: 'text' }], + } as never); + expect(() => + apply(store, Interrupt.WizardAsk, 'answer_question', { answers: 'red' }), + ).toThrow(MissingParamError); + expect(() => + apply(store, 'setup', 'choose', { key: 'router', value: '' }), + ).toThrow(MissingParamError); + }); +}); + +describe('coverage', () => { + it('every flow key and interrupt is actionable or explicitly no-action', () => { + const missing: string[] = []; + for (const config of PROGRAM_REGISTRY) { + const { flow } = flowFor(config.id); + for (const step of flow.steps) { + if (!step.screenId) continue; + if ( + actionsFor(flow, step.screenId).length === 0 && + !NO_ACTION_SCREENS.has(step.screenId) + ) { + missing.push(`${config.id}:${step.screenId}`); + } + } + } + for (const interrupt of Object.values(Interrupt)) { + if (!(interrupt in GENERIC_ACTIONS) && !NO_ACTION_SCREENS.has(interrupt)) + missing.push(interrupt); + } + expect(missing).toEqual([]); + }); + + it('no screen is both actionable and no-action', () => { + const both = [...NO_ACTION_SCREENS].filter((s) => s in GENERIC_ACTIONS); + expect(both).toEqual([]); + }); + + it('the driver rejects an action the current screen does not offer', () => { + const store = storeFor(); + const driver = new ControlDriver(store); + expect(() => driver.performAction('keep_skills')).toThrow( + UnknownActionError, + ); + expect(driver.performAction('confirm_setup').session.setupConfirmed).toBe( + true, + ); + }); +}); diff --git a/src/store/control/__tests__/server.test.ts b/src/store/control/__tests__/server.test.ts new file mode 100644 index 000000000..7db41e416 --- /dev/null +++ b/src/store/control/__tests__/server.test.ts @@ -0,0 +1,536 @@ +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { HostResolution } from '../../host-resolution.js'; +import { Program } from '../../programs/program-registry.js'; +import { OutroKind, RunPhase } from '../../session/wizard-session.js'; +import { createControlledStore, expectNoSecrets } from '../../testing/index.js'; +import { ControlClient, ControlClientError } from '../client.js'; +import { + attachControlServer, + MAX_BODY_BYTES, + type ControlServerHandle, +} from '../server.js'; +import type { ControlHooks, ControlSurface } from '../types.js'; + +const handles: ControlServerHandle[] = []; +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(handles.splice(0).map((h) => h.close())); + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function socketDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wz-ctl-')); + dirs.push(dir); + return dir; +} + +/** A socket file whose server died without unlinking: what a crashed wizard leaves. */ +async function staleSocket(socketPath: string): Promise { + const child = spawn( + process.execPath, + [ + '-e', + "require('net').createServer().listen(process.argv[1], () => process.stdout.write('ok'))", + socketPath, + ], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ); + await new Promise((resolve) => + child.stdout.once('data', () => resolve()), + ); + const exited = new Promise((resolve) => + child.once('exit', () => resolve()), + ); + child.kill('SIGKILL'); + await exited; +} + +const US = HostResolution.fromApiHost('https://us.posthog.com'); + +async function serve( + surface: ControlSurface = 'headless', + program = Program.PostHogIntegration, + options: { apiKey?: string | null; runMs?: number } = {}, +) { + const store = createControlledStore(program, { + installDir: '/tmp/control-server', + ...(options.apiKey === null + ? {} + : { apiKey: options.apiKey ?? 'phx_test_key' }), + }); + const hooks: ControlHooks = { + setCredentials: vi.fn(() => { + store.setCredentials({ + accessToken: 'phx_SECRET', + projectApiKey: 'phc_TOKEN', + host: US, + projectId: 7, + }); + return Promise.resolve(); + }), + detect: vi.fn(() => { + store.setDetectionComplete(); + return Promise.resolve(); + }), + startRun: vi.fn(async () => { + store.setRunPhase(RunPhase.Running); + await new Promise((r) => setTimeout(r, options.runMs ?? 30)); + store.setOutroData({ kind: OutroKind.Success, message: 'done' }); + store.setRunPhase(RunPhase.Completed); + }), + shutdown: vi.fn(() => Promise.resolve()), + }; + const socketPath = path.join(socketDir(), 'c.sock'); + const handle = await attachControlServer(store, { + socketPath, + surface, + hooks, + version: '0.0.0-test', + program, + }); + handles.push(handle); + return { + store, + hooks, + handle, + socketPath, + client: new ControlClient(socketPath), + }; +} + +/** A raw request, for the malformed cases the client cannot produce. */ +function raw( + socketPath: string, + method: string, + reqPath: string, + body?: string, + headers: Record = {}, +) { + return new Promise<{ status: number; json: Record }>( + (resolve, reject) => { + const req = http.request( + { socketPath, method, path: reqPath, headers }, + (res) => { + let text = ''; + res.on('data', (c: Buffer) => (text += c.toString())); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + json: JSON.parse(text) as Record, + }), + ); + }, + ); + req.on('error', reject); + if (body !== undefined) req.write(body); + req.end(); + }, + ); +} + +const JSON_HEADERS = { 'content-type': 'application/json' }; +const settle = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('control server', () => { + it('answers health with the surface and program', async () => { + const { client } = await serve('tui', Program.Audit); + expect(await client.health()).toEqual({ + ok: true, + surface: 'tui', + program: Program.Audit, + pid: process.pid, + version: '0.0.0-test', + }); + }); + + it('serves the state and applies actions through their setters', async () => { + const { client, store } = await serve(); + const before = await client.state(); + expect(before.currentScreen).toBe('intro'); + expect(before.actions.map((a) => a.id)).toEqual(['confirm_setup']); + const after = await client.performAction('confirm_setup'); + expect(after.session.setupConfirmed).toBe(true); + expect(after.version).toBeGreaterThan(before.version); + expect(store.session.setupConfirmed).toBe(true); + }); + + it('maps bad input to 400 and unknown routes to 404', async () => { + const { client, socketPath } = await serve(); + await expect(client.performAction('keep_skills')).rejects.toMatchObject({ + status: 400, + }); + expect((await raw(socketPath, 'GET', '/nope')).status).toBe(404); + expect( + (await raw(socketPath, 'POST', '/nope', '{}', JSON_HEADERS)).status, + ).toBe(404); + expect( + ( + await raw( + socketPath, + 'POST', + '/actions/confirm_setup', + '{', + JSON_HEADERS, + ) + ).status, + ).toBe(400); + expect( + (await raw(socketPath, 'POST', '/actions/%zz', '{}', JSON_HEADERS)) + .status, + ).toBe(400); + expect( + ( + await raw( + socketPath, + 'POST', + '/actions/confirm_setup', + '{"params":[]}', + JSON_HEADERS, + ) + ).status, + ).toBe(400); + expect( + (await raw(socketPath, 'POST', '/runs', '{}', JSON_HEADERS)).status, + ).toBe(400); + }); + + it('rejects a missing param with 400 and the param name', async () => { + const { client, store } = await serve( + 'headless', + Program.ErrorTrackingUploadSourceMaps, + ); + store.completeSetup(); + store.setCredentials({ + accessToken: 't', + projectApiKey: 'k', + host: US, + projectId: 1, + }); + expect((await client.state()).currentScreen).toBe('source-maps-detect'); + await expect( + client.performAction('pick_source_maps_project', { variant: 'node' }), + ).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining('"path"'), + }); + }); + + it('answers 413 past the body cap, 200 at it, and 415 for non-JSON', async () => { + const { socketPath } = await serve(); + const wrap = (pad: string) => JSON.stringify({ params: { pad } }); + const overhead = wrap('').length; + const atLimit = wrap('x'.repeat(MAX_BODY_BYTES - overhead)); + expect(Buffer.byteLength(atLimit)).toBe(MAX_BODY_BYTES); + expect( + ( + await raw( + socketPath, + 'POST', + '/actions/confirm_setup', + atLimit, + JSON_HEADERS, + ) + ).status, + ).toBe(200); + const over = await raw( + socketPath, + 'POST', + '/actions/confirm_setup', + wrap('x'.repeat(MAX_BODY_BYTES - overhead + 1)), + JSON_HEADERS, + ); + expect(over.status).toBe(413); + expect(over.json.error).toContain(String(MAX_BODY_BYTES)); + expect( + ( + await raw(socketPath, 'POST', '/actions/confirm_setup', 'x', { + 'content-type': 'text/plain', + }) + ).status, + ).toBe(415); + }); + + it('long polls until the next commit, and returns on timeout', async () => { + const { client, store } = await serve(); + const since = (await client.state()).version; + const pending = client.waitForChange(since, 5000); + setTimeout(() => store.completeSetup(), 20); + const state = await pending; + expect(state.version).toBeGreaterThan(since); + expect(state.session.setupConfirmed).toBe(true); + + const started = Date.now(); + const timedOut = await client.waitForChange(state.version, 60); + expect(timedOut.version).toBe(state.version); + expect(Date.now() - started).toBeGreaterThanOrEqual(50); + }); + + it('reads immediately for a non-numeric wait and times out for a version ahead of the store', async () => { + const { client, socketPath } = await serve(); + const current = (await client.state()).version; + const started = Date.now(); + const immediate = await raw(socketPath, 'GET', '/state?wait=abc&since=0'); + expect(immediate.status).toBe(200); + expect(Date.now() - started).toBeLessThan(500); + const ahead = await client.waitForChange(current + 100, 60); + expect(ahead.version).toBe(current); + }); + + it('wakes every concurrent poller on one commit', async () => { + const { client, store } = await serve(); + const since = (await client.state()).version; + const a = client.waitForChange(since, 5000); + const b = client.waitForChange(since, 5000); + setTimeout(() => store.completeSetup(), 20); + const [sa, sb] = await Promise.all([a, b]); + expect(sa.version).toBe(sb.version); + expect(sa.version).toBeGreaterThan(since); + }); + + it('answers pending long polls when it closes instead of holding the process', async () => { + const { client, handle } = await serve(); + const since = (await client.state()).version; + const started = Date.now(); + const pending = client.waitForChange(since, 5000); + await settle(20); + await handle.close(); + const state = await pending; + expect(state.version).toBe(since); + expect(Date.now() - started).toBeLessThan(1500); + }); + + it('owns its socket: 0600, a dead socket replaced, a live one refused, a plain file refused, unlinked on close', async () => { + const dir = socketDir(); + const stale = path.join(dir, 'stale.sock'); + await staleSocket(stale); + expect(fs.existsSync(stale)).toBe(true); + const store = createControlledStore(); + const hooks = { + setCredentials: vi.fn(), + detect: vi.fn(), + startRun: vi.fn(), + shutdown: vi.fn(), + } as unknown as ControlHooks; + const attach = (socketPath: string) => + attachControlServer(store, { + socketPath, + surface: 'tui', + hooks, + version: 't', + program: 'p', + }); + const handle = await attach(stale); + expect(fs.statSync(stale).mode & 0o777).toBe(0o600); + expect((await new ControlClient(stale).health()).ok).toBe(true); + + const live = path.join(dir, 'live.sock'); + const other = net.createServer().listen(live); + await new Promise((r) => other.once('listening', r)); + await expect(attach(live)).rejects.toThrow(/already served/); + other.close(); + + const file = path.join(dir, 'not-a-socket'); + fs.writeFileSync(file, 'keep me'); + await expect(attach(file)).rejects.toThrow(/not a socket/); + expect(fs.readFileSync(file, 'utf8')).toBe('keep me'); + + await handle.close(); + expect(fs.existsSync(stale)).toBe(false); + }); + + it('runs one independent run at a time and records the resolved request in the ledger', async () => { + const { client, hooks, store } = await serve('headless'); + const run = await client.startRun({ + programId: Program.PostHogIntegration, + installDir: 'apps/web', + frameworkContext: { picked: 'yes' }, + skillId: 'nextjs', + }); + const absolute = path.join(store.session.installDir, 'apps/web'); + expect(run).toMatchObject({ + status: 'running', + programId: Program.PostHogIntegration, + installDir: absolute, + }); + expect(hooks.startRun).toHaveBeenCalledWith({ + programId: Program.PostHogIntegration, + installDir: absolute, + frameworkContext: { picked: 'yes' }, + skillId: 'nextjs', + }); + await expect( + client.startRun({ programId: Program.PostHogIntegration }), + ).rejects.toMatchObject({ status: 409 }); + expect((await client.state()).session.runPhase).toBe(RunPhase.Running); + + await settle(80); + const runs = await client.runs(); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + runId: run.runId, + status: 'done', + error: null, + }); + expect(runs[0].result?.session).toMatchObject({ + runPhase: RunPhase.Completed, + outroData: { kind: OutroKind.Success, message: 'done' }, + }); + expect(runs[0].finishedAt).not.toBeNull(); + expect((await client.state()).session.runPhase).toBe(RunPhase.Completed); + }); + + it('drops nothing silently: a non-object frameworkContext is a 400', async () => { + const { socketPath, hooks } = await serve('headless'); + const r = await raw( + socketPath, + 'POST', + '/runs', + JSON.stringify({ + programId: Program.PostHogIntegration, + frameworkContext: [], + }), + JSON_HEADERS, + ); + expect(r.status).toBe(400); + expect(hooks.startRun).not.toHaveBeenCalled(); + }); + + it('keeps runs in start order and hands out copies', async () => { + const { client, store } = await serve( + 'headless', + Program.PostHogIntegration, + { + runMs: 5, + }, + ); + await client.startRun({ programId: Program.Metrics }); + await settle(40); + await client.startRun({ programId: Program.Audit }); + await settle(40); + const runs = await client.runs(); + expect(runs.map((r) => [r.programId, r.status])).toEqual([ + [Program.Metrics, 'done'], + [Program.Audit, 'done'], + ]); + expect(runs.every((r) => r.installDir === store.session.installDir)).toBe( + true, + ); + runs[0].status = 'failed'; + expect((await client.runs())[0].status).toBe('done'); + }); + + it('records a failed run with its error and the state at failure', async () => { + const { client, hooks } = await serve('headless'); + vi.mocked(hooks.startRun).mockRejectedValueOnce( + new Error('gateway refused'), + ); + await client.startRun({ programId: Program.PostHogIntegration }); + await settle(20); + const [record] = await client.runs(); + expect(record).toMatchObject({ + status: 'failed', + error: 'gateway refused', + }); + expect(record.result?.currentScreen).toBe('intro'); + }); + + it('refuses detection, credentials, and shutdown while a run is in flight', async () => { + const { client, hooks } = await serve( + 'headless', + Program.PostHogIntegration, + { + runMs: 150, + }, + ); + await client.startRun({ programId: Program.PostHogIntegration }); + await expect(client.detect({})).rejects.toMatchObject({ status: 409 }); + await expect(client.setCredentials()).rejects.toMatchObject({ + status: 409, + }); + await expect(client.shutdown()).rejects.toMatchObject({ status: 409 }); + expect(hooks.detect).not.toHaveBeenCalled(); + expect(hooks.setCredentials).not.toHaveBeenCalled(); + expect(hooks.shutdown).not.toHaveBeenCalled(); + await settle(200); + await client.shutdown(); + await settle(10); + expect(hooks.shutdown).toHaveBeenCalledTimes(1); + }); + + it('treats a running TUI phase as in flight too', async () => { + const { client, store, hooks } = await serve('tui'); + store.setRunPhase(RunPhase.Running); + await expect(client.shutdown()).rejects.toMatchObject({ status: 409 }); + expect(hooks.shutdown).not.toHaveBeenCalled(); + }); + + it('serves each surface its own hooks and answers 501 for the other', async () => { + const headless = await serve('headless'); + await expect(headless.client.armRun()).rejects.toMatchObject({ + status: 501, + }); + expect( + (await headless.client.detect({ programId: Program.Audit })).session + .detectionComplete, + ).toBe(true); + expect(headless.hooks.detect).toHaveBeenCalledWith({ + programId: Program.Audit, + }); + + const tui = await serve('tui'); + await expect( + tui.client.startRun({ programId: Program.Audit }), + ).rejects.toMatchObject({ status: 501 }); + await expect(tui.client.detect()).rejects.toMatchObject({ status: 501 }); + expect((await tui.client.armRun()).session.runRequested).toBe(true); + expect(tui.store.session.runRequested).toBe(true); + expect((await tui.client.armRun()).session.runRequested).toBe(true); + }); + + it('resolves a relative detect install dir against the live one', async () => { + const { client, hooks, store } = await serve('headless'); + await client.detect({ installDir: 'packages/api' }); + expect(hooks.detect).toHaveBeenCalledWith({ + installDir: path.join(store.session.installDir, 'packages/api'), + }); + }); + + it('commits credentials through the hook, leaks nothing, and shuts down once', async () => { + const { client, hooks } = await serve('tui'); + const state = await client.setCredentials(); + expect(hooks.setCredentials).toHaveBeenCalledTimes(1); + expect(state.session).toMatchObject({ hasCredentials: true, projectId: 7 }); + expectNoSecrets(JSON.stringify(state)); + + await client.shutdown(); + await client.shutdown(); + await settle(10); + expect(hooks.shutdown).toHaveBeenCalledTimes(1); + }); + + it('refuses to resolve credentials for a session without an API key', async () => { + const { client, hooks } = await serve('tui', Program.PostHogIntegration, { + apiKey: null, + }); + await expect(client.setCredentials()).rejects.toMatchObject({ + status: 400, + }); + expect(hooks.setCredentials).not.toHaveBeenCalled(); + }); + + it('surfaces a hook failure as 500 with its message', async () => { + const { client, hooks } = await serve('tui'); + vi.mocked(hooks.setCredentials).mockRejectedValueOnce(new Error('no key')); + await expect(client.setCredentials()).rejects.toEqual( + new ControlClientError(500, 'no key'), + ); + }); +}); diff --git a/src/store/control/__tests__/state.test.ts b/src/store/control/__tests__/state.test.ts new file mode 100644 index 000000000..140f3d453 --- /dev/null +++ b/src/store/control/__tests__/state.test.ts @@ -0,0 +1,206 @@ +import { HostResolution } from '../../host-resolution.js'; +import { OutroKind, RunPhase } from '../../session/wizard-session.js'; +import { createControlledStore, expectNoSecrets } from '../../testing/index.js'; +import { actionsFor, toActionView } from '../actions.js'; +import { + CONTROL_SESSION_KEYS, + isSecretKey, + projectState, + redactContext, +} from '../state.js'; + +const US = HostResolution.fromApiHost('https://us.posthog.com'); + +describe('the control state projection', () => { + it('never carries a credential, an api key, a user, or a vaulted answer', () => { + const store = createControlledStore(undefined, { + apiKey: 'phx_PERSONAL_SECRET', + }); + store.setCredentials({ + accessToken: 'phx_ACCESS_SECRET', + projectApiKey: 'phc_PROJECT_TOKEN', + host: US, + projectId: 42, + }); + store.setApiUser({ + email: 'someone@example.com', + uuid: 'user-uuid', + } as never); + store.setFrameworkContext('router', 'app'); + store.setFrameworkContext('upload-api-key', 'phs_UPLOAD_SECRET'); + store.setFrameworkContext( + 'answer', + 'secret:0b7c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f', + ); + + const state = projectState(store); + expectNoSecrets(JSON.stringify(state), [ + 'someone@example.com', + 'user-uuid', + ]); + expect(state.session.hasCredentials).toBe(true); + expect(state.session.projectId).toBe(42); + expect(state.session.frameworkContext).toEqual({ + router: 'app', + 'upload-api-key': '[redacted]', + answer: '[secret-ref]', + }); + }); + + it('projects exactly the listed session keys plus the two credential facts', () => { + const state = projectState(createControlledStore()); + expect(Object.keys(state.session).sort()).toEqual( + [...CONTROL_SESSION_KEYS, 'hasCredentials', 'projectId'].sort(), + ); + expect(Object.keys(state).sort()).toEqual( + [ + 'actions', + 'currentScreen', + 'eventPlan', + 'handoffText', + 'session', + 'setupQuestions', + 'statusMessages', + 'tasks', + 'version', + ].sort(), + ); + }); + + it('mirrors the store: the listed session fields, the run atoms, the screen', () => { + const store = createControlledStore(); + store.completeSetup(); + store.setTasks([ + { label: 'Install SDK', status: 'in_progress', done: false } as never, + ]); + store.pushStatus('installing the SDK'); + store.setEventPlan([{ name: 'signup', description: 'a user signed up' }]); + store.setHandoffText('run this prompt'); + store.setDashboardUrl('https://us.posthog.com/project/1/dashboard/2'); + store.setOutroData({ + kind: OutroKind.Success, + message: 'ok', + body: 'long body copy', + }); + store.setRunPhase(RunPhase.Completed); + + const state = projectState(store); + for (const key of CONTROL_SESSION_KEYS) { + if (key === 'frameworkContext') continue; + expect(state.session[key], key).toEqual(store.session[key]); + } + expect(state.currentScreen).toBe(store.currentScreen); + expect(state.version).toBe(store.getVersion()); + expect(state.tasks).toEqual([ + { label: 'Install SDK', status: 'in_progress', done: false }, + ]); + expect(state.statusMessages).toEqual(['installing the SDK']); + expect(state.eventPlan).toEqual([ + { name: 'signup', description: 'a user signed up' }, + ]); + expect(state.handoffText).toBe('run this prompt'); + expect(state.actions).toEqual( + actionsFor(store.flow, store.currentScreen).map(toActionView), + ); + expect(JSON.stringify(state)).not.toContain('"apply"'); + }); + + it('offers the screen actions without their closures and with their params', () => { + const store = createControlledStore(); + expect(projectState(store).currentScreen).toBe('intro'); + expect(projectState(store).actions).toMatchObject([ + { id: 'confirm_setup', params: { share: 'boolean (optional)' } }, + ]); + expect(projectState(store).actions[0]).not.toHaveProperty('apply'); + const before = projectState(store).version; + store.completeSetup(); + const after = projectState(store); + expect(after.version).toBeGreaterThan(before); + expect(after.session.setupConfirmed).toBe(true); + }); + + it('lists only the setup questions the session has not answered', () => { + const store = createControlledStore(); + store.setFrameworkConfig( + 'nextjs' as never, + { + metadata: { + setup: { + questions: [ + { + key: 'router', + message: 'Which router?', + options: [{ label: 'App', value: 'app' }], + detect: () => Promise.resolve(null), + }, + { + key: 'styling', + message: 'Which styling?', + options: [{ label: 'CSS', value: 'css', hint: 'plain' }], + detect: () => Promise.resolve(null), + }, + ], + }, + }, + } as never, + ); + expect(projectState(store).setupQuestions.map((q) => q.key)).toEqual([ + 'router', + 'styling', + ]); + store.setFrameworkContext('router', 'app'); + expect(projectState(store).setupQuestions).toEqual([ + { + key: 'styling', + message: 'Which styling?', + options: [{ label: 'CSS', value: 'css', hint: 'plain' }], + }, + ]); + }); + + it('keeps only the allow-listed outro error detail', () => { + const store = createControlledStore(); + store.setOutroData({ + kind: OutroKind.Error, + message: 'boom', + errorDetail: { + reason: 'no manifest', + response: { headers: { authorization: 'Bearer phx_LEAK' } }, + }, + }); + expect(projectState(store).session.outroData).toEqual({ + kind: OutroKind.Error, + message: 'boom', + errorDetail: { reason: 'no manifest' }, + }); + }); + + it('redacts secret-named keys and secret refs, and nothing else', () => { + expect( + redactContext({ + a: 1, + token: 'x', + apiKey: 'y', + ACCESS_TOKEN: 'z', + nested: { k: 'v' }, + ref: 'secret:abcdef0123456789', + short: 'secret:abc', + monkey: 'business', + keyboard: 'qwerty', + }), + ).toEqual({ + a: 1, + token: '[redacted]', + apiKey: '[redacted]', + ACCESS_TOKEN: '[redacted]', + nested: { k: 'v' }, + ref: '[secret-ref]', + short: 'secret:abc', + monkey: 'business', + keyboard: 'qwerty', + }); + expect(isSecretKey('upload-api-key')).toBe(true); + expect(isSecretKey('projectApiKey')).toBe(true); + expect(isSecretKey('hotkeys')).toBe(false); + }); +}); diff --git a/src/store/control/actions.ts b/src/store/control/actions.ts new file mode 100644 index 000000000..3a2851f0c --- /dev/null +++ b/src/store/control/actions.ts @@ -0,0 +1,274 @@ +/** The commits a parent may make, keyed by flow key or interrupt; a program adds its own through `FlowStep.controlActions`. */ +import { + McpOutcome, + ScanConsent, + type AskAnswers, +} from '../session/wizard-session.js'; +import type { Flow } from '../state/flow.js'; +import { FLOW_KEY } from '../state/flow-resolution.js'; +import { Interrupt } from '../state/interrupts.js'; +import { + optionalBoolean, + optionalOneOf, + optionalStringArray, + requireRecord, + requireString, +} from './params.js'; +import type { ActionView, DriverAction } from './types.js'; + +/** Thrown when an action is not legal on the current screen. Maps to 400. */ +export class UnknownActionError extends Error { + constructor(action: string, screen: string) { + super( + `No action "${action}" on screen "${screen}". ` + + 'Read state.actions first.', + ); + this.name = 'UnknownActionError'; + } +} + +/** Screens with no commit: the runner or the agent advances them, or they are terminal. */ +export const NO_ACTION_SCREENS: ReadonlySet = new Set([ + FLOW_KEY.Auth, + FLOW_KEY.Run, + 'ai-opt-in', + FLOW_KEY.Exit, + 'audit-run', + 'doctor-report', + Interrupt.ManagedSettings, + Interrupt.AuthError, + Interrupt.SessionTimeout, +]); + +const confirmSetup: DriverAction = { + id: 'confirm_setup', + description: 'Confirm the intro and continue (sets setupConfirmed).', + apply: (store) => store.completeSetup(), +}; + +/** The default intro also decides scan sharing; Enter grants when undecided, as its key handler does. */ +const confirmSetupWithSharing: DriverAction = { + id: 'confirm_setup', + description: + 'Confirm the intro and continue. share: true grants and false declines ' + + 'sharing scan results; absent keeps the toggle (granted when undecided).', + params: { share: 'boolean (optional)' }, + apply: (store, params) => { + const share = + params.share === undefined + ? undefined + : optionalBoolean('confirm_setup', params, 'share', true); + if (share === false) { + store.declineSharing(); + } else if ( + share === true || + store.session.scanConsent === ScanConsent.Undecided + ) { + store.grantSharing(); + } + store.completeSetup(); + }, +}; + +const dismissOutro: DriverAction = { + id: 'dismiss_outro', + description: 'Dismiss the outro (sets outroDismissed).', + apply: (store) => store.setOutroDismissed(), +}; + +const setMcpOutcome = (description: string): DriverAction => ({ + id: 'set_mcp_outcome', + description, + params: { + outcome: '"installed" | "skipped" (default skipped)', + clients: 'string[] (optional)', + }, + apply: (store, params) => { + const outcome = optionalOneOf( + 'set_mcp_outcome', + params, + 'outcome', + ['installed', 'skipped'] as const, + 'skipped', + ); + store.setMcpComplete( + outcome === 'installed' ? McpOutcome.Installed : McpOutcome.Skipped, + optionalStringArray('set_mcp_outcome', params, 'clients'), + ); + }, +}); + +export const GENERIC_ACTIONS: Readonly< + Record +> = { + 'health-check': [ + { + id: 'dismiss_outage', + description: 'Dismiss the blocking outage screen and continue.', + apply: (store) => store.dismissOutage(), + }, + ], + setup: [ + { + id: 'choose', + description: + 'Answer one setup question by committing a framework-context value. ' + + 'Read state.setupQuestions for the key and allowed values.', + params: { key: 'setup question key', value: 'chosen option value' }, + apply: (store, params) => { + const key = requireString('choose', params, 'key'); + const value = requireString('choose', params, 'value'); + store.setFrameworkContext(key, value); + }, + }, + ], + [FLOW_KEY.Outro]: [dismissOutro], + 'audit-outro': [dismissOutro], + 'source-maps-outro': [dismissOutro], + [FLOW_KEY.MintFailure]: [ + { + id: 'continue_setup', + description: 'Continue to MCP and Slack after the skill is saved.', + apply: (store) => store.setMintHandoff('continue'), + }, + { + id: 'dismiss_outro', + description: 'Exit the wizard from the mint failure screen.', + apply: (store) => store.setMintHandoff('exit'), + }, + ], + [FLOW_KEY.Mcp]: [ + setMcpOutcome( + 'Complete the MCP step. outcome is installed or skipped; clients optional.', + ), + ], + 'mcp-add': [setMcpOutcome('Complete the standalone MCP-add flow.')], + 'mcp-remove': [setMcpOutcome('Complete the standalone MCP-remove flow.')], + 'mcp-suggested-prompts': [ + { + id: 'dismiss', + description: 'Dismiss the suggested-prompts step.', + apply: (store) => store.setMcpSuggestedPromptsDismissed(), + }, + ], + [FLOW_KEY.SlackConnect]: [ + { + id: 'dismiss_slack', + description: 'Skip or finish the Connect-Slack step.', + apply: (store) => store.setSlackStepDismissed(), + }, + { + id: 'set_slack_connected', + description: 'Mark Slack as connected (then dismiss to advance).', + params: { connected: 'boolean (default true)' }, + apply: (store, params) => + store.setSlackConnected( + optionalBoolean('set_slack_connected', params, 'connected', true), + ), + }, + ], + [FLOW_KEY.KeepSkills]: [ + { + id: 'keep_skills', + description: + 'Decide whether to keep installed skills; completes the run.', + params: { kept: 'boolean (default true)' }, + apply: (store, params) => + store.setSkillsComplete( + optionalBoolean('keep_skills', params, 'kept', true), + ), + }, + ], + [Interrupt.WizardAsk]: [ + { + id: 'answer_question', + description: + 'Resolve the pending wizard_ask request with a complete answers ' + + 'map: { [questionId]: string | string[] }. See state.session.pendingQuestion.', + params: { answers: 'Record' }, + apply: (store, params) => + store.resolvePendingQuestion( + requireRecord('answer_question', params, 'answers') as AskAnswers, + ), + }, + { + id: 'cancel_question', + description: 'Cancel the pending wizard_ask request (sentinel answers).', + apply: (store) => store.cancelPendingQuestion(), + }, + ], + [Interrupt.TaskNotice]: [ + { + id: 'resolve_notice', + description: + 'Resolve the task-notice overlay a program shows before an optional ' + + 'step. keep=true runs the step, keep=false skips it. See state.session.taskNotice.', + params: { keep: 'boolean (default true)' }, + apply: (store, params) => + store.resolveTaskNotice( + optionalBoolean('resolve_notice', params, 'keep', true), + ), + }, + ], + [Interrupt.SettingsOverride]: [ + { + id: 'backup_and_fix', + description: 'Back up and fix conflicting .claude/settings.json.', + apply: (store) => { + store.backupAndFixSettingsOverride(); + }, + }, + ], + [Interrupt.PortConflict]: [ + { + id: 'resolve_port_conflict', + description: + 'Dismiss the port-conflict overlay and retry the OAuth port loop.', + apply: (store) => store.resolvePortConflict(), + }, + ], + [Interrupt.ManualAuthCode]: [ + { + id: 'submit_auth_code', + description: 'Submit a manually-entered OAuth authorization code.', + params: { code: 'authorization code' }, + apply: (store, params) => + store.submitManualAuthCode( + requireString('submit_auth_code', params, 'code'), + ), + }, + { + id: 'dismiss_auth_code', + description: 'Dismiss the manual auth-code overlay without submitting.', + apply: (store) => store.dismissManualAuthCode(), + }, + ], +}; + +/** Every program intro shares one shape: confirm and continue. */ +function isIntro(screen: string): boolean { + return screen === 'intro' || screen.endsWith('-intro'); +} + +function genericActionsFor(screen: string): readonly DriverAction[] { + if (screen === 'intro') return [confirmSetupWithSharing]; + if (isIntro(screen)) return [confirmSetup]; + return GENERIC_ACTIONS[screen] ?? []; +} + +/** Actions legal on `screen` in `flow`: the flow's own first, then generic. */ +export function actionsFor(flow: Flow, screen: string): DriverAction[] { + const own = flow.steps + .filter((step) => step.screenId === screen) + .flatMap((step) => step.controlActions ?? []); + const seen = new Set(own.map((a) => a.id)); + return [...own, ...genericActionsFor(screen).filter((a) => !seen.has(a.id))]; +} + +export function toActionView(action: DriverAction): ActionView { + return { + id: action.id, + description: action.description, + ...(action.params ? { params: action.params } : {}), + }; +} diff --git a/src/store/control/client.ts b/src/store/control/client.ts new file mode 100644 index 000000000..d1628b669 --- /dev/null +++ b/src/store/control/client.ts @@ -0,0 +1,145 @@ +import * as http from 'node:http'; +import type { + ControlState, + DetectRequest, + HealthResponse, + RunRecord, + RunRequest, +} from './types.js'; + +export class ControlClientError extends Error { + constructor(readonly status: number, message: string) { + super(message); + this.name = 'ControlClientError'; + } +} + +/** The parent's side of the control API: one unix socket, JSON in and out. */ +export class ControlClient { + constructor(private readonly socketPath: string) {} + + health(): Promise { + return this.request('GET', '/health'); + } + + async state(): Promise { + return (await this.request<{ state: ControlState }>('GET', '/state')).state; + } + + /** Long poll: the first commit with `version > since`, else after `waitMs`. */ + async waitForChange(since: number, waitMs: number): Promise { + const path = `/state?wait=${waitMs}&since=${since}`; + return ( + await this.request<{ state: ControlState }>( + 'GET', + path, + undefined, + waitMs + 5000, + ) + ).state; + } + + async performAction( + id: string, + params: Record = {}, + ): Promise { + return ( + await this.request<{ state: ControlState }>( + 'POST', + `/actions/${encodeURIComponent(id)}`, + { params }, + ) + ).state; + } + + async setCredentials(): Promise { + return ( + await this.request<{ state: ControlState }>('POST', '/credentials', {}) + ).state; + } + + /** TUI surface: release the runner's agent start. Idempotent. */ + async armRun(): Promise { + return (await this.request<{ state: ControlState }>('POST', '/run', {})) + .state; + } + + async detect(req: DetectRequest = {}): Promise { + return (await this.request<{ state: ControlState }>('POST', '/detect', req)) + .state; + } + + async startRun(req: RunRequest): Promise { + return (await this.request<{ run: RunRecord }>('POST', '/runs', req)).run; + } + + async runs(): Promise { + return (await this.request<{ runs: RunRecord[] }>('GET', '/runs')).runs; + } + + async shutdown(): Promise { + await this.request<{ ok: true }>('POST', '/shutdown', {}); + } + + private request( + method: 'GET' | 'POST', + path: string, + body?: unknown, + timeoutMs = 30_000, + ): Promise { + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const req = http.request( + { + socketPath: this.socketPath, + path, + method, + headers: payload + ? { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + } + : {}, + timeout: timeoutMs, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + let parsed: { ok?: boolean; error?: string } & Record< + string, + unknown + >; + try { + parsed = JSON.parse(text) as typeof parsed; + } catch { + return reject( + new ControlClientError( + res.statusCode ?? 0, + `bad response: ${text}`, + ), + ); + } + const status = res.statusCode ?? 0; + if (status >= 400 || parsed.ok === false) { + return reject( + new ControlClientError( + status, + parsed.error ?? `HTTP ${status}`, + ), + ); + } + resolve(parsed as T); + }); + }, + ); + req.on('timeout', () => + req.destroy(new Error('control request timed out')), + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); + } +} diff --git a/src/store/control/driver.ts b/src/store/control/driver.ts new file mode 100644 index 000000000..689f87fbc --- /dev/null +++ b/src/store/control/driver.ts @@ -0,0 +1,54 @@ +import type { WizardStore } from '../state/store.js'; +import { actionsFor, UnknownActionError } from './actions.js'; +import { projectState } from './state.js'; +import type { ControlState } from './types.js'; + +/** Reads the committed store and acts through the setter the screen's key handler would call. */ +export class ControlDriver { + constructor(private readonly store: WizardStore) {} + + readState(): ControlState { + return projectState(this.store); + } + + /** Apply a named action on the current screen; 400-class errors throw. */ + performAction( + actionId: string, + params: Record = {}, + ): ControlState { + const screen = this.store.currentScreen; + const action = actionsFor(this.store.flow, screen).find( + (a) => a.id === actionId, + ); + if (!action) throw new UnknownActionError(actionId, screen); + action.apply(this.store, params); + return this.readState(); + } + + /** Resolve on the first commit past `since`, on `timeoutMs`, or when `signal` aborts. */ + waitForVersion( + since: number, + timeoutMs: number, + signal?: AbortSignal, + ): Promise { + if (this.store.getVersion() > since || signal?.aborted) { + return Promise.resolve(this.readState()); + } + return new Promise((resolve) => { + let settled = false; + const finish = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsub(); + signal?.removeEventListener('abort', finish); + resolve(this.readState()); + }; + const timer = setTimeout(finish, timeoutMs); + const unsub = this.store.subscribe(() => { + if (this.store.getVersion() > since) finish(); + }); + signal?.addEventListener('abort', finish, { once: true }); + }); + } +} diff --git a/src/store/control/index.ts b/src/store/control/index.ts new file mode 100644 index 000000000..4ba6065aa --- /dev/null +++ b/src/store/control/index.ts @@ -0,0 +1,13 @@ +/** The control API over a unix socket; shipped code imports it dynamically from the cli runners only. */ +export { attachControlServer, ROUTES } from './server.js'; +export { ControlClient, ControlClientError } from './client.js'; +export { ControlDriver } from './driver.js'; +export { + actionsFor, + GENERIC_ACTIONS, + NO_ACTION_SCREENS, + UnknownActionError, +} from './actions.js'; +export { BadParamError, MissingParamError } from './params.js'; +export { CONTROL_SESSION_KEYS } from './state.js'; +export { CONTROL_SERVER_MARKER } from './marker.js'; diff --git a/src/store/control/marker.ts b/src/store/control/marker.ts new file mode 100644 index 000000000..2bca8cc5d --- /dev/null +++ b/src/store/control/marker.ts @@ -0,0 +1,5 @@ +/** + * Appears in exactly one built chunk. The smoke test proves published TUI + * entry chunks never import that chunk and headless builds still carry it. + */ +export const CONTROL_SERVER_MARKER = 'wizard-control-server'; diff --git a/src/store/control/params.ts b/src/store/control/params.ts new file mode 100644 index 000000000..1431f6dc9 --- /dev/null +++ b/src/store/control/params.ts @@ -0,0 +1,113 @@ +/** Thrown when an action lacks a required param. Maps to 400. */ +export class MissingParamError extends Error { + constructor(subject: string, param: string) { + super(`"${subject}" requires param "${param}".`); + this.name = 'MissingParamError'; + } +} + +/** Thrown when a param is present but unusable. Maps to 400. */ +export class BadParamError extends Error { + constructor(subject: string, param: string, detail: string) { + super(`"${subject}" param "${param}": ${detail}`); + this.name = 'BadParamError'; + } +} + +type Params = Record; + +export function isRecord(value: unknown): value is Params { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function requireString( + subject: string, + params: Params, + key: string, +): string { + const v = params[key]; + if (typeof v !== 'string' || v.length === 0) { + throw new MissingParamError(subject, key); + } + return v; +} + +export function optionalString( + subject: string, + params: Params, + key: string, +): string | undefined { + const v = params[key]; + if (v === undefined) return undefined; + if (typeof v !== 'string' || v.length === 0) { + throw new BadParamError(subject, key, 'expected a non-empty string'); + } + return v; +} + +export function optionalBoolean( + subject: string, + params: Params, + key: string, + fallback: boolean, +): boolean { + const v = params[key]; + if (v === undefined) return fallback; + if (typeof v !== 'boolean') { + throw new BadParamError(subject, key, 'expected a boolean'); + } + return v; +} + +export function optionalOneOf( + subject: string, + params: Params, + key: string, + allowed: readonly T[], + fallback: T, +): T { + const v = params[key]; + if (v === undefined) return fallback; + if (typeof v !== 'string' || !(allowed as readonly string[]).includes(v)) { + throw new BadParamError( + subject, + key, + `expected one of ${allowed.join(', ')}`, + ); + } + return v as T; +} + +export function optionalStringArray( + subject: string, + params: Params, + key: string, +): string[] { + const v = params[key]; + if (v === undefined) return []; + if (!Array.isArray(v) || v.some((item) => typeof item !== 'string')) { + throw new BadParamError(subject, key, 'expected an array of strings'); + } + return v as string[]; +} + +export function requireRecord( + subject: string, + params: Params, + key: string, +): Params { + const v = params[key]; + if (!isRecord(v)) throw new MissingParamError(subject, key); + return v; +} + +export function optionalRecord( + subject: string, + params: Params, + key: string, +): Params | undefined { + const v = params[key]; + if (v === undefined) return undefined; + if (!isRecord(v)) throw new BadParamError(subject, key, 'expected an object'); + return v; +} diff --git a/src/store/control/runs.ts b/src/store/control/runs.ts new file mode 100644 index 000000000..2ed1944b4 --- /dev/null +++ b/src/store/control/runs.ts @@ -0,0 +1,64 @@ +import { randomUUID } from 'node:crypto'; +import type { ProgramId } from '../programs/program-registry.js'; +import type { ControlState, RunRecord } from './types.js'; + +/** Thrown when a route needs an idle store while a run is in flight. Maps to 409. */ +export class RunInFlightError extends Error { + constructor(runId?: string) { + super( + runId ? `A run is already in flight: ${runId}` : 'A run is in flight', + ); + this.name = 'RunInFlightError'; + } +} + +/** Every independent run this process served, in start order. */ +export class RunLedger { + private readonly records: RunRecord[] = []; + + get active(): RunRecord | null { + return this.records.find((r) => r.status === 'running') ?? null; + } + + start(programId: ProgramId, installDir: string): RunRecord { + const running = this.active; + if (running) throw new RunInFlightError(running.runId); + const record: RunRecord = { + runId: randomUUID(), + programId, + installDir, + status: 'running', + error: null, + startedAt: new Date().toISOString(), + finishedAt: null, + result: null, + }; + this.records.push(record); + return record; + } + + finish(runId: string, result: ControlState): void { + const record = this.find(runId); + record.status = 'done'; + record.result = result; + record.finishedAt = new Date().toISOString(); + } + + fail(runId: string, error: string, result: ControlState): void { + const record = this.find(runId); + record.status = 'failed'; + record.error = error; + record.result = result; + record.finishedAt = new Date().toISOString(); + } + + list(): RunRecord[] { + return this.records.map((r) => ({ ...r })); + } + + private find(runId: string): RunRecord { + const record = this.records.find((r) => r.runId === runId); + if (!record) throw new Error(`unknown run ${runId}`); + return record; + } +} diff --git a/src/store/control/server.ts b/src/store/control/server.ts new file mode 100644 index 000000000..690dc9f45 --- /dev/null +++ b/src/store/control/server.ts @@ -0,0 +1,417 @@ +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import { + PROGRAM_REGISTRY, + type ProgramId, +} from '../programs/program-registry.js'; +import { RunPhase } from '../session/wizard-session.js'; +import { logToFile } from '../shared/debug.js'; +import { resolveInstallDir } from '../shared/paths.js'; +import type { WizardStore } from '../state/store.js'; +import { UnknownActionError } from './actions.js'; +import { ControlDriver } from './driver.js'; +import { CONTROL_SERVER_MARKER } from './marker.js'; +import { + BadParamError, + MissingParamError, + optionalRecord, + optionalString, +} from './params.js'; +import { RunInFlightError, RunLedger } from './runs.js'; +import type { + ControlHooks, + ControlState, + ControlSurface, + DetectRequest, + HealthResponse, + RunRequest, +} from './types.js'; + +export const MAX_BODY_BYTES = 64 * 1024; +const PROBE_TIMEOUT_MS = 200; +/** Long polls cap here so a stuck parent never pins a connection forever. */ +const MAX_WAIT_MS = 600_000; + +/** Every route the server answers; the docs table is checked against it. */ +export const ROUTES = [ + 'GET /health', + 'GET /state', + 'GET /runs', + 'POST /actions/:id', + 'POST /credentials', + 'POST /run', + 'POST /detect', + 'POST /runs', + 'POST /shutdown', +] as const; + +export interface ControlServerOptions { + socketPath: string; + surface: ControlSurface; + hooks: ControlHooks; + version: string; + program: string; +} + +export interface ControlServerHandle { + readonly socketPath: string; + readonly ledger: RunLedger; + close(): Promise; +} + +/** A hook the other surface owns. Maps to 501. */ +class SurfaceUnavailableError extends Error { + constructor(route: string, surface: ControlSurface) { + super(`${route} is not available on the ${surface} surface`); + this.name = 'SurfaceUnavailableError'; + } +} + +class HttpError extends Error { + constructor(readonly status: number, message: string) { + super(message); + this.name = 'HttpError'; + } +} + +function statusFor(err: unknown): number { + if (err instanceof HttpError) return err.status; + if ( + err instanceof UnknownActionError || + err instanceof MissingParamError || + err instanceof BadParamError + ) { + return 400; + } + if (err instanceof RunInFlightError) return 409; + if (err instanceof SurfaceUnavailableError) return 501; + return 500; +} + +function requireProgram(programId: unknown): ProgramId { + if (typeof programId !== 'string' || !programId) { + throw new HttpError(400, 'programId is required'); + } + if (!PROGRAM_REGISTRY.some((c) => c.id === programId)) { + throw new HttpError(400, `unknown program "${programId}"`); + } + return programId; +} + +/** Refuse a live socket or a non-socket path; unlink a stale socket. */ +async function claimSocketPath(socketPath: string): Promise { + if (!fs.existsSync(socketPath)) return; + if (!fs.lstatSync(socketPath).isSocket()) { + throw new Error(`control socket path is not a socket: ${socketPath}`); + } + const live = await new Promise((resolve) => { + const probe = net.connect(socketPath); + const done = (value: boolean): void => { + probe.destroy(); + resolve(value); + }; + probe.setTimeout(PROBE_TIMEOUT_MS, () => done(true)); + probe.once('connect', () => done(true)); + probe.once('error', () => done(false)); + }); + if (live) throw new Error(`control socket already served: ${socketPath}`); + fs.unlinkSync(socketPath); +} + +function readBody(req: http.IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const type = req.headers['content-type']; + if (type && !type.toLowerCase().startsWith('application/json')) { + reject(new HttpError(415, 'body must be application/json')); + req.resume(); + return; + } + const chunks: Buffer[] = []; + let size = 0; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + // Stop reading but keep the connection: the 413 still has to go out. + req.pause(); + reject(new HttpError(413, `body over ${MAX_BODY_BYTES} bytes`)); + } else { + chunks.push(chunk); + } + }); + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8').trim(); + if (!text) return resolve({}); + try { + const parsed: unknown = JSON.parse(text); + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + throw new Error('not an object'); + } + resolve(parsed as Record); + } catch { + reject(new HttpError(400, 'body is not a JSON object')); + } + }); + req.on('error', reject); + }); +} + +function send(res: http.ServerResponse, status: number, body: unknown): void { + const text = JSON.stringify(body); + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(text), + }); + res.end(text); +} + +function actionId(pathname: string): string | null { + const match = /^\/actions\/([^/]+)$/.exec(pathname); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + throw new HttpError(400, 'action id is not valid percent-encoding'); + } +} + +/** Serve the control API for one store over a unix socket: HTTP/1.1, JSON in and out. */ +export async function attachControlServer( + store: WizardStore, + options: ControlServerOptions, +): Promise { + const { socketPath, surface, hooks } = options; + const ledger = new RunLedger(); + const driver = new ControlDriver(store); + const polls = new AbortController(); + let shuttingDown = false; + + const requireSurface = (route: string, wanted: ControlSurface): void => { + if (surface !== wanted) throw new SurfaceUnavailableError(route, surface); + }; + + /** Routes that rewrite the session or end the process wait for the run to end. */ + const requireIdle = (): void => { + const active = ledger.active; + if (active) throw new RunInFlightError(active.runId); + if (store.session.runPhase === RunPhase.Running) { + throw new RunInFlightError(); + } + }; + + const startRun = (body: Record) => { + const route = 'POST /runs'; + const frameworkContext = optionalRecord(route, body, 'frameworkContext'); + const skillId = optionalString(route, body, 'skillId'); + const installDir = resolveInstallDir( + store.session.installDir, + optionalString(route, body, 'installDir'), + ); + const req: RunRequest = { + programId: requireProgram(body.programId), + installDir, + ...(frameworkContext ? { frameworkContext } : {}), + ...(skillId ? { skillId } : {}), + }; + const record = ledger.start(req.programId, installDir); + // The state keeps this run's outcome until the next run starts; the hook + // resets it then, so a poller reading after completion sees the result. + Promise.resolve() + .then(() => hooks.startRun(req)) + .then( + () => ledger.finish(record.runId, driver.readState()), + (err: unknown) => + ledger.fail( + record.runId, + err instanceof Error ? err.message : String(err), + driver.readState(), + ), + ) + .catch((err: unknown) => + logToFile('[control] ledger update failed:', err), + ); + return { ...record }; + }; + + const handle = async ( + req: http.IncomingMessage, + res: http.ServerResponse, + ): Promise => { + const url = new URL(req.url ?? '/', 'http://control'); + const method = req.method ?? 'GET'; + const route = `${method} ${url.pathname}`; + + if (route === 'GET /health') { + const health: HealthResponse = { + ok: true, + version: options.version, + surface, + pid: process.pid, + program: options.program, + }; + return send(res, 200, health); + } + if (route === 'GET /state') { + const wait = Number(url.searchParams.get('wait') ?? ''); + const since = Number(url.searchParams.get('since') ?? ''); + let state: ControlState; + if (Number.isFinite(wait) && wait > 0) { + const from = Number.isFinite(since) ? since : store.getVersion(); + state = await driver.waitForVersion( + from, + Math.min(wait, MAX_WAIT_MS), + polls.signal, + ); + } else { + state = driver.readState(); + } + return send(res, 200, { ok: true, state }); + } + if (route === 'GET /runs') { + return send(res, 200, { ok: true, runs: ledger.list() }); + } + if (method !== 'POST') throw new HttpError(404, `no route ${route}`); + + const body = await readBody(req); + const action = actionId(url.pathname); + if (action !== null) { + const params = optionalRecord(route, body, 'params') ?? {}; + return send(res, 200, { + ok: true, + state: driver.performAction(action, params), + }); + } + switch (url.pathname) { + case '/credentials': + requireIdle(); + if (!store.session.apiKey) { + throw new HttpError(400, 'this session has no API key to resolve'); + } + await hooks.setCredentials(); + return send(res, 200, { ok: true, state: driver.readState() }); + case '/run': + requireSurface(route, 'tui'); + store.requestRun(); + return send(res, 200, { ok: true, state: driver.readState() }); + case '/detect': { + requireSurface(route, 'headless'); + requireIdle(); + const installDir = optionalString(route, body, 'installDir'); + const detect: DetectRequest = { + ...(body.programId !== undefined + ? { programId: requireProgram(body.programId) } + : {}), + ...(installDir + ? { + installDir: resolveInstallDir( + store.session.installDir, + installDir, + ), + } + : {}), + }; + await hooks.detect(detect); + return send(res, 200, { ok: true, state: driver.readState() }); + } + case '/runs': + requireSurface(route, 'headless'); + return send(res, 200, { ok: true, run: startRun(body) }); + case '/shutdown': + requireIdle(); + send(res, 200, { ok: true }); + if (!shuttingDown) { + shuttingDown = true; + res.once('finish', () => { + hooks + .shutdown() + .catch((err: unknown) => + logToFile('[control] shutdown hook failed:', err), + ); + }); + } + return; + default: + throw new HttpError(404, `no route ${route}`); + } + }; + + const server = http.createServer((req, res) => { + handle(req, res).catch((err: unknown) => { + const status = statusFor(err); + const message = err instanceof Error ? err.message : String(err); + if (status === 500) { + logToFile(`[control] ${describeRequest(req)} failed:`, err); + } + if (res.headersSent) { + res.end(); + return; + } + if (status === 413) { + // The unread body would stall this connection; close it after the reply. + res.setHeader('connection', 'close'); + res.once('finish', () => req.destroy()); + } + send(res, status, { ok: false, error: message }); + }); + }); + server.keepAliveTimeout = 1000; + + await claimSocketPath(socketPath); + // Listen with a tight umask so the socket never exists with wider permissions. + const previousUmask = process.umask(0o077); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); + }); + } finally { + process.umask(previousUmask); + } + fs.chmodSync(socketPath, 0o600); + logToFile( + `[control] listening on ${socketPath} (${surface}) ${CONTROL_SERVER_MARKER}`, + ); + + let closed = false; + const unlink = (): void => { + try { + fs.unlinkSync(socketPath); + } catch { + /* already gone */ + } + }; + const onExit = (): void => { + if (!closed) unlink(); + }; + process.once('exit', onExit); + + return { + socketPath, + ledger, + close: async () => { + if (closed) return; + closed = true; + process.off('exit', onExit); + unlink(); + // Aborted long polls answer first; idle keep-alive connections are swept until none remain. + polls.abort(); + await new Promise((resolve) => setImmediate(resolve)); + const sweep = setInterval(() => server.closeIdleConnections(), 10); + const forced = setTimeout(() => server.closeAllConnections(), 1000); + await new Promise((resolve) => server.close(() => resolve())); + clearInterval(sweep); + clearTimeout(forced); + }, + }; +} + +function describeRequest(req: http.IncomingMessage): string { + return `${req.method ?? 'GET'} ${req.url ?? '/'}`; +} diff --git a/src/store/control/state.ts b/src/store/control/state.ts new file mode 100644 index 000000000..2daf143d3 --- /dev/null +++ b/src/store/control/state.ts @@ -0,0 +1,110 @@ +import type { WizardSession } from '../session/wizard-session.js'; +import { sanitizeErrorDetail } from '../shared/errors/sanitize.js'; +import type { WizardStore } from '../state/store.js'; +import { actionsFor, toActionView } from './actions.js'; +import type { ControlSession, ControlState } from './types.js'; + +/** The session fields a parent may read; everything else stays in the process. */ +export const CONTROL_SESSION_KEYS = [ + 'installDir', + 'integration', + 'detectedFrameworkLabel', + 'detectionComplete', + 'frameworkContext', + 'setupConfirmed', + 'integrate', + 'llmOptIn', + 'discoveredFeatures', + 'runRequested', + 'runPhase', + 'completedRuns', + 'pendingQuestion', + 'taskNotice', + 'outroData', + 'outroDismissed', + 'dashboardUrl', + 'notebookUrl', + 'mcpComplete', + 'slackStepDismissed', + 'skillsComplete', +] as const satisfies readonly (keyof WizardSession)[]; + +const SECRET_WORDS = new Set([ + 'key', + 'keys', + 'token', + 'tokens', + 'secret', + 'secrets', + 'password', + 'passwords', + 'credential', + 'credentials', +]); +const SECRET_REF = /^secret:[0-9a-f-]{16,}$/i; + +/** `upload-api-key`, `accessToken`, and `ACCESS_TOKEN` name a secret; `monkey` does not. */ +export function isSecretKey(name: string): boolean { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .some((word) => SECRET_WORDS.has(word)); +} + +/** Values a driver may read; secret refs and secret-named keys never leave. */ +export function redactContext( + ctx: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(ctx)) { + if (isSecretKey(key)) { + out[key] = '[redacted]'; + } else if (typeof value === 'string' && SECRET_REF.test(value)) { + out[key] = '[secret-ref]'; + } else { + out[key] = value; + } + } + return out; +} + +function projectSession(s: WizardSession): ControlSession { + const picked = Object.fromEntries( + CONTROL_SESSION_KEYS.map((key) => [key, s[key]]), + ) as Pick; + return { + ...picked, + frameworkContext: redactContext(s.frameworkContext), + outroData: s.outroData + ? { + ...s.outroData, + ...(s.outroData.errorDetail + ? { errorDetail: sanitizeErrorDetail(s.outroData.errorDetail) } + : {}), + } + : null, + hasCredentials: s.credentials !== null, + projectId: s.credentials?.projectId ?? null, + }; +} + +/** Project the committed store for a controlling parent. */ +export function projectState(store: WizardStore): ControlState { + const s = store.session; + const screen = store.currentScreen; + const questions = s.frameworkConfig?.metadata.setup?.questions ?? []; + return { + version: store.getVersion(), + currentScreen: screen, + session: projectSession(s), + tasks: [...store.tasks], + statusMessages: [...store.statusMessages], + eventPlan: [...store.eventPlan], + handoffText: store.handoffText, + setupQuestions: questions + .filter((q) => !(q.key in s.frameworkContext)) + .map((q) => ({ key: q.key, message: q.message, options: q.options })), + actions: actionsFor(store.flow, screen).map(toActionView), + }; +} diff --git a/src/store/control/types.ts b/src/store/control/types.ts new file mode 100644 index 000000000..edee5e0fa --- /dev/null +++ b/src/store/control/types.ts @@ -0,0 +1,86 @@ +import type { SetupQuestion } from '../framework-config.js'; +import type { ProgramId } from '../programs/program-registry.js'; +import type { WizardSession } from '../session/wizard-session.js'; +import type { PlannedEvent, TaskItem, WizardStore } from '../state/store.js'; +import type { CONTROL_SESSION_KEYS } from './state.js'; + +/** One commit a controlling parent may make on a screen. */ +export interface DriverAction { + /** Stable id named in `POST /actions/`. */ + id: string; + description: string; + /** Parameter name to a human/type hint. Absent means no params. */ + params?: Record; + /** Apply the commit through exactly one store setter or resolver. */ + apply: (store: WizardStore, params: Record) => void; +} + +/** An action as the wire carries it: no closure. */ +export type ActionView = Omit; + +/** The session as a parent reads it: the listed fields, credentials as a flag. */ +export type ControlSession = Pick< + WizardSession, + (typeof CONTROL_SESSION_KEYS)[number] +> & { + hasCredentials: boolean; + projectId: number | null; +}; + +/** The store as a parent reads it; no token, key, user record, or answer value is ever projected. */ +export interface ControlState { + version: number; + currentScreen: string; + session: ControlSession; + tasks: TaskItem[]; + statusMessages: string[]; + eventPlan: PlannedEvent[]; + handoffText: string | null; + /** Setup questions the session has not answered yet. */ + setupQuestions: Array>; + /** The commits legal on `currentScreen`. */ + actions: ActionView[]; +} + +export type DetectRequest = { programId?: ProgramId } & Partial< + Pick +>; + +export type RunRequest = { programId: ProgramId } & Partial< + Pick +>; + +/** One independent run the headless surface served. */ +export interface RunRecord { + runId: string; + programId: ProgramId; + installDir: string; + status: 'running' | 'done' | 'failed'; + error: string | null; + startedAt: string; + finishedAt: string | null; + /** The state as `GET /state` read it when the run ended. */ + result: ControlState | null; +} + +export type ControlSurface = 'tui' | 'headless'; + +export interface HealthResponse { + ok: true; + version: string; + surface: ControlSurface; + pid: number; + program: string; +} + +/** What the composition root does on the parent's behalf; the store never runs agents. */ +export interface ControlHooks { + /** Resolve credentials host side and commit them, advancing `auth`. */ + setCredentials(): Promise; + /** Headless surface: run detection for a program, writing through setters. */ + detect(req: DetectRequest): Promise; + /** Headless surface: one independent agent run. Resolves when it ends. */ + startRun(req: RunRequest): Promise; + /** Flush and exit. Idempotent. */ + shutdown(): Promise; +} diff --git a/src/store/index.ts b/src/store/index.ts index d06771cd6..1b716db11 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -210,6 +210,7 @@ export { classifyRunFailure } from './shared/errors/run-failure.js'; export { skillErrorCode } from './shared/errors/skill-map.js'; export { headlessOption, + isControlledTui, isHeadless, regionOption, } from './shared/headless-mode.js'; @@ -219,6 +220,7 @@ export { WIZARD_BENCHMARK_FILE, WIZARD_LOG_FILE, relativeToInstallDir, + resolveInstallDir, } from './shared/paths.js'; export { provisionNewAccount, requestDeepLink } from './shared/provisioning.js'; export { getOrAskForProjectData } from './shared/setup-utils.js'; diff --git a/src/store/programs/error-tracking-upload-source-maps/steps.ts b/src/store/programs/error-tracking-upload-source-maps/steps.ts index 9c106e495..395b69919 100644 --- a/src/store/programs/error-tracking-upload-source-maps/steps.ts +++ b/src/store/programs/error-tracking-upload-source-maps/steps.ts @@ -10,7 +10,8 @@ import type { ProgramStep } from '../program-step.js'; import type { WizardSession } from '../../session/wizard-session.js'; import { RunPhase } from '../../session/wizard-session.js'; -import { SOURCE_MAPS_CONTEXT_KEYS } from './detect.js'; +import { SOURCE_MAPS_CONTEXT_KEYS, VARIANT_DISPLAY_NAME } from './detect.js'; +import { requireString } from '../../control/params.js'; function projectSelected(session: WizardSession): boolean { return ( @@ -42,6 +43,44 @@ export const ERROR_TRACKING_UPLOAD_SOURCE_MAPS_PROGRAM: ProgramStep[] = [ screenId: 'source-maps-detect', isComplete: projectSelected, gate: projectSelected, + controlActions: [ + { + id: 'pick_source_maps_project', + description: + 'Commit the project to wire source-map upload for, as the detect ' + + "screen's picker would. The candidate list lives in the screen's " + + 'agentic report, so the caller supplies the pick.', + params: { + variant: 'skill variant (e.g. "node", "nextjs")', + path: 'project path relative to the repo root ("." = root)', + }, + apply: (store, params) => { + const variant = requireString( + 'pick_source_maps_project', + params, + 'variant', + ); + const path = requireString( + 'pick_source_maps_project', + params, + 'path', + ); + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedVariant, + variant, + ); + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName, + (VARIANT_DISPLAY_NAME as Record)[variant] ?? + variant, + ); + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedPath, + path, + ); + }, + }, + ], }, { id: 'run', diff --git a/src/store/programs/error-tracking/index.ts b/src/store/programs/error-tracking/index.ts index 8aa47422a..25b5a5a8b 100644 --- a/src/store/programs/error-tracking/index.ts +++ b/src/store/programs/error-tracking/index.ts @@ -1,4 +1,5 @@ import { Integration } from '../../shared/constants.js'; +import { pickIntegrationTargetAction } from '../shared/control-actions.js'; import { detectFramework } from '../../detection/index.js'; import { scopeInstallDirToProject } from '../../detection/project-scope.js'; import { FRAMEWORK_REGISTRY } from '../../registry.js'; @@ -8,6 +9,7 @@ import { ERROR_TRACKING_UNSUPPORTED, errorTrackingProjectDir, gatherErrorTrackingContext, + ERROR_TRACKING_PROJECT_PATH_KEY, } from './detect-agentic.js'; import type { ProgramConfig, ProgramStep } from '../program-step.js'; import type { WizardSession } from '../../session/wizard-session.js'; @@ -74,6 +76,9 @@ const PICK_PROJECT_STEP: ProgramStep = { label: 'Detecting projects', screenId: 'error-tracking-detect', isComplete: (session) => session.integration != null, + controlActions: [ + pickIntegrationTargetAction(ERROR_TRACKING_PROJECT_PATH_KEY), + ], }; const ERROR_TRACKING_STEPS: ProgramStep[] = AGENT_SKILL_STEPS.flatMap( diff --git a/src/store/programs/self-driving/steps.ts b/src/store/programs/self-driving/steps.ts index c30cd89bb..3a7b6f9ee 100644 --- a/src/store/programs/self-driving/steps.ts +++ b/src/store/programs/self-driving/steps.ts @@ -23,7 +23,11 @@ import { detectSelfDrivingPrerequisites, POSTHOG_PRESENT_KEY, SELF_DRIVING_INTEGRATE_PATH_KEY, + GITHUB_REQUIRED_MESSAGE, + GITHUB_REQUIRED_BODY, } from './detect.js'; +import { pickIntegrationTargetAction } from '../shared/control-actions.js'; +import { OutroKind } from '../../session/wizard-session.js'; import { prepSelfDrivingIntegration } from './detect-agentic.js'; /** True once detection found PostHog already present in the project. */ @@ -65,6 +69,16 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ isComplete: (session) => postHogPresent(session) || session.integrate !== null, gate: (session) => postHogPresent(session) || session.integrate !== null, + controlActions: [ + { + id: 'set_integrate', + description: + 'Answer the self-driving integration check. integrate=true sets up ' + + 'the PostHog SDK first; false goes straight to Self-driving.', + params: { integrate: 'boolean (default false)' }, + apply: (store, params) => store.setIntegrate(params.integrate === true), + }, + ], }, HEALTH_CHECK_STEP, { @@ -88,6 +102,9 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ // (integrate=false); without the latter the orchestrator's waitUntil hangs. isComplete: (session) => session.integration != null || session.integrate === false, + controlActions: [ + pickIntegrationTargetAction(SELF_DRIVING_INTEGRATE_PATH_KEY), + ], }, { // The integration's own run step, imported and composed here: it runs the @@ -111,6 +128,14 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ screenId: 'self-driving-handoff', show: (session) => session.integrate === true, isComplete: (session) => session.selfDrivingHandoffConfirmed, + controlActions: [ + { + id: 'confirm_self_driving_handoff', + description: + 'Acknowledge the post-integration handoff and start the Self-driving run.', + apply: (store) => store.confirmSelfDrivingHandoff(), + }, + ], }, { // Hard gate before the agent starts: Self-driving cannot research findings @@ -125,6 +150,25 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ session.githubConnected === true || session.githubDeclined, gate: (session) => session.githubConnected === true || session.githubDeclined, + controlActions: [ + { + id: 'set_github_connected', + description: 'Resolve the GitHub App connection check', + params: { connected: 'boolean' }, + apply: (store, params) => + store.setGithubConnected(params.connected !== false), + }, + { + id: 'decline_github', + description: 'Answer "I can\'t connect right now" and end the run', + apply: (store) => + store.declineGithub({ + kind: OutroKind.Cancel, + message: GITHUB_REQUIRED_MESSAGE, + body: GITHUB_REQUIRED_BODY, + }), + }, + ], }, { id: 'run', diff --git a/src/store/programs/shared/control-actions.ts b/src/store/programs/shared/control-actions.ts new file mode 100644 index 000000000..3e7693051 --- /dev/null +++ b/src/store/programs/shared/control-actions.ts @@ -0,0 +1,41 @@ +import type { DriverAction } from '../../control/types.js'; +import { BadParamError, requireString } from '../../control/params.js'; +import { FRAMEWORK_REGISTRY } from '../../registry.js'; +import type { Integration } from '../../shared/constants.js'; + +/** Commit the project a detect screen's picker would: its path and framework. */ +export function pickIntegrationTargetAction(pathKey: string): DriverAction { + return { + id: 'pick_integration_target', + description: + "Commit the project to set up, as the detect screen's picker would: " + + 'its path relative to the repo root and its framework.', + params: { + path: 'project path relative to the repo root ("." = root)', + integration: 'framework id, e.g. "nextjs"', + }, + apply: (store, params) => { + const path = requireString('pick_integration_target', params, 'path'); + const integration = requireString( + 'pick_integration_target', + params, + 'integration', + ); + const config = (FRAMEWORK_REGISTRY as Partial>)[ + integration + ]; + if (!config) { + throw new BadParamError( + 'pick_integration_target', + 'integration', + `unknown framework "${integration}"`, + ); + } + store.setFrameworkContext(pathKey, path); + store.setFrameworkConfig( + integration as Integration, + FRAMEWORK_REGISTRY[integration as Integration], + ); + }, + }; +} diff --git a/src/store/session/wizard-session.ts b/src/store/session/wizard-session.ts index 87361a6df..ee0f73400 100644 --- a/src/store/session/wizard-session.ts +++ b/src/store/session/wizard-session.ts @@ -240,6 +240,10 @@ export interface WizardSession { * see `NEVER_FROM_ENV`, and keep that list in step with this comment. */ e2eAsk: boolean; + /** `--control-socket`: the unix socket path a parent drives this run over, else null. */ + controlSocket: string | null; + /** A controlled run starts its agent only after the parent posts `/run`. */ + runRequested: boolean; /** * `--local-posthog` folds into `baseUrl`, and `--local-context-mill` is read * from `getLocalDev()` — neither belongs here. This one stays because @@ -475,8 +479,9 @@ export function buildSession(args: { installDir?: string; ci?: boolean; signup?: boolean; - /** Harness-only. Set by the e2e TUI host from `E2E_ASK`, never by a flag. */ + /** Keep the `wizard_ask` bridge wired in a `ci` session; `--e2e-ask` or the e2e host. */ e2eAsk?: boolean; + controlSocket?: string; localDev?: boolean; localMcp?: boolean; localContextMill?: boolean; @@ -504,6 +509,8 @@ export function buildSession(args: { ci: args.ci ?? false, signup: args.signup ?? false, e2eAsk: args.e2eAsk ?? false, + controlSocket: args.controlSocket ?? null, + runRequested: false, localMcp: local.localMcp, mcpFeatures: args.mcpFeatures, apiKey: args.apiKey, diff --git a/src/store/shared/environment.ts b/src/store/shared/environment.ts index 7ac24e30c..422c9d04d 100644 --- a/src/store/shared/environment.ts +++ b/src/store/shared/environment.ts @@ -22,15 +22,14 @@ export function isNonInteractiveEnvironment(): boolean { } /** - * Session fields the environment must never set, matched case-insensitively - * against the camel-cased key `read-env` produces. - * - * `e2eAsk` re-wires the `wizard_ask` bridge in an otherwise non-interactive - * run. Only the e2e TUI host may set it: a real `--ci` run has nobody to answer, - * so every question would stall for the bridge timeout instead of failing fast - * with an actionable error. See `shouldDisableAsk`. + * Session fields the raw environment spread must never set, matched + * case-insensitively against the camel-cased key `read-env` produces. They + * arrive through their flags instead (yargs also reads those from + * `POSTHOG_WIZARD_*` in dev builds). `e2eAsk` re-wires the `wizard_ask` bridge + * in an otherwise non-interactive run; a real `--ci` run has nobody to answer, + * so every question would stall instead of failing fast. See `shouldDisableAsk`. */ -const NEVER_FROM_ENV = ['e2eAsk']; +const NEVER_FROM_ENV = ['e2eAsk', 'controlSocket', 'runRequested']; /** * Session args from the `POSTHOG_WIZARD_*` environment variables. diff --git a/src/store/shared/headless-mode.ts b/src/store/shared/headless-mode.ts index 4925e83c1..ac4c0fb26 100644 --- a/src/store/shared/headless-mode.ts +++ b/src/store/shared/headless-mode.ts @@ -52,6 +52,11 @@ export function isHeadless(options: Record): boolean { return options[HEADLESS_FLAG] === true; } +/** `--ci` with a control socket: the real TUI, API-key auth, a parent driving it. */ +export function isControlledTui(options: Record): boolean { + return options.ci === true && Boolean(options.controlSocket); +} + // `--region` only means something non-interactively (API-key auth has no OAuth // token response to read `posthog_region` from), so only headless-capable // commands declare it. diff --git a/src/store/shared/paths.ts b/src/store/shared/paths.ts index 76f0bbb5a..356b50b98 100644 --- a/src/store/shared/paths.ts +++ b/src/store/shared/paths.ts @@ -1,5 +1,5 @@ import { tmpdir } from 'node:os'; -import { join, sep } from 'node:path'; +import { isAbsolute, join, sep } from 'node:path'; // /tmp is stable and discoverable on macOS/Linux; Windows needs os.tmpdir() const TMP = process.platform === 'win32' ? tmpdir() : '/tmp'; @@ -25,3 +25,12 @@ export function relativeToInstallDir(file: string, installDir: string): string { const prefix = installDir.endsWith(sep) ? installDir : installDir + sep; return file.startsWith(prefix) ? file.slice(prefix.length) : file; } + +/** A sub-app path stays relative to the live install dir; an absolute path wins. */ +export function resolveInstallDir( + live: string, + requested: string | undefined, +): string { + if (!requested) return live; + return isAbsolute(requested) ? requested : join(live, requested); +} diff --git a/src/store/state/__tests__/invariants.test.ts b/src/store/state/__tests__/invariants.test.ts index 226e9a849..a003f0cc5 100644 --- a/src/store/state/__tests__/invariants.test.ts +++ b/src/store/state/__tests__/invariants.test.ts @@ -396,6 +396,8 @@ const MUTATIONS: MutationCase[] = [ invoke: (s) => s.completeRunStep('integrate-run'), emits: 1, }, + { name: 'requestRun', invoke: (s) => s.requestRun(), emits: 1 }, + { name: 'resetRunState', invoke: (s) => s.resetRunState(), emits: 1 }, { name: 'setOutroDismissed', invoke: (s) => s.setOutroDismissed(), emits: 1 }, { name: 'setOutroData', diff --git a/src/store/state/__tests__/reset-run-state.test.ts b/src/store/state/__tests__/reset-run-state.test.ts new file mode 100644 index 000000000..a5d91e92b --- /dev/null +++ b/src/store/state/__tests__/reset-run-state.test.ts @@ -0,0 +1,65 @@ +import { HostResolution } from '../../host-resolution.js'; +import { OutroKind, RunPhase } from '../../session/wizard-session.js'; +import { createControlledStore } from '../../testing/index.js'; + +describe('resetRunState', () => { + it('clears exactly what one run produced and keeps what the next run needs', async () => { + const store = createControlledStore(); + store.setCredentials({ + accessToken: 't', + projectApiKey: 'k', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 3, + }); + store.setFrameworkContext('router', 'app'); + store.completeRunStep('earlier'); + store.setTasks([ + { label: 'Install', status: 'completed', done: true } as never, + ]); + store.pushStatus('installing'); + store.setEventPlan([{ name: 'signup', description: 'd' }]); + store.setHandoffText('prompt'); + store.setCurrentStage('verify' as never); + store.setRunPhase(RunPhase.Completed); + store.setOutroData({ kind: OutroKind.Success, message: 'ok' }); + store.setOutroDismissed(); + store.setDashboardUrl('https://us.posthog.com/d/1'); + const question = store.requestQuestion({ + id: 'q', + source: 'test', + questions: [{ id: 'a', prompt: 'A?', kind: 'text' }], + } as never); + const notice = store.showTaskNotice({ + title: 't', + body: [], + confirmLabel: 'y', + cancelLabel: 'n', + prompt: 'p', + }); + + const version = store.getVersion(); + store.resetRunState(); + + expect(store.tasks).toEqual([]); + expect(store.statusMessages).toEqual([]); + expect(store.eventPlan).toEqual([]); + expect(store.handoffText).toBeNull(); + expect(store.currentStage).toBeNull(); + expect(store.session).toMatchObject({ + runPhase: RunPhase.Idle, + outroData: null, + outroDismissed: false, + dashboardUrl: null, + notebookUrl: null, + pendingQuestion: null, + taskNotice: null, + }); + await expect(question).resolves.toEqual({ a: '__cancelled__' }); + await expect(notice).resolves.toBe(false); + + expect(store.session.credentials?.projectId).toBe(3); + expect(store.session.frameworkContext).toEqual({ router: 'app' }); + expect(store.session.completedRuns).toContain('earlier'); + expect(store.getVersion()).toBeGreaterThan(version); + }); +}); diff --git a/src/store/state/flow.ts b/src/store/state/flow.ts index c68518fcb..1b440a65e 100644 --- a/src/store/state/flow.ts +++ b/src/store/state/flow.ts @@ -12,6 +12,7 @@ import type { WizardReadinessResult } from '../health-checks/readiness.js'; import type { Integration } from '../shared/constants.js'; import type { FrameworkConfig } from '../framework-config.js'; import type { ProgramId } from '../programs/program-registry.js'; +import type { DriverAction } from '../control/types.js'; /** Context passed to onInit callbacks, before the real session is assigned. */ export interface StoreInitContext { @@ -73,6 +74,11 @@ export interface FlowStep { * steps shared across programs. Attribution only. Matched by `screenId`. */ reportsAsProgramId?: ProgramId; + /** + * Commits a controlling parent may make on this step's screen, beyond the + * generic ones every flow shares. Each calls one store setter. + */ + controlActions?: readonly DriverAction[]; } export interface Flow { diff --git a/src/store/state/store-api.ts b/src/store/state/store-api.ts index bd18fae34..23d4b7d93 100644 --- a/src/store/state/store-api.ts +++ b/src/store/state/store-api.ts @@ -29,6 +29,7 @@ export const STORE_BOUNDARY_MEMBERS = [ 'hasInterrupt', 'interruptDepth', 'pushStatus', + 'resetRunState', 'resolvePendingQuestion', 'resolvePortConflict', 'resolveTaskNotice', @@ -39,6 +40,7 @@ export const STORE_BOUNDARY_MEMBERS = [ 'setCredentials', 'setCurrentStage', 'setDetectedFramework', + 'setDetectionComplete', 'setEventPlan', 'setFrameworkConfig', 'setFrameworkContext', diff --git a/src/store/state/store.ts b/src/store/state/store.ts index 116d328c6..a8f316d48 100644 --- a/src/store/state/store.ts +++ b/src/store/state/store.ts @@ -846,6 +846,30 @@ export class WizardStore { this.setRunPhase(RunPhase.Idle); } + /** Clear what one agent run leaves behind, so the next independent run starts clean. */ + resetRunState(): void { + this.cancelPendingQuestion(); + if (this.session.taskNotice) this.resolveTaskNotice(false); + this.$tasks.set([]); + this.$statusMessages.set([]); + this.$eventPlan.set([]); + this.$handoffText.set(null); + this.$tokenUsage.set(EMPTY_TOKEN_USAGE); + this.$currentStage.set(null); + this.$session.setKey('runPhase', RunPhase.Idle); + this.$session.setKey('outroData', null); + this.$session.setKey('outroDismissed', false); + this.$session.setKey('dashboardUrl', null); + this.$session.setKey('notebookUrl', null); + this.emitChange(); + } + + /** A controlled run's parent released the agent; the runner waits on this. */ + requestRun(): void { + this.$session.setKey('runRequested', true); + this.emitChange(); + } + setOutroDismissed(dismissed = true): void { this.$session.setKey('outroDismissed', dismissed); this.emitChange(); diff --git a/src/store/task-stream/task-stream-push.ts b/src/store/task-stream/task-stream-push.ts index a3e0a9c45..7b1ee84ec 100644 --- a/src/store/task-stream/task-stream-push.ts +++ b/src/store/task-stream/task-stream-push.ts @@ -120,6 +120,8 @@ export interface TaskStreamPushOptions { auditChecks?: () => unknown; /** When false, destination subscription/delivery remains disabled. */ enabled?: boolean; + /** Keys the stream by this skill instead of the store's; set for an independent run. */ + skillId?: string; } export class TaskStreamPush { @@ -127,6 +129,7 @@ export class TaskStreamPush { private readonly destinations: TaskStreamDestination[]; private readonly startedAt: string; private readonly programId: string; + private readonly skillId: string | null; private readonly sessionId: string; private readonly eventPlanWatcher: EventPlanWatcher | null; private readonly auditChecks: (() => unknown) | null; @@ -156,8 +159,9 @@ export class TaskStreamPush { // skillId may not be set yet — fall back to programId so the // session_id is stable for the whole run regardless of when the // program metadata is populated. + this.skillId = opts.skillId ?? null; const skillId = sanitizeChannelId( - this.store.session.skillId ?? this.programId, + this.skillId ?? this.store.session.skillId ?? this.programId, ); this.sessionId = `${this.programId}-${skillId}-${this.startedAt}`; } @@ -299,7 +303,9 @@ export class TaskStreamPush { private async sendOnce(): Promise { const { session, tasks, eventPlan, handoffText } = this.store; - const skillId = sanitizeChannelId(session.skillId ?? this.programId); + const skillId = sanitizeChannelId( + this.skillId ?? session.skillId ?? this.programId, + ); const phase = session.runPhase; // Program rows carry the phase; the area rows carry the audit's progress. diff --git a/src/store/testing/index.ts b/src/store/testing/index.ts index 98a69fd25..bc6ea4c16 100644 --- a/src/store/testing/index.ts +++ b/src/store/testing/index.ts @@ -1,6 +1,9 @@ import { flowFor } from '../programs/flow-for.js'; import { Program, type ProgramId } from '../programs/program-registry.js'; +import { buildSession } from '../session/wizard-session.js'; import { WizardStore } from '../state/store.js'; +import { setUI } from '../ui/index.js'; +import { StoreUI } from '../ui/store-ui.js'; /** A real store on a real program flow; tests fake nothing below it. */ export function createTestStore( @@ -8,3 +11,31 @@ export function createTestStore( ): WizardStore { return new WizardStore(flowFor(programId).flow); } + +/** A store behind `StoreUI` with a non-interactive session: what a controlled run drives. */ +export function createControlledStore( + programId: ProgramId = Program.PostHogIntegration, + session: Partial[0]> = {}, +): WizardStore { + const store = createTestStore(programId); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: '/tmp/controlled-store', + ci: true, + ...session, + }); + return store; +} + +const SECRET_MARKERS = ['phx_', 'phc_', 'phs_', 'secret:', 'sk_live_']; + +/** Fails when any known secret shape appears in text meant for a parent or a log. */ +export function expectNoSecrets(text: string, extra: string[] = []): void { + for (const marker of [...SECRET_MARKERS, ...extra]) { + if (text.includes(marker)) { + throw new Error( + `secret marker "${marker}" leaked into: ${text.slice(0, 120)}`, + ); + } + } +} diff --git a/src/store/types.ts b/src/store/types.ts index d8fab130e..2b950493a 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -94,3 +94,19 @@ export type { } from './ui/wizard-ui.js'; export type { WizardSpellbook } from './wizard-spellbook.js'; export type { StoreBoundaryMember, WizardStoreApi } from './state/store-api.js'; +export type { + ActionView, + ControlHooks, + ControlSession, + ControlState, + ControlSurface, + DetectRequest, + DriverAction, + HealthResponse, + RunRecord, + RunRequest, +} from './control/types.js'; +export type { + ControlServerHandle, + ControlServerOptions, +} from './control/server.js'; diff --git a/src/tui/start-tui.ts b/src/tui/start-tui.ts index fa135fd3c..065eff20a 100644 --- a/src/tui/start-tui.ts +++ b/src/tui/start-tui.ts @@ -18,6 +18,9 @@ import { getExitLine } from './exit-line.js'; export { releaseTerminal }; +/** Appears in the TUI chunk; the smoke test proves it never imports the control server. */ +export const TUI_ENTRY_MARKER = 'wizard-tui-entry'; + export interface TuiHandle { unmount: () => void; store: WizardStore; @@ -29,6 +32,7 @@ export function startTUI( program: ProgramId = Program.PostHogIntegration, ): TuiHandle { enterDarkTerminal(); + logToFile(`[${TUI_ENTRY_MARKER}] ${program}`); const store = new WizardStore(flowFor(program).flow); store.version = version;