diff --git a/docs-internal/web-parity-partition.md b/docs-internal/web-parity-partition.md deleted file mode 100644 index 8c295c2a..00000000 --- a/docs-internal/web-parity-partition.md +++ /dev/null @@ -1,137 +0,0 @@ -# `--web` parity: the partition - -Phase 0 output. Every interactive command gets a `--web` twin that serves a -compiled screen instead of an `inquirer` prompt. This file says who owns what, -so agents working in parallel never touch the same file. - -## CORRECTION — how to find the work - -The first version of this file scoped the work by grepping for `inquirer`, -which found 16 commands. That was the wrong filter and it missed half the -product: a command does not need to ask a question to have a screen. `capy -users` prints a table, `capy info` prints session state, `capy targets` prints -a listing — under the MCP an agent gets a wall of ANSI where the screen is a -real table, and several of them (`grant-branch`, `revoke`, `cleanup`, -`targets-remove`) are destructive and already have a consent gate drawn. - -**The authoritative map already exists.** From the monorepo root: - - bun run scripts/check-coverage.ts - -It reads the CLI's own `.command()` registry and the workbench's declared -screens and prints the mapping — 35 commands, 41 screens, 33 covered. Use it. -Do not re-derive coverage from a grep, and do not treat this table as the list -of what needs doing; the checker is. - -Only `capy run` and `capy add` declare no screen. `run` is correct — it wraps a -child process and has no question to ask. `add` has the intake page already -(`intakePage.ts`) and predates the screen contract. - -## The rule that decides the partition - -**Ownership is by FILE, never by command.** Several commands share files — -`connectors/shared.ts` is imported by connect, rotate and add; `orgCreation.ts` -by org and capy — so one-agent-per-command puts two agents in one file. The -clusters below are closed under "imports each other". - -`errorScreen.ts` is deliberately excluded from every parcel: 13 files import -it, so it is not a flow, it is the global error display. It is converted last, -alone, once the flows have settled. - -## The reference implementation - -`src/ui/syncConflictScreen.ts` + `tests/ui/syncConflictScreen.test.ts`. - -It is the only converted flow and it is the template. Follow it exactly: - -- build the payload in a `buildXData(params, nonce)` function, exported so a - test can assert its shape without standing a server up -- serve with `runBrowserWizard({ firstScreen: { html: '', standalone: true }, - renderFirst: (nonce) => renderScreen('screen-name', buildXData(p, nonce)) })` -- `standalone: true` because a compiled screen is a whole document; advancing - is a reload, not an innerHTML swap -- refuse a malformed submit inline (`return { error: '…' }`) rather than - applying a guess — an answer the screen cannot produce did not come from the - screen -- strip ANSI from anything the terminal formatted before it reaches a payload -- carry the CLI's own wording verbatim; never reword a label for the browser - -## What "done" means for a parcel - -1. Every prompt in the parcel's commands has a `--web` path. -2. The CLI's `stops[]` is built by the command, not the screen, and the same - array is what `--json` emits. See `src/core/branchCreatePlan.ts`. -3. A unit test on `buildXData` — shape, edge cases, no secret value in the - payload. -4. **A browser test in `tests/ui/browserFlow.e2e.test.ts` that clicks the real - page.** Not optional. Three inventions this session survived weeks of - careful commenting and died on the first click; a flow with no browser test - is a flow nobody has run. -5. `bash tests/run-tests.sh` green, `bunx tsc --noEmit` clean, deps still 6. - -## Parcels - -| # | Branch | Files owned | Screens | Prompts | -|---|---|---|---|---| -| P1 | `web/connectors` | `connectCommand.ts`, `rotateCommand.ts`, `connectors/*` | connect-provider, connect-setup, connect-live-gate, connect-overwrite, connect-result, rotate-plan-confirm, rotate-progress | 14 | -| P2 | `web/deploy` | `deployCommand.ts`, `deployTokenCommand.ts` | deploy-destination, deploy-target-setup, deploy-plan-confirm, deploy-targets, deploy-tokens, deploy-run-result | 20 | -| P3 | `web/org-onboarding` | `orgCommand.ts`, `orgCreation.ts`, `byocCommand.ts`, `ui/onboardingWeb.ts`, `ui/selectWeb.ts` | create-organization, local-onboarding, switch-organization, byoc-connect, local-passphrase-unlock | 16 | -| P4 | `web/init-sync` | `capyCommand.ts`, `statusCommand.ts` | init-wizard, sync-status, sync-result, secret-commit-review | 7 | -| P5 | `web/secrets` | `editCommand.ts`, `addCommand.ts`, `decryptCommand.ts`, `exportCommand.ts`, `ui/editScreen.ts` | secret-table, secret-value-editor, secret-intake, seed-phrase-decrypt | 3 | -| P6 | `web/members` | `inviteCommand.ts`, `kickCommand.ts` | invite-teammate, redeem-invite, org-members, member-branch-access | 3 | -| P7 | `web/branches` | `checkoutCommand.ts` | branch-create, branch-list | 1 | -| P8 | `web/recovery` | `recoverCommand.ts`, `endRecoverCommand.ts`, `transportCommand.ts` | recover-master-key, end-recover-cleanup, transport-machine | 3 | -| — | (coordinator, last) | `ui/errorScreen.ts` + its 13 call sites | the blocked/error views | — | - -`capyCommand.ts` is P4's alone even though the sync-conflict work already -touched it: it is the largest command file and the one most likely to conflict. - -## Trainstops - -A command that asks more than one question draws a rail. The rail is the CLI's: -the command computes the whole route before opening anything, and the screen -renders it. A screen may say where it is STANDING and nothing else. - -Stops carry `detail` and, where a step is something the user does by hand, -`manual`. Both come from the payload. Seven screens' fixtures assert these -today because no command emits them — closing that is part of each parcel. - -Cross-command journeys (init → connect → deploy) are Phase 3, not a parcel. - -## Hard constraints - -- **Never** launch the developer's browser. `CAPY_WEB_NO_OPEN=1` in every test. - The CLI honours it; `open: !process.env.CAPY_WEB_NO_OPEN`. -- No new runtime dependency. Currently 6. -- No secret VALUE in a payload — snippets only, the rule the TTY tables follow. - A recovery phrase renders in the page and never travels back. -- `--web` is agent-only. It must not appear in human-facing docs. -- Run `bash tests/run-tests.sh`, never bare `bun test` over several dirs — - Bun's `mock.module()` leaks across files in one process and invents failures. -- UI source lives in the monorepo (`packages/ui`), not here. This repo carries - only the compiled `generated.ts`. If a screen needs changing, change it there - and run `bun run sync-assets` from the monorepo root. - -## Post-merge: the argv wiring (coordinator) - -`--web` is declared ONCE, on the root program (`src/index.ts:53`), so Commander -binds it to the global scope. A subcommand reading `options.web` always gets -`undefined`; it has to read `command.optsWithGlobals().web`. Two call sites -already do this and say so in a comment — `capy add` at :690, and the `force` -merge in `connect` at :719 — but no other subcommand threads it. - -The effect is worse than a missing feature: `capy connect --web` PARSES, is -silently ignored, and hands an agent a TTY prompt it cannot answer with no -indication why. Every reviewer that looked for it flagged it. - -This must land AFTER the parcels merge, not before: the wiring calls signatures -the parcels introduce (`ConnectCommand.list({ web })` does not exist until -`web/connectors` lands). Attempting it early fails the typecheck, which is how -this note came to be written. - -Checklist when the parcels are in — for each command with a converted flow: - - web: command.optsWithGlobals().web === true - -and a test that asserts the flag actually reaches the command's options, since -the failure mode here is silence rather than an error. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 82713edd..9a3fbdcb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -582,9 +582,6 @@ capy add [options] | Option | Description | Default | |---|---|---| -| `--reason ` | short note shown on the intake page | | -| `--help-url ` | per-variable "where to find this" link, e.g. STRIPE_SECRET_KEY=https://dashboard.stripe.com/apikeys (repeatable) | `[]` | -| `--no-open` | do not auto-open the browser; print the URL only | | | `--no-push` | write to .env only; do not push to Capy | | | `-f, --force` | overwrite existing values without prompting | | | `--non-tty` | never prompt; resolve from flags or fail fast (agents/CI) | | @@ -760,6 +757,7 @@ Every refusal carries a stable `code` — branch on it, never on message text. - `STDIN_TOO_LARGE` - `ADD_STDIN_ONE_NAME` - `ADD_VAR_EXISTS` +- `WEB_MODE_REMOVED` ## Conventions diff --git a/package.json b/package.json index a0662a99..dd018e65 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "build:binary": "node scripts/build-binary.mjs" }, "dependencies": { + "@inquirer/core": "^10.3.2", "commander": "^11.0.0", "dotenv": "^16.3.0", "inquirer": "^12.9.6", diff --git a/scripts/check-screens.mjs b/scripts/check-screens.mjs index 6a6dcb87..2da1c37e 100644 --- a/scripts/check-screens.mjs +++ b/scripts/check-screens.mjs @@ -19,6 +19,11 @@ const ALLOWED_DEPS = [ 'commander', 'dotenv', 'inquirer', + // Not a screens dependency, and not a new package: the searchable + // pickers build on inquirer's own prompt core. It was already installed + // as an inquirer dependency at the same version; declaring it only makes + // the direct import explicit. + '@inquirer/core', 'open', 'proper-lockfile', // Not a screens dependency: `capy transport` and `capy pair` (CAP-684) diff --git a/scripts/demo/DEMO.md b/scripts/demo/DEMO.md deleted file mode 100644 index c19afa1f..00000000 --- a/scripts/demo/DEMO.md +++ /dev/null @@ -1,73 +0,0 @@ -# Capy `--web` demo — three headlines - -A deterministic, offline demo of the agent-facing Capy flows. No live backend, no -network. Everything runs against a seeded local-only fixture so the same thing -happens every time. - -## TL;DR — prove it's all green - -```bash -bash scripts/demo/run-all.sh -``` - -Runs both `--web` E2E verifiers headless (no browser hijack). For the NL-routing -headline, run the eval in the capy-mcp repo (below). - ---- - -## Headline 1 — resolve a sync conflict in the browser - -The flagship. An agent (or `capy`) hits a conflict between your local edits and the -pinned baseline; instead of a TTY menu it can't drive, `capy --web` opens a local -page where you pick, per variable, which value to keep. Snippets only — full secret -values never leave the loopback. - -**Live walkthrough:** -```bash -HOME=/tmp/capy-demo-home bun scripts/demo/seed.ts # seed a deterministic conflict -cd /tmp/capy-demo-home/demo-project -HOME=/tmp/capy-demo-home capy --web # browser opens; resolve visually -``` - -**Headless proof (what CI runs):** `node scripts/demo/verify-web-conflict.mjs` -— plays the browser, asserts the COMMITTED values match the choices (API_KEY→pinned -baseline restored, DATABASE_URL→local edit kept). - -## Headline 2 — onboard a fresh project in the browser - -First-run, fully offline. `capy byoc --web` renders the setup trainstops — show / -confirm the 24-word recovery phrase, set a local passphrase — in the browser. - -**Security:** the recovery phrase is generated in-process, shown only in the page, -and NEVER printed to the terminal — so an agent shelling this through the MCP never -sees it. - -**Live walkthrough:** -```bash -HOME=/tmp/capy-onboard-home capy byoc --web # browser opens; generate phrase, set passphrase -``` - -**Headless proof:** `node scripts/demo/verify-web-onboarding.mjs` — asserts setup -completes offline, a project bootstraps after, and the phrase never appears in the -CLI's stdout/stderr (not even a 4-word fragment). - -## Headline 3 — natural-language tool routing (capy-mcp repo) - -An agent picks the RIGHT capy tool from plain English. The convergence eval drives -`claude` headless with only the capy MCP tools and measures which tool it selects. - -```bash -# in the capy-mcp repo: -node scripts/eval/run-eval.mjs --core-only # all core intents -node scripts/eval/run-eval.mjs --intents=init,sync # the capy_sync intents specifically -``` - -`capy_sync` (CAP-274 D) is the tie-in: "set up Capy here" / "pull the latest -secrets" route to it, and it shells `capy --web` — so the agent triggering a sync is -exactly what opens the headline-1 / headline-2 browser flows. The conflict → browser -→ resolve handoff is the whole story end to end. - -## Screens - -`bun scripts/demo/preview.ts ` writes a standalone HTML snapshot of each -`--web` screen (no server) for screenshots/review. diff --git a/scripts/demo/preview.ts b/scripts/demo/preview.ts deleted file mode 100644 index 1af7aac8..00000000 --- a/scripts/demo/preview.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Render each `capy --web` screen to a standalone HTML file (no server needed) so - * they can be screenshotted for review. Pure presentation — uses the same screen - * builders the live wizard uses. The recovery phrase shown here is a throwaway - * generated just for the preview; it is not used as a key. - * - * bun scripts/demo/preview.ts - */ -import { writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { DEPLOY_PAGE_CSS } from '../../src/ui/deployPage/generatedAssets'; -import { buildLocalOnboardingData } from '../../src/ui/onboardingWeb'; -import { buildConflictData } from '../../src/ui/syncConflictScreen'; -import { renderScreen } from '../../src/ui/screens/serve'; -import { CAPY_LOGO_SVG } from '../../src/ui/browserWizard'; -import { generateSeedPhrase } from '../../src/crypto/keyManager'; -import { LOCAL_PHRASE_NOTES } from '../../src/commands/byocCommand'; - -const outDir = process.argv[2] || '/tmp/capy-preview'; -mkdirSync(outDir, { recursive: true }); - -function page(title: string, screenHtml: string, theme: 'light' | 'dark' = 'light'): string { - // Force the theme for the preview: set color-scheme on and, for dark, - // flip the screen's own wrapper (which declares `light dark`) to `dark` so its - // light-dark() colours resolve dark even without OS emulation. - const dark = theme === 'dark'; - // Force the screen's wrapper to the chosen scheme so light-dark() resolves - // deterministically in headless (which otherwise follows the host's preference). - const inner = screenHtml.replaceAll('color-scheme:light dark', `color-scheme:${theme}`); - const bodyBg = dark ? 'background:#000;color:#fff;' : 'background:#fff;color:#171717;'; - return ` - -
-
-
${CAPY_LOGO_SVG}
-

${title}

-
-
${inner}
-
-`; -} - -const previewPhrase = generateSeedPhrase(); -const previewWords = previewPhrase.split(/\s+/).filter(Boolean); - -/** One step of local onboarding, as the compiled `local-onboarding` screen. */ -const onboarding = (state: Parameters[0]): string => - renderScreen( - 'local-onboarding', - buildLocalOnboardingData({ ...state, bodyLines: LOCAL_PHRASE_NOTES }, 'preview-nonce'), - ); - -const screens: Array<[string, string, string]> = [ - ['1-onboard-choose.html', 'Set up Capy — local mode', onboarding({})], - [ - '2-onboard-phrase.html', - 'Set up Capy — local mode', - onboarding({ source: 'generate', phraseWords: previewWords }), - ], - [ - '3-onboard-passphrase.html', - 'Set up Capy — local mode', - onboarding({ source: 'generate', phraseWords: previewWords, phraseSettled: true }), - ], - [ - '4-conflict-resolver.html', - 'Resolve 2 conflicts — demo/development', - renderScreen( - 'sync-conflict', - buildConflictData( - { - rows: [ - { variable: 'API_KEY', pinned: 'sk_...001', local: 'sk_...999', remote: null }, - { variable: 'DATABASE_URL', pinned: 'pos...dev', local: 'pos...ing', remote: null }, - ], - unresolvable: new Set(), - showLocal: true, - showRemote: false, - localMode: false, - isOnboarding: false, - isBehind: false, - remoteState: 'empty', - actions: [ - { value: 'commit_local', label: 'Commit and push all local values' }, - { value: 'retrieve_pinned', label: 'Retrieve all pinned values' }, - { value: 'individual', label: 'Individually resolve' }, - { value: 'skip', label: 'Continue working' }, - ], - projectName: 'demo', - branch: 'development', - }, - 'preview-nonce', - ), - ), - ], - [ - '5-conflict-resolver-3way.html', - 'Resolve 2 conflicts — demo/development', - // 3-way conflict (a teammate also pushed) → the Remote column appears too. - renderScreen( - 'sync-conflict', - buildConflictData( - { - rows: [ - { variable: 'API_KEY', pinned: 'sk_...001', local: 'sk_...999', remote: 'sk_...777' }, - { variable: 'DATABASE_URL', pinned: 'pos...dev', local: 'pos...ing', remote: 'pos...prd' }, - ], - unresolvable: new Set(), - showLocal: true, - showRemote: true, - localMode: false, - isOnboarding: false, - isBehind: false, - remoteState: 'ok', - actions: [ - { value: 'commit_local', label: 'Commit and push all local values' }, - { value: 'retrieve_pinned', label: 'Retrieve all pinned values' }, - { value: 'individual', label: 'Individually resolve' }, - { value: 'skip', label: 'Continue working' }, - ], - projectName: 'demo', - branch: 'development', - }, - 'preview-nonce', - ), - ), - ], -]; - -for (const [file, title, html] of screens) { - writeFileSync(join(outDir, file), page(title, html, 'light')); - console.log(join(outDir, file)); - const darkFile = file.replace(/\.html$/, '-dark.html'); - writeFileSync(join(outDir, darkFile), page(title, html, 'dark')); - console.log(join(outDir, darkFile)); -} diff --git a/scripts/demo/run-all.sh b/scripts/demo/run-all.sh deleted file mode 100755 index 9bf2b47c..00000000 --- a/scripts/demo/run-all.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Rehearsable, offline end-to-end demo of the `capy --web` flows. Runs both -# headless E2E verifiers back to back so you can confirm everything is green -# before a live walkthrough. No live backend, no network — deterministic. -# -# bash scripts/demo/run-all.sh -# -# For a LIVE demo (real browser), see DEMO.md — these verifiers run headless -# (CAPY_WEB_NO_OPEN) so they never hijack your browser. -set -euo pipefail -cd "$(dirname "$0")/../.." - -echo "▶ Building capy…" -bun run build >/dev/null 2>&1 -echo "✓ built" -echo - -echo "═══ Headline 1: browser sync-conflict resolver ═══" -node scripts/demo/verify-web-conflict.mjs -echo - -echo "═══ Headline 2: local-only onboarding in the browser ═══" -node scripts/demo/verify-web-onboarding.mjs -echo - -echo "✓ All --web E2E flows pass. (NL tool-routing — headline 3 — is covered by" -echo " the capy-mcp eval: scripts/eval/run-eval.mjs --core-only, intents init/sync" -echo " now route to capy_sync.)" diff --git a/scripts/demo/seed.ts b/scripts/demo/seed.ts deleted file mode 100644 index 7cc94f9c..00000000 --- a/scripts/demo/seed.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Seed a deterministic, offline, local-only fixture that makes the REAL bare - * `capy` sync flow detect a conflict every time — no live service, no mock - * server, no network. Used to demo / rehearse the `capy --web` browser - * conflict resolver and onboarding trainstops (CAP-274). - * - * It is a genuine end-to-end seed: real crypto, a real `capy push` to commit a - * baseline into the local keep cache, then a real edit to `.env` so the next - * `capy` sees local != pinned (State 2) and opens the resolver. - * - * MUST be run with HOME pointed at a throwaway fixture dir so it never touches - * the developer's real ~/.capy: - * - * HOME=/tmp/capy-demo-home bun scripts/demo/seed.ts - * - * Prints the seeded project dir on the last line (stdout) so a demo runner can - * `cd` there and launch `capy --web`. - */ -import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -// ── Safety rail: refuse to seed into a real home dir ────────────────────────── -const home = homedir(); -if (!process.env.HOME || !/capy[-_]?demo|capy[-_]?fixture|tmp/i.test(home)) { - console.error( - `Refusing to seed: HOME (${home}) does not look like a throwaway fixture dir.\n` + - `Run with e.g. HOME=/tmp/capy-demo-home bun scripts/demo/seed.ts`, - ); - process.exit(2); -} - -const projectDir = process.env.CAPY_DEMO_PROJECT || join(home, 'demo-project'); -const BRANCH = 'development'; - -// Baseline secrets (what gets committed/pinned) and the post-edit local values. -// Two of three diverge → a 2-row conflict table; STRIPE_KEY stays to show the -// resolver only surfaces what actually changed. -const BASELINE: Record = { - API_KEY: 'sk_baseline_0001', - DATABASE_URL: 'postgres://localhost:5432/app_dev', - STRIPE_KEY: 'pk_test_unchanged', -}; -const EDITED: Record = { - API_KEY: 'sk_locally_edited_9999', - DATABASE_URL: 'postgres://localhost:5432/app_staging', - STRIPE_KEY: 'pk_test_unchanged', -}; - -const toEnv = (vars: Record) => - Object.entries(vars) - .map(([k, v]) => `${k}=${v}`) - .join('\n') + '\n'; - -async function main() { - // Clean any prior fixture so the seed is deterministic. - for (const p of [join(home, '.capy'), projectDir]) { - if (existsSync(p)) rmSync(p, { recursive: true, force: true }); - } - - const gc = await import('../../src/config/globalConfig'); - const km = await import('../../src/crypto/keyManager'); - const kr = await import('../../src/crypto/keyResolver'); - const { saveAndActivateProfile } = await import('../../src/config/profileConfig'); - - // 1. Local-only profile + an unlocked passphrase session (no auth, no server). - saveAndActivateProfile('local', { url: 'local://', localOnly: true }); - const masterKey = km.seedPhraseToMasterKey(km.generateSeedPhrase()); - kr.saveLocalKey(masterKey, 'demo-passphrase'); - gc.saveLocalSession(masterKey.toString('hex')); - - // 2. A project dir with an empty keep.lock + a baseline .env. - mkdirSync(join(projectDir, '.capy'), { recursive: true }); - writeFileSync(join(projectDir, '.capy', 'branch'), BRANCH); - writeFileSync( - join(projectDir, 'keep.lock'), - JSON.stringify( - { - version: '3.0', - org_id: gc.LOCAL_ORG_ID, - project_id: 'proj-demo', - project_name: 'demo', - variables: {}, - }, - null, - 2, - ), - ); - writeFileSync(join(projectDir, '.env'), toEnv(BASELINE)); - - // 3. Commit the baseline with a REAL push (writes pinned hashes into keep.lock - // and the encrypted blob into the local keep cache — the "pinned" side). - process.chdir(projectDir); - const { PushCommand } = await import('../../src/commands/pushCommand'); - await new PushCommand().execute(); - - // 4. Diverge: edit .env so local != pinned. Next `capy` → State 2 resolver. - writeFileSync(join(projectDir, '.env'), toEnv(EDITED)); - - console.error('\n✓ Seeded. Conflict will fire on next `capy` in:'); - // Last stdout line = the project dir, for a demo runner to consume. - console.log(projectDir); -} - -main().catch((err) => { - console.error('seed failed:', err); - process.exit(1); -}); diff --git a/scripts/demo/setup-claude-e2e.mjs b/scripts/demo/setup-claude-e2e.mjs deleted file mode 100644 index f1455b9b..00000000 --- a/scripts/demo/setup-claude-e2e.mjs +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Scaffold a ready-to-use E2E test project for verifying the capy MCP in Claude - * Code: a seeded local-only project with a planted sync conflict + a project-local - * `.mcp.json` that registers the capy MCP pointed at the local --web-capable capy - * build and an isolated HOME. Open the project in Claude Code, say "sync my - * secrets", and the browser conflict resolver opens for you to click through. - * - * CAPY_MCP_BIN=/path/to/capy-mcp/bin/capy-mcp \ - * node scripts/demo/setup-claude-e2e.mjs [rootDir] - * - * CAPY_MCP_BIN (required) points at your capy-mcp server entry. The fixture root - * defaults to a temp dir (override with the [rootDir] arg or CAPY_E2E_ROOT). - * - * Re-run any time to refresh (re-seeds the conflict + the unlocked local session). - * Touches only the scaffold dir — never your real ~/.capy. - */ -import { spawn } from 'child_process'; -import { writeFileSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; - -const CAPY_CLI = new URL('../..', import.meta.url).pathname; -const CAPY_BIN = process.env.CAPY_BIN || join(CAPY_CLI, 'bin', 'capy'); // local --web-capable build -// Path to the capy-mcp server entry (its bin/capy-mcp). Required — set it to wherever -// your capy-mcp checkout lives. -const MCP_BIN = process.env.CAPY_MCP_BIN; - -// Where to build the throwaway fixture + test project (override via arg or env). -const ROOT = process.argv[2] || process.env.CAPY_E2E_ROOT || join(tmpdir(), 'capy-claude-e2e'); -const HOME = join(ROOT, 'capy-demo-home'); // name matches seed.ts's throwaway-dir safety rail -const PROJECT = join(ROOT, 'project'); - -function run(cmd, args, env) { - return new Promise((resolve, reject) => { - const cp = spawn(cmd, args, { cwd: CAPY_CLI, env: { ...process.env, ...env }, stdio: 'inherit' }); - cp.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`)); - }); - }); -} - -async function main() { - if (!MCP_BIN) { - console.error('Set CAPY_MCP_BIN to your capy-mcp server entry (its bin/capy-mcp). Example:'); - console.error(' CAPY_MCP_BIN=/path/to/capy-mcp/bin/capy-mcp node scripts/demo/setup-claude-e2e.mjs'); - process.exit(2); - } - - // 1. Seed the deterministic offline conflict into an isolated HOME + project dir. - await run('bun', ['scripts/demo/seed.ts'], { HOME, CAPY_DEMO_PROJECT: PROJECT }); - - // 2. Drop a project-local .mcp.json so Claude Code launches the capy MCP with the - // local capy binary and the fixture HOME (capy runs in this project's cwd). - const mcp = { - mcpServers: { - capy: { command: 'node', args: [MCP_BIN], env: { CAPY_BIN, HOME } }, - }, - }; - writeFileSync(join(PROJECT, '.mcp.json'), JSON.stringify(mcp, null, 2) + '\n'); - - console.log('\n✓ E2E test project ready.\n'); - console.log('Verify in Claude Code:'); - console.log(` 1. cd ${PROJECT}`); - console.log(' 2. claude (approve the "capy" MCP server when prompted)'); - console.log(' 3. say: "sync my secrets"'); - console.log(' → the agent calls capy_sync → capy --web → the conflict resolver opens'); - console.log(' in YOUR browser. Pick values, Apply, and it commits to this fixture.\n'); - console.log('Re-run this script to refresh if the local session has expired.\n'); -} - -main().catch((e) => { - console.error('\n✗ setup failed:', e.message); - process.exit(1); -}); diff --git a/scripts/demo/verify-web-conflict.mjs b/scripts/demo/verify-web-conflict.mjs deleted file mode 100644 index c9f1bc36..00000000 --- a/scripts/demo/verify-web-conflict.mjs +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Full end-to-end verification of the `capy --web` sync conflict resolver, - * driven headlessly: it seeds the offline fixture, launches the REAL `capy --web` - * binary, plays the role of the browser (POSTs the user's per-variable choices to - * the loopback), then re-runs bare `capy` to prove the conflict committed. - * - * node scripts/demo/verify-web-conflict.mjs - * - * Exit 0 = the browser resolution round-tripped through the real CLI and the - * conflict is gone. Exit 1 = something broke. - */ -import { spawn } from 'child_process'; -import { join } from 'path'; -import { readFileSync } from 'fs'; -import { createHash } from 'crypto'; - -const REPO = new URL('../..', import.meta.url).pathname; -const HOME = '/tmp/capy-demo-home'; -const CAPY = join(REPO, 'bin', 'capy'); - -function run(cmd, args, opts = {}) { - return new Promise((resolve) => { - const cp = spawn(cmd, args, { ...opts, env: { ...process.env, HOME, ...(opts.env || {}) } }); - let out = ''; - let err = ''; - cp.stdout.on('data', (d) => { - out += d.toString(); - opts.onStdout?.(d.toString(), () => cp); - }); - cp.stderr.on('data', (d) => (err += d.toString())); - cp.on('close', (code) => resolve({ code, out, err })); - }); -} - -const URL_RE = /http:\/\/127\.0\.0\.1:(\d+)\/\?n=([a-f0-9]+)/; - -async function main() { - // 1. Seed the offline fixture (clean every run). - const seed = await run('bun', ['scripts/demo/seed.ts'], { cwd: REPO }); - if (seed.code !== 0) throw new Error(`seed failed:\n${seed.err}`); - const projectDir = seed.out.trim().split('\n').pop(); - console.log(`• seeded fixture: ${projectDir}`); - - // 2. Launch `capy --web`; play the browser when it prints the loopback URL. - let posted = false; - const web = await run('node', [CAPY, '--web'], { - cwd: projectDir, - env: { CAPY_WEB_NO_OPEN: '1' }, // headless: drive the loopback, don't open a browser - onStdout: async (chunk, getCp) => { - const m = chunk.match(URL_RE); - if (!m || posted) return; - posted = true; - const base = `http://127.0.0.1:${m[1]}`; - const nonce = m[2]; - console.log(`• capy --web opened loopback on ${base} — playing the browser`); - // Keep PINNED baseline for API_KEY, keep LOCAL edit for DATABASE_URL. - await new Promise((r) => setTimeout(r, 100)); - const res = await fetch(`${base}/submit`, { - method: 'POST', - headers: { 'content-type': 'application/json', origin: base }, - body: JSON.stringify({ - nonce, - // The compiled screen answers two levels: the whole-run action, and - // the per-variable sources only when that action is `individual`. - // The old flat shape ({ API_KEY: 'pinned' }) is refused now rather - // than half-applied, which is what this script proved by hanging on - // the refusal it did not expect. - payload: { - __action: 'submit', - action: 'individual', - choices: { API_KEY: 'pinned', DATABASE_URL: 'local' }, - }, - }), - }); - const body = await res.json(); - console.log(`• POST /submit → ${res.status} ${JSON.stringify(body)}`); - // An inline refusal keeps the wizard open by design, so a script that - // ignores one waits for the idle timeout and is killed with no reason - // printed. Say what happened instead. - if (body && body.error) { - console.error(`\n✗ E2E FAIL: the CLI refused the submit — ${body.error}`); - process.exit(1); - } - }, - }); - if (web.code !== 0) throw new Error(`capy --web exited ${web.code}:\n${web.err}\n${web.out}`); - if (!posted) throw new Error(`capy --web never printed a loopback URL:\n${web.out}`); - - // 3. Re-run bare `capy`: the conflict must be gone now. - const after = await run('node', [CAPY], { cwd: projectDir, stdio: ['ignore', 'pipe', 'pipe'] }); - const clean = /Everything is up to date/.test(after.out); - console.log(`• re-run bare capy → ${clean ? 'CLEAN (no conflict)' : 'STILL DIRTY'}`); - if (!clean) throw new Error(`expected a clean sync after web-resolve, got:\n${after.out}`); - - // 4. Value-level check: "up to date" alone can't tell a correct resolution from - // a silent deletion (keep.lock + .env stay consistent either way). Assert the - // committed hashes match the CHOICES: API_KEY=pinned baseline, DATABASE_URL=local edit. - const keep = JSON.parse(readFileSync(join(projectDir, 'keep.lock'), 'utf8')); - const hashOf = (v) => createHash('sha256').update(v).digest('hex').slice(0, 16); - const committed = (name) => keep.variables[name]?.find((e) => e.branch === 'development')?.value_hash; - const checks = [ - ['API_KEY', hashOf('sk_baseline_0001'), 'pinned baseline restored'], - ['DATABASE_URL', hashOf('postgres://localhost:5432/app_staging'), 'local edit kept'], - ['STRIPE_KEY', hashOf('pk_test_unchanged'), 'unchanged carried over'], - ]; - for (const [name, want, label] of checks) { - const got = committed(name); - if (got !== want) throw new Error(`${name}: expected ${label} (${want}), got ${got}`); - console.log(`• ${name}: ${label} ✓`); - } - console.log('\n✓ E2E PASS — browser resolution committed the CORRECT values through the real CLI.'); -} - -main().catch((e) => { - console.error('\n✗ E2E FAIL:', e.message); - process.exit(1); -}); diff --git a/scripts/demo/verify-web-onboarding.mjs b/scripts/demo/verify-web-onboarding.mjs deleted file mode 100644 index 50b88d4f..00000000 --- a/scripts/demo/verify-web-onboarding.mjs +++ /dev/null @@ -1,136 +0,0 @@ -/** - * End-to-end verification of `capy byoc --web` (local-only onboarding in the - * browser), driven headlessly. It plays the browser through the trainstops - * (generate phrase → confirm → set passphrase), then asserts: - * - * 1. setup completed offline (local profile + wrapped key written), - * 2. a fresh project bootstraps afterward (keep.lock written), - * 3. SECURITY: the 24-word recovery phrase — captured from the loopback page — - * NEVER appears in the CLI's stdout/stderr. This is the invariant that keeps - * the phrase out of an MCP/model's view when an agent shells this command. - * - * node scripts/demo/verify-web-onboarding.mjs - */ -import { spawn } from 'child_process'; -import { join } from 'path'; -import { existsSync, rmSync, mkdirSync, readFileSync } from 'fs'; - -const REPO = new URL('../..', import.meta.url).pathname; -const HOME = '/tmp/capy-onboard-home'; -const CAPY = join(REPO, 'bin', 'capy'); - -function run(cmd, args, opts = {}) { - return new Promise((resolve) => { - const cp = spawn(cmd, args, { ...opts, env: { ...process.env, HOME, ...(opts.env || {}) } }); - let out = ''; - let err = ''; - cp.stdout.on('data', (d) => { - out += d.toString(); - opts.onStdout?.(d.toString()); - }); - cp.stderr.on('data', (d) => (err += d.toString())); - cp.on('close', (code) => resolve({ code, out, err })); - }); -} - -const URL_RE = /http:\/\/127\.0\.0\.1:(\d+)\/\?n=([a-f0-9]+)/; -const headers = { 'content-type': 'application/json' }; -const post = (base, nonce, payload) => - fetch(`${base}/submit`, { method: 'POST', headers: { ...headers, origin: base }, body: JSON.stringify({ nonce, payload }) }); - -// Every step of this flow is a compiled screen, which is a whole document: the -// answer comes back as `{ next: true }` and the browser RELOADS to receive the -// next step. So the page is fetched rather than read out of the submit's reply. -const page = (base, nonce) => fetch(`${base}/?n=${nonce}`).then((r) => r.text()); - -/** - * Pull the 24 words out of the phrase step's payload. - * - * The screen renders from `window.__CAPY_DATA__`, inlined into the document at - * serve time, so `phraseWords` is exactly what the person in front of the page - * is being shown — and reading it here is the only way this script can hold the - * leak check, which compares those words against the CLI's own output. - */ -function extractPhrase(html) { - const m = html.match(/"phraseWords":\s*(\[[^\]]*\])/); - if (!m) return ''; - return JSON.parse(m[1]).join(' '); -} - -async function main() { - // Fresh, empty fixture home (no profile yet). - rmSync(HOME, { recursive: true, force: true }); - mkdirSync(HOME, { recursive: true }); - - let phrase = ''; - const byoc = await run('node', [CAPY, 'byoc', '--web'], { - env: { CAPY_WEB_NO_OPEN: '1' }, - onStdout: async (chunk) => { - const m = chunk.match(URL_RE); - if (!m || phrase) return; - const base = `http://127.0.0.1:${m[1]}`; - const nonce = m[2]; - console.log(`• byoc --web loopback on ${base} — playing the browser`); - // Step 0: where the phrase comes from. Two rows, one of them this. - const s1 = await (await post(base, nonce, { source: 'generate' })).json(); - if (s1.error) throw new Error(`phrase-source step refused: ${s1.error}`); - if (!s1.next) throw new Error(`expected the phrase step to be next, got ${JSON.stringify(s1)}`); - phrase = extractPhrase(await page(base, nonce)); - if (phrase.split(' ').length !== 24) throw new Error(`expected 24 words in page, got ${phrase.split(' ').length}`); - console.log(`• captured the 24-word phrase from the page (kept only for the leak check)`); - // Step 1: consent, and consent only. The words travel one way, so this - // step answers with a boolean — a payload carrying them is refused. - const guard = await (await post(base, nonce, { confirmed: true, phrase })).json(); - if (!guard.error) throw new Error('the phrase step accepted the words back — it must not'); - console.log(`• the page refused to send the phrase back ✓ (${guard.error})`); - const s2 = await (await post(base, nonce, { confirmed: true })).json(); - if (!s2.next) throw new Error(`expected the passphrase step to be next, got ${JSON.stringify(s2)}`); - // Step 2: set passphrase. - const s3 = await (await post(base, nonce, { passphrase: 'demo-passphrase', confirm: 'demo-passphrase' })).json(); - console.log(`• POST passphrase → ${JSON.stringify(s3)}`); - if (!s3.done) throw new Error(`setup did not finish: ${JSON.stringify(s3)}`); - }, - }); - if (byoc.code !== 0) throw new Error(`byoc --web exited ${byoc.code}:\n${byoc.err}\n${byoc.out}`); - - // 1. Setup completed: local profile + wrapped key on disk. - const cfg = JSON.parse(readFileSync(join(HOME, '.capy', 'config.json'), 'utf8')); - if (cfg.profiles?.local?.localOnly !== true) throw new Error('local profile not active/localOnly'); - if (!existsSync(join(HOME, '.capy', 'local', 'key.local'))) throw new Error('wrapped local key not written'); - console.log('• local profile active + wrapped key written ✓'); - - // 2. SECURITY: the phrase must NOT be anywhere in the CLI's terminal output. - const terminal = byoc.out + byoc.err; - if (!phrase || phrase.split(' ').length !== 24) throw new Error('failed to capture phrase for the leak check'); - if (terminal.includes(phrase)) throw new Error('LEAK: recovery phrase appeared in CLI stdout/stderr'); - // Also defend against partial leaks: no 4-consecutive-word window should appear. - const words = phrase.split(' '); - for (let i = 0; i + 4 <= words.length; i++) { - const window = words.slice(i, i + 4).join(' '); - if (terminal.includes(window)) throw new Error(`LEAK: phrase fragment "${window}" in CLI output`); - } - console.log('• recovery phrase never appeared in the terminal ✓ (stays in the browser only)'); - - // 3. A fresh project bootstraps afterward (session is unlocked → no re-prompt). - const projectDir = join(HOME, 'fresh-project'); - rmSync(projectDir, { recursive: true, force: true }); - mkdirSync(projectDir, { recursive: true }); - // CAPY_WEB_NO_OPEN on this one too: a demo script must never be able to take - // over the browser of whoever is running it. - const boot = await run('node', [CAPY], { - cwd: projectDir, - stdio: ['ignore', 'pipe', 'pipe'], - env: { CAPY_WEB_NO_OPEN: '1' }, - }); - if (!existsSync(join(projectDir, 'keep.lock'))) { - throw new Error(`fresh project did not bootstrap a keep.lock:\n${boot.out}\n${boot.err}`); - } - console.log('• fresh project bootstrapped a keep.lock after onboarding ✓'); - - console.log('\n✓ E2E PASS — browser onboarding set up local mode offline, leaked nothing, and a project is ready.'); -} - -main().catch((e) => { - console.error('\n✗ E2E FAIL:', e.message); - process.exit(1); -}); diff --git a/src/commands/addCommand.ts b/src/commands/addCommand.ts index 8933ed7b..e09669e0 100644 --- a/src/commands/addCommand.ts +++ b/src/commands/addCommand.ts @@ -1,10 +1,7 @@ -import { EventEmitter, once } from 'node:events'; import { CapyError, ERROR_CODES } from '../types'; import { resolveContext, writeAndSync, type ResolvedContext } from './connectors/shared'; import { MAX_PIPED_BYTES, readPipedValue, refuseInvalidName, refusePiped } from './pipedValue'; import { runPipedWrite, variableExists } from './pipedWrite'; -import { runWebIntake, parseVars, type SecretPair } from '../ui/secretIntakeScreen'; -import type { IntakeVar } from '../ui/screens/contract'; import { recordsForWrite, refuseBadPrFlags, @@ -13,18 +10,13 @@ import { type PrFlags, } from './keepLockPr'; -// The intake moved to `ui/secretIntakeScreen.ts` with the compiled screen it -// now serves. Re-exported here because this is where the flow is entered from -// and where its tests have always looked for it. -export { runWebIntake, parseVars, type SecretPair }; +/** One name and the value entered for it. */ +export interface SecretPair { + readonly name: string; + readonly value: string; +} export interface AddOpts { - web?: boolean; - reason?: string; - /** Repeatable `--help-url NAME=URL` pairs: a per-variable "where to find this" link. */ - helpUrls?: string[]; - /** false when --no-open was passed (commander negation). */ - open?: boolean; noPush?: boolean; force?: boolean; nonTty?: boolean; @@ -36,42 +28,13 @@ export interface AddOpts { const VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; -/** Parse repeatable `--help-url NAME=URL` flags into a name→url map (http(s) only). */ -export function parseHelpUrls(pairs: string[] | undefined): Record { - return Object.fromEntries( - (pairs ?? []).flatMap((pair) => { - const eq = pair.indexOf('='); - if (eq <= 0) return []; - const name = pair.slice(0, eq).trim(); - const url = pair.slice(eq + 1).trim(); - return VAR_RE.test(name) && /^https?:\/\//i.test(url) ? [[name, url] as const] : []; - }), - ); -} - -/** - * The terminal's overwrite confirm, in a form the intake page can carry. - * - * The CLI's own sentence, verbatim, because two wordings for one thing is a - * bug. `--web` used to skip this question entirely — the confirm is gated on - * `!opts.web` — so a browser intake silently overwrote existing values with no - * mention anywhere. It cannot be a second screen (the intake form is the whole - * flow and has no confirm step), so it is stated above the form and the Save - * button is the answer: closing the window changes nothing, which is what - * refusing meant in the terminal too. - */ -export function overwriteNotice(existing: string[]): string | undefined { - return existing.length > 0 ? `${existing.join(', ')} already exist(s). Overwrite?` : undefined; -} - /** - * Piped mode is "a value arrives on stdin, and there is no `--web`". It is - * decided by stdin alone. `--non-tty` with a real terminal on stdin is NOT - * piped (nothing was piped, and reading the keyboard would hang), so it still - * reaches the refusal below that points at `--web`. + * Piped mode is "a value arrives on stdin". It is decided by stdin alone. + * `--non-tty` with a real terminal on stdin is NOT piped (nothing was piped, + * and reading the keyboard would hang), so it reaches the refusal below. */ -export function isPipedAdd(opts: AddOpts): boolean { - return opts.web !== true && process.stdin.isTTY !== true; +export function isPipedAdd(): boolean { + return process.stdin.isTTY !== true; } /** Asks for each value in turn, hidden. Sequential by construction: one prompt, then the rest. */ @@ -110,7 +73,7 @@ export class AddCommand { refuseBadPrFlags(opts.pr ?? {}, opts.json === true); // ` | capy add NAME`: the value comes from stdin and nothing prompts. - if (isPipedAdd(opts)) return this.executePiped(names, opts); + if (isPipedAdd()) return this.executePiped(names, opts); if (names.length === 0) { throw new CapyError('No variable name given.', ERROR_CODES.INVALID_FORMAT); @@ -125,7 +88,7 @@ export class AddCommand { const push = opts.noPush !== true; const existing = names.filter((n) => n in ctx.localPlaintext); - if (existing.length > 0 && !opts.force && !opts.web && !opts.nonTty) { + if (existing.length > 0 && !opts.force && !opts.nonTty) { const inquirer = (await import('inquirer')).default; const { ok } = await inquirer.prompt([ { type: 'confirm', name: 'ok', message: `${existing.join(', ')} already exist(s). Overwrite?`, default: false }, @@ -136,11 +99,7 @@ export class AddCommand { } } - const savedNames = opts.web - ? await this.collectViaBrowser(ctx, names, existing, opts, push) - : await this.collectViaTerminal(ctx, names, opts, push); - // The browser was closed without saving: already reported, nothing to confirm. - if (savedNames === undefined) return; + const savedNames = await this.collectViaTerminal(ctx, names, opts, push); const where = push ? ` and synced to ${ctx.branch}` : ' (.env only — not pushed)'; console.log(`✓ Saved ${savedNames.length} variable(s): ${savedNames.join(', ')}${where}.`); @@ -193,66 +152,6 @@ export class AddCommand { }); } - /** - * The browser intake. Returns the names that were saved, or `undefined` when - * the form was closed without saving (reported here). - */ - private async collectViaBrowser( - ctx: ResolvedContext, - names: readonly string[], - existing: readonly string[], - opts: AddOpts, - push: boolean, - ): Promise { - const helpUrls = parseHelpUrls(opts.helpUrls); - const vars: IntakeVar[] = names.map((name) => ({ name, helpUrl: helpUrls[name] })); - // The overwrite warning goes above whatever the caller asked for: the - // consequence of pressing Save outranks the note explaining why the form - // was opened. - const warning = opts.force ? undefined : overwriteNotice([...existing]); - const reason = [warning, opts.reason].filter(Boolean).join(' ') || undefined; - - // `onSubmit` having run is the only signal that the form was filled in (a - // closed window and a step nobody answered both resolve the intake the same - // way). It is announced on a one-shot channel rather than written to a - // variable, so nothing here is reassigned. - const bus = new EventEmitter(); - const submitted = once(bus, 'submitted'); - await runWebIntake( - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI and - // headless runs drive the loopback without hijacking a real browser. - { vars, reason, open: opts.open !== false && !process.env.CAPY_WEB_NO_OPEN }, - async (pairs) => { - await writeInOrder(ctx, pairs, push); - bus.emit('submitted', pairs.map((p) => p.name)); - }, - ); - // First in the list wins when both are already settled: a submission that - // happened beats the "nothing was entered" fallback. - const [captured] = (await Promise.race([submitted, Promise.resolve([[]] as [string[]])])) as [string[]]; - - // The intake resolves whether or not it was filled in — a closed window - // and a step nobody answered both land here — and this is the only - // signal that separates them from a save. Without it the command ran on - // to `✓ Saved 0 variable(s): ` and reported a refusal as a success, - // which for an overwrite is the difference between "I left it alone" and - // "I replaced it". - // - // A PRINTED LINE AND A NORMAL RETURN, not a throw. Closing the window is - // a refusal, and a refusal is one of the two endings this flow HAS — it - // is not a fault. Throwing here reached the process-level - // `unhandledRejection` handler (nothing between here and `program.parse` - // catches it), which printed the sentence a second time under a node - // stack trace and exited 1: a person who declined an overwrite was shown - // a crash. The terminal path for the identical refusal prints `Aborted.` - // and returns 0. - if (captured.length === 0) { - console.log('\n Nothing was added. The browser was closed without saving.\n'); - return undefined; - } - return captured; - } - /** The hidden terminal prompt, one value per name. */ private async collectViaTerminal( ctx: ResolvedContext, @@ -262,7 +161,8 @@ export class AddCommand { ): Promise { if (opts.nonTty) { throw new CapyError( - 'Non-interactive add requires --web (browser intake). Re-run with --web.', + // COPY-FLAG + 'Non-interactive add reads the value from stdin: | capy add NAME', ERROR_CODES.INVALID_FORMAT, ); } diff --git a/src/commands/byocCommand.ts b/src/commands/byocCommand.ts index 0b6c7659..bd640aa1 100644 --- a/src/commands/byocCommand.ts +++ b/src/commands/byocCommand.ts @@ -30,9 +30,6 @@ import { import { saveLocalKey } from '../crypto/keyResolver'; import { saveLocalSession } from '../config/globalConfig'; import { displayAndConfirmRecoveryPhrase } from '../ui/recoveryPhrase'; -// Type-only: erased at compile time, so the TTY path does not pull the browser -// screens into its module graph. -import type { ProbeCode, ProbeOutcome } from '../ui/screens/contract'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; const DIM = (s: string) => `\x1b[90m${s}\x1b[0m`; @@ -42,6 +39,16 @@ const RED = (s: string) => `\x1b[31m${s}\x1b[0m`; const DEFAULT_URL = 'https://capy.internal'; const PROBE_TIMEOUT_MS = 4000; +/** Why a probe came out the way it did, as a code. */ +type ProbeCode = + | 'ok' + | 'connection_failed' + | 'tls_untrusted' + | 'http_status' + | 'not_json' + | 'not_capy' + | 'ca_unreadable'; + interface ProbeOk { ok: true; url: string; @@ -57,25 +64,15 @@ interface ProbeFail { * Why it failed, as a code. * * The reason is the sentence a human reads; this is what anything decides - * on. Exactly one code opens the CA-bundle sub-flow, and picking that out by - * searching for "self-signed" in a string is a bug waiting for a reword — - * which is what `selfSignedFor` was standing in for before this existed. + * on. Exactly one code opens the CA-bundle sub-flow. */ code: ProbeCode; - /** `err.cause.code || err.code || err.name` on a transport failure. */ - transportCode?: string; - /** Set when `code` is `http_status`. */ - httpStatus?: number; - /** What the untrusted certificate says. Best effort; only on `tls_untrusted`. */ - cert?: ProbeOutcome['cert']; } type ProbeResult = ProbeOk | ProbeFail; function normalizeUrl(input: string): string { - let url = input.trim(); - if (!/^https?:\/\//i.test(url)) { - url = `https://${url}`; - } + const trimmed = input.trim(); + const url = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; // Strip trailing slash so `${url}/path` doesn't double-slash. return url.replace(/\/+$/, ''); } @@ -93,54 +90,36 @@ function isCertError(err: any): boolean { ); } -/** - * Ask `{url}/health` whether it is a Capy service. - * - * `withCert` is opt-in and costs a second handshake, so only the browser flow - * asks for it: that flow puts the certificate on screen before asking anyone - * to trust its authority, and the terminal has nowhere to show it and would be - * paying up to four extra seconds to print the same one red line. - */ -async function probe( - url: string, - caBundle?: string, - opts: { withCert?: boolean } = {}, -): Promise { - // If the caller supplied a CA bundle, build a one-shot dispatcher for this - // probe so we can validate the bundle before saving it into the profile. - let dispatcher: any = undefined; - if (caBundle) { - try { - const ca = readFileSync(expandHome(caBundle), 'utf-8'); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const undici = require('undici') as { Agent: new (opts: any) => any }; - dispatcher = new undici.Agent({ connect: { ca } }); - } catch (err) { - return { - ok: false, - code: 'ca_unreadable', - reason: `cannot read CA bundle: ${(err as Error).message}`, - }; - } +/** A one-shot undici dispatcher trusting `caBundle`, or the failure to read it. */ +function dispatcherFor(caBundle: string): { dispatcher: any } | ProbeFail { + try { + const ca = readFileSync(expandHome(caBundle), 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const undici = require('undici') as { Agent: new (opts: any) => any }; + return { dispatcher: new undici.Agent({ connect: { ca } }) }; + } catch (err) { + return { + ok: false, + code: 'ca_unreadable', + reason: `cannot read CA bundle: ${(err as Error).message}`, + }; } +} - let res: Response; +/** The `/health` response, or the transport failure as a probe result. */ +async function fetchHealth(url: string, dispatcher: any): Promise<{ res: Response } | ProbeFail> { try { - res = await fetch(`${url}/health`, { + const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), ...(dispatcher ? { dispatcher } as any : {}), }); + return { res }; } catch (err: any) { if (isCertError(err)) { return { ok: false, code: 'tls_untrusted', reason: 'self-signed or untrusted TLS certificate', - // Best effort, and only once we already know the handshake failed on - // trust: the browser flow asks the user to trust a certificate - // authority, and the terminal asks that with a default of yes while - // showing nothing about the certificate at all. - cert: opts.withCert ? await peekCertificate(url) : undefined, }; } const code = err?.cause?.code || err?.code || err?.name || 'unknown'; @@ -148,27 +127,44 @@ async function probe( ok: false, code: 'connection_failed', reason: `connection failed (${code})`, - transportCode: String(code), }; } +} + +/** The parsed JSON body, or `undefined` when the response was not JSON. */ +async function readJsonBody(res: Response): Promise<{ body: any } | undefined> { + try { + return { body: await res.json() }; + } catch { + return undefined; + } +} + +/** Ask `{url}/health` whether it is a Capy service. */ +async function probe(url: string, caBundle?: string): Promise { + // If the caller supplied a CA bundle, build a one-shot dispatcher for this + // probe so we can validate the bundle before saving it into the profile. + const built = caBundle ? dispatcherFor(caBundle) : { dispatcher: undefined }; + if ('ok' in built) return built; + + const fetched = await fetchHealth(url, built.dispatcher); + if ('ok' in fetched) return fetched; + const { res } = fetched; if (res.status !== 200) { return { ok: false, code: 'http_status', reason: `health endpoint returned HTTP ${res.status}`, - httpStatus: res.status, }; } - let body: any; - try { - body = await res.json(); - } catch { + const parsed = await readJsonBody(res); + if (!parsed) { return { ok: false, code: 'not_json', reason: 'health endpoint did not return JSON' }; } - if (!body || body.service !== 'capy') { + if (!parsed.body || parsed.body.service !== 'capy') { return { ok: false, code: 'not_capy', @@ -185,67 +181,6 @@ async function probe( }; } -/** - * Read what the untrusted certificate actually says. - * - * A second handshake with verification off, purely to look: the fetch above - * has already refused this endpoint, and nothing is sent — no credentials, no - * request, the socket is closed as soon as the peer's certificate is on the - * table. Best effort throughout; an instance that will not complete even an - * unverified handshake simply has no facts to show, and the flow says so by - * having none rather than by inventing any. - */ -async function peekCertificate(url: string): Promise { - let target: URL; - try { - target = new URL(url); - } catch { - return undefined; - } - if (target.protocol !== 'https:') return undefined; - - const tls = await import('tls'); - return new Promise((resolve) => { - const done = (value: ProbeOutcome['cert']): void => { - clearTimeout(timer); - try { - socket.destroy(); - } catch { - /* already gone */ - } - resolve(value); - }; - const socket = tls.connect( - { - host: target.hostname, - port: Number(target.port) || 443, - servername: target.hostname, - rejectUnauthorized: false, - }, - () => { - const peer = socket.getPeerCertificate(); - if (!peer || !peer.subject) return done(undefined); - const expiresAt = new Date(peer.valid_to); - const valid = !Number.isNaN(expiresAt.getTime()); - // A DN field can repeat (two OUs, two CNs), and node hands back an - // array when it does. One line either way. - const one = (v: string | string[] | undefined): string | undefined => - Array.isArray(v) ? v.join(', ') : v; - done({ - subject: one(peer.subject.CN) || one(peer.subject.O) || '(unnamed)', - issuer: one(peer.issuer?.CN) || one(peer.issuer?.O) || '(unnamed)', - // Already formatted here, because the screen treats it as display. - expires: valid ? expiresAt.toISOString().slice(0, 10) : peer.valid_to, - expired: valid ? expiresAt.getTime() < Date.now() : false, - }); - }, - ); - socket.on('error', () => done(undefined)); - const timer = setTimeout(() => done(undefined), PROBE_TIMEOUT_MS); - timer.unref?.(); - }); -} - function expandHome(p: string): string { if (p.startsWith('~/')) return join(homedir(), p.slice(2)); return p; @@ -344,41 +279,34 @@ async function promptForProfileName(suggested: string): Promise { } /** - * The probe-and-prompt loop. Tries `initial` first, then keeps asking on + * The probe-and-prompt loop. Tries `url` first, then keeps asking on * failure. The only way out (other than success) is the user hitting * Ctrl-C, which the top-level uncaughtException handler converts into a * clean exit. */ -async function probeWithRetries(initial: string): Promise { - let url = initial; - let caBundle: string | undefined; +async function probeWithRetries(url: string, caBundle?: string): Promise { + process.stdout.write(`Trying ${url}/health ... `); + const result = await probe(url, caBundle); - while (true) { - process.stdout.write(`Trying ${url}/health ... `); - const result = await probe(url, caBundle); - - if (result.ok) { - console.log(GREEN('✓') + ' found (capy service detected)'); - return result; - } + if (result.ok) { + console.log(GREEN('✓') + ' found (capy service detected)'); + return result; + } - console.log(RED('✗') + ' ' + result.reason); - - if (result.code === 'tls_untrusted' && !caBundle) { - // Offer the CA-bundle path first — most likely fix when the URL is - // right and only the cert is self-signed. Keyed off the code, never off - // the sentence: this is the one outcome that opens the sub-flow. - const bundle = await promptForCaBundle(); - if (bundle) { - caBundle = bundle; - continue; // re-probe the same URL with the bundle - } - // User declined; fall through to URL prompt. - } + console.log(RED('✗') + ' ' + result.reason); - url = await promptForUrl(url); - caBundle = undefined; // new URL, drop the old bundle assumption + if (result.code === 'tls_untrusted' && !caBundle) { + // Offer the CA-bundle path first — most likely fix when the URL is + // right and only the cert is self-signed. Keyed off the code, never off + // the sentence: this is the one outcome that opens the sub-flow. + const bundle = await promptForCaBundle(); + // Re-probe the same URL with the bundle. User declined: fall through to + // the URL prompt. + if (bundle) return probeWithRetries(url, bundle); } + + // A new URL drops the old bundle assumption. + return probeWithRetries(await promptForUrl(url)); } /** @@ -419,13 +347,7 @@ async function promptForRecoveryPhrase(): Promise { return phrase; } -/** - * The warning the CLI prints beside a local-mode recovery phrase. - * - * Named so the browser path can carry the same words. Two wordings for one - * warning is the thing that makes a product feel like two products, and this - * particular warning is the one nobody gets a second chance to read. - */ +/** The warning the CLI prints beside a local-mode recovery phrase. */ export const LOCAL_PHRASE_NOTES = [ 'This recovery phrase generates the master key for', 'every project on this machine.', @@ -438,49 +360,40 @@ export const LOCAL_PHRASE_NOTES = [ 'IF YOU LOSE THIS PHRASE WE CANNOT HELP YOU!', ]; -/** - * The floor a local passphrase has to clear. - * - * One definition, handed to the browser path as well: the page holds its - * button on this number and the CLI refuses on it, and a page whose floor had - * drifted from the validator's would offer a button that cannot be pressed. - */ +/** The floor a local passphrase has to clear. */ export const MIN_PASSPHRASE_LENGTH = 8; /** Prompt for a local passphrase with confirmation. */ async function promptForPassphrase(): Promise { const inquirer = (await import('inquirer')).default; - while (true) { - const { passphrase } = await inquirer.prompt([ - { - type: 'password', - name: 'passphrase', - message: 'Set a local passphrase (locks your key on this machine):', - mask: '*', - validate: (input: string) => - input.length >= MIN_PASSPHRASE_LENGTH || - `Use at least ${MIN_PASSPHRASE_LENGTH} characters`, - }, - ]); - const { confirm } = await inquirer.prompt([ - { - type: 'password', - name: 'confirm', - message: 'Confirm passphrase:', - mask: '*', - }, - ]); - if (passphrase === confirm) return passphrase; - console.log(RED('✗') + ' Passphrases do not match. Try again.'); - } + const { passphrase } = await inquirer.prompt([ + { + type: 'password', + name: 'passphrase', + message: 'Set a local passphrase (locks your key on this machine):', + mask: '*', + validate: (input: string) => + input.length >= MIN_PASSPHRASE_LENGTH || + `Use at least ${MIN_PASSPHRASE_LENGTH} characters`, + }, + ]); + const { confirm } = await inquirer.prompt([ + { + type: 'password', + name: 'confirm', + message: 'Confirm passphrase:', + mask: '*', + }, + ]); + if (passphrase === confirm) return passphrase; + console.log(RED('✗') + ' Passphrases do not match. Try again.'); + return promptForPassphrase(); } /** * Derive M from a recovery phrase, wrap it at rest with a passphrase, save the - * `local` profile, and open an unlocked session. Shared by the TTY (`localSetup`) - * and browser (`localSetupWeb`) onboarding paths so the crypto/writes are - * identical regardless of how the phrase + passphrase were collected. Throws on - * failure. The phrase is consumed here and never returned or logged. + * `local` profile, and open an unlocked session. Throws on failure. The + * phrase is consumed here and never returned or logged. */ export function finalizeLocalSetup(phrase: string, passphrase: string): void { const masterKey = seedPhraseToMasterKey(phrase); @@ -517,44 +430,6 @@ async function localSetup(): Promise { return 0; } -/** - * Browser-rendered local-only setup (`capy byoc --web`). The recovery phrase is - * shown in the loopback page and never touches this terminal — so an agent - * driving this through the MCP never sees it. - */ -async function localSetupWeb(): Promise { - const { runLocalOnboardingWeb } = await import('../ui/onboardingWeb'); - console.log(DIM(' Setting up local mode in your browser — your recovery phrase stays in the page, never the terminal.')); - - let ok = false; - try { - ok = await runLocalOnboardingWeb( - (phrase, passphrase) => finalizeLocalSetup(phrase, passphrase), - { - open: !process.env.CAPY_WEB_NO_OPEN, - bodyLines: LOCAL_PHRASE_NOTES, - minPassphraseLength: MIN_PASSPHRASE_LENGTH, - }, - ); - } catch (err: any) { - console.error(`Failed to set up local mode: ${err.message}`); - return 1; - } - if (!ok) { - console.log(''); - console.log(' Setup cancelled.'); - return 1; - } - - console.log(''); - console.log(`${GREEN('✓')} Local mode ready — profile ${B('local')} active`); - console.log(''); - console.log(` Run ${B('capy')} in a project directory to start.`); - console.log(DIM(' Lock the key any time with `capy lock`.')); - console.log(''); - return 0; -} - async function promptUseLocalMode(): Promise { const inquirer = (await import('inquirer')).default; const { local } = await inquirer.prompt([ @@ -568,98 +443,18 @@ async function promptUseLocalMode(): Promise { return local; } -/** - * Connect to a self-hosted instance, in the browser. - * - * The terminal's version of this is a `while (true)` whose only exit other than - * success is Ctrl-C, so a run that mistypes a host it no longer remembers has - * no way out at all. Here every step has a Cancel, and every failure keeps the - * distinction between "that name did not resolve", "nothing is listening" and - * "nothing answered in four seconds", which the terminal prints as three - * variants of one red line and then asks the same question about. - */ -async function connectSetupWeb(initialUrl?: string): Promise { - const { connectByocInBrowser } = await import('../ui/byocScreens'); - const existing = readProfileConfig(); - - const settled = await connectByocInBrowser({ - defaultUrl: normalizeUrl(initialUrl || DEFAULT_URL), - urlSource: initialUrl ? 'argv' : 'builtin', - probe: async (url, caBundle) => { - const result = await probe(url, caBundle, { withCert: true }); - return { - url, - code: result.code, - reason: result.reason, - ...(result.ok - ? {} - : { - httpStatus: result.httpStatus, - transportCode: result.transportCode, - cert: result.cert, - }), - ...(caBundle ? { caBundle } : {}), - }; - }, - suggestName: deriveProfileName, - existingProfiles: Object.entries(existing?.profiles ?? {}) - .filter(([, p]) => Boolean(p.url)) - .map(([name, p]) => ({ name, url: p.url!, active: existing?.default === name })), - open: !process.env.CAPY_WEB_NO_OPEN, - }); - - if (settled.cancelled) { - console.log(''); - console.log(' Cancelled. Every profile you already have is exactly as it was.'); - console.log(''); - return 1; - } - - const profile: Profile = { url: settled.url }; - if (settled.caBundle) profile.caBundle = expandHome(settled.caBundle); - +/** The probe-and-prompt loop's result, or the error it ended on. */ +async function probeOrError(startUrl: string): Promise<{ result: ProbeOk } | { error: string }> { try { - saveAndActivateProfile(settled.profileName, profile); + return { result: await probeWithRetries(startUrl) }; } catch (err: any) { - console.error(`Failed to save profile: ${err.message}`); - return 1; + return { error: err.message }; } - - console.log(''); - console.log(`${GREEN('✓')} Saved profile ${B(settled.profileName)} and switched`); - console.log(''); - console.log(` Run ${B('capy')}.`); - console.log(DIM(' (first authenticated command triggers login)')); - console.log(''); - return 0; } -export async function byocCommand( - initialUrl?: string, - opts: { web?: boolean } = {}, -): Promise { +export async function byocCommand(initialUrl?: string): Promise { console.log(''); - if (opts.web) { - // "Use in local mode?" is the one question `--web` has no screen for: the - // local flow renders a recovery phrase and the connect flow renders a - // server URL, and neither screen has a fork to the other. The argument - // answers it instead — `capy byoc ` is an address to connect to, and - // `capy byoc` with nothing to connect to is the offline path. Until now - // the URL was accepted and silently dropped, and local mode was set up - // regardless. - // - // KNOWN DIVERGENCE, and it is a divergence in LOGIC rather than rendering, - // which is the one thing `--web` is not supposed to do: `capy byoc ` - // in a terminal can still answer yes and land in local mode, and here it - // cannot. Closing it needs the question to exist as a step somebody can - // answer — a fork at the head of `byoc-connect`, or a first stop of its - // own — which is a packages/ui change this parcel reports rather than - // makes. Until then the argument is the only signal there is, and reading - // it beats dropping it. - return initialUrl ? connectSetupWeb(initialUrl) : localSetupWeb(); - } - // Ask first: local-only mode skips the URL probe entirely — there is no // server to reach. if (await promptUseLocalMode()) { @@ -668,19 +463,17 @@ export async function byocCommand( const startUrl = normalizeUrl(initialUrl || DEFAULT_URL); - let result: ProbeOk; - try { - result = await probeWithRetries(startUrl); - } catch (err: any) { - console.error(`\nError: ${err.message}`); + const probed = await probeOrError(startUrl); + if ('error' in probed) { + console.error(`\nError: ${probed.error}`); return 1; } + const { result } = probed; const suggested = deriveProfileName(result.url); const name = await promptForProfileName(suggested); - const profile: Profile = { url: result.url }; - if (result.caBundle) profile.caBundle = result.caBundle; + const profile: Profile = { url: result.url, ...(result.caBundle ? { caBundle: result.caBundle } : {}) }; try { saveAndActivateProfile(name, profile); diff --git a/src/commands/capyCommand.ts b/src/commands/capyCommand.ts index d4fb1a55..d0f9d021 100644 --- a/src/commands/capyCommand.ts +++ b/src/commands/capyCommand.ts @@ -46,6 +46,7 @@ import { cleanupOrgData } from '../cleanup/orgCleanup'; import { compareSecrets, hashValue, formatSnippet } from './statusCommand'; import { ACCENT } from '../ui/colors'; import { installSyncHooks } from '../git/syncHooks'; +import { initProjectQuestion } from '../ui/projectQuestions'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -225,31 +226,6 @@ export class CapyCommand { promptPick: async (branches, defaultName) => { const grey = (s: string) => `\x1b[90m${s}\x1b[0m`; console.log('\nNo branch is checked out in this directory yet.'); - if (this.options.web) { - // The compiled branch list, which is the same listing `capy checkout` - // serves. It marks protection off `is_protected` — the terminal - // picker prints `(protected)` and then lets a 403 explain — and the - // rows come from the server, so a name that is not one of them did - // not come from this page. - // - // No row opens selected: this directory is on no branch, so there is - // nothing for the list to open on, and the CLI's `defaultName` - // preselection has no field on that screen to land in. - const { chooseBranchInBrowser } = await import('../ui/branchScreens'); - const { branch: chosen, cancelled } = await chooseBranchInBrowser({ - projectName: projectState.projectName || 'project', - activeBranch: null, - branches, - canDelete: false, - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI / - // headless verification drive the loopback without hijacking one. - open: !process.env.CAPY_WEB_NO_OPEN, - }); - if (cancelled) { - throw new CapyError('Branch selection cancelled', ERROR_CODES.AUTH_FAILED); - } - return chosen; - } const { selected: pick } = await inquirer.prompt([{ type: 'list', name: 'selected', @@ -350,46 +326,17 @@ export class CapyCommand { await this.syncProject(projectState); // One additional TTY-only prompt at the very end of a successful init — - // skipped under --web (that flow is browser-driven, not terminal - // prompts), skipped under --dry-run (a dry run must never prompt or - // write), and a no-op if AGENTS.md/CLAUDE.md already has the section. - if (!this.options.web && !this.options.dryRun) await offerAgentsSetupAfterInit(); + // skipped under --dry-run (a dry run must never prompt or write), and a + // no-op if AGENTS.md/CLAUDE.md already has the section. + if (!this.options.dryRun) await offerAgentsSetupAfterInit(); } - /** - * First run in this directory. - * - * Under `--web` the six questions below are stops on ONE declared route, - * served into one browser window by `InitWizardSession`. The window is opened - * by the first question and released here — on the way out, or on the way out - * through a failure, so a run that dies between two stops does not leave a - * page claiming to still be working on it. - */ + /** First run in this directory. */ private async initializeProject(): Promise { - // Imported only on the `--web` path: the module pulls in every compiled - // screen, and a terminal run has no use for them. - // - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI / headless - // verification drive the loopback without hijacking a real browser. - const wizard = this.options.web - ? new (await import('../ui/initWizardScreen')).InitWizardSession({ - open: !process.env.CAPY_WEB_NO_OPEN, - }) - : null; - try { - await this.runInitialization(wizard); - await wizard?.finish(); - // Same one-time, TTY-only offer as the local-only init path above — - // same --web and --dry-run gating. - if (!this.options.web && !this.options.dryRun) await offerAgentsSetupAfterInit(); - } catch (err) { - // The browser is holding a submit at this point, and it must not be told - // that submit worked. `abort` replaces the question with what stopped - // the run — carrying the error's CODE, and the remedy any call site that - // knew one declared with `willBlock` just before it threw. - await wizard?.abort(err); - throw err; - } + await this.runInitialization(); + // The same one-time, TTY-only offer as the local-only init path above — + // same --dry-run gating. + if (!this.options.dryRun) await offerAgentsSetupAfterInit(); } /** @@ -401,7 +348,6 @@ export class CapyCommand { private async resolveSelectedOrganization( orgs: Organization[], authResult: AuthResult, - wizard: import('../ui/initWizardScreen').InitWizardSession | null, refreshToken: string | undefined, ): Promise { const CREATE_NEW_ORG = '__create_new__'; @@ -410,26 +356,13 @@ export class CapyCommand { if (orgs.length === 0) { console.log('\nNo organization found. Let\'s create one.'); - const created = await this.createNewOrganization(refreshToken!, authResult.user_id!); - wizard?.record({ - organization: { kind: 'new', name: created.name }, - recoveryShown: true, - }); - return created; + return this.createNewOrganization(refreshToken!, authResult.user_id!); } - const orgId = await this.resolveOrgIdChoice(wizard, orgs, currentOrgId, CREATE_NEW_ORG); + const orgId = await this.resolveOrgIdChoice(orgs, currentOrgId, CREATE_NEW_ORG); if (orgId === CREATE_NEW_ORG) { - const created = await this.createNewOrganization(refreshToken!, authResult.user_id!); - // Naming it and being shown the phrase both happened, elsewhere. The - // rail settles those two stops rather than leaving them ◌ behind a - // fork this run has already taken. - wizard?.record({ - organization: { kind: 'new', name: created.name }, - recoveryShown: true, - }); - return created; + return this.createNewOrganization(refreshToken!, authResult.user_id!); } if (currentOrg && orgId === currentOrg.id) { @@ -441,26 +374,12 @@ export class CapyCommand { return target; } - /** Asks (wizard or inquirer) which org id was chosen. Throws on cancel. */ + /** Asks which org id was chosen. */ private async resolveOrgIdChoice( - wizard: import('../ui/initWizardScreen').InitWizardSession | null, orgs: Organization[], currentOrgId: string | undefined, createNewOrgValue: string, ): Promise { - if (wizard) { - // No TTY under --web (e.g. driven through the MCP): the picker is the - // wizard's `organization` stop, which carries the same list and the - // same "create new" row an inquirer prompt would have shown — and, on - // the rail beside it, the five stops that come after. - const chosen = await wizard.askOrganization( - orgs.map(o => ({ id: o.id, name: o.name, isCurrent: o.id === currentOrgId })), - ); - if (chosen === null) { - throw new CapyError('Organization selection cancelled', ERROR_CODES.AUTH_FAILED); - } - return chosen === 'create' ? createNewOrgValue : chosen; - } const { orgId } = await inquirer.prompt([{ type: 'list', name: 'orgId', @@ -498,9 +417,7 @@ export class CapyCommand { orgSpinner.succeed(`Organization: ${org.name}`); } - private async runInitialization( - wizard: import('../ui/initWizardScreen').InitWizardSession | null, - ): Promise { + private async runInitialization(): Promise { this.debug('initializeProject start', { cwd: process.cwd() }); console.log('Welcome to Capy\n'); @@ -530,13 +447,6 @@ export class CapyCommand { spinner.succeed(`Authenticated as ${authResult.user_email || authResult.user_first_name} (${authResult._auth_method || 'oauth'})`); - // The first stop is settled before anything opens: the browser is only - // reached once there is a session, so `auth` is drawn done from the start. - wizard?.record({ - signedInAs: authResult.user_email || authResult.user_first_name || undefined, - orgCount: authResult.organizations?.length ?? 0, - }); - // Persist user ID to sync state immediately so the next `capy` run can find // the user-scoped session file at ~/.capy/auth/sessions/{userId}.json. // Without this, sync-state has no user_id, detectProjectState returns @@ -549,32 +459,12 @@ export class CapyCommand { // Resolve organization const orgs = authResult.organizations || []; const refreshToken = authResult._refresh_token || this.authService.getToken()?.refresh_token; - const selectedOrg = await this.resolveSelectedOrganization(orgs, authResult, wizard, refreshToken); + const selectedOrg = await this.resolveSelectedOrganization(orgs, authResult, refreshToken); // User has access to an existing org but no local key — they were invited // and need to redeem their invite code to receive the shared master key. const orgKeyPresent = hasOrgKey(selectedOrg.id, authResult.user_id!); - wizard?.record({ hasOrgKey: orgKeyPresent }); if (!orgKeyPresent) { - // The most common way this run stops, and it stops one step after the - // browser answered a question — so the page would otherwise be told the - // organization it just picked went through. `redeem` is on the rail from - // the start for exactly this; the run stops standing on it. - // - // Stated in fields rather than left for the message below to be mined - // for: the remedy is a command, not a sentence that happens to contain - // one. - wizard?.willBlock( - 'redeem', - { - code: ERROR_CODES.AUTH_FAILED, - title: 'This device does not hold this organization\'s key', - detail: - 'You have access to the organization, but the shared encryption key has never been transferred to this device. An owner can send you an invite code; redeeming it moves the key here. Then run capy again in this directory.', - remedy: 'capy redeem ', - }, - { facts: [{ label: 'Organization', value: selectedOrg.name }] }, - ); throw new CapyError( `You have access to "${selectedOrg.name}" but no encryption key on this device.\n\n` + ' Ask your org owner for an invite code, then run:\n\n' + @@ -591,11 +481,10 @@ export class CapyCommand { // "The lookup failed" and "this org has none" both end up as an empty list // here, and they are not the same fact: one walks the user into creating a // second project alongside one they already have. The rail says which. - const { existingProjects, projectsUnavailable } = await this.listExistingProjectsOrUnavailable(); - wizard?.record({ projectCount: existingProjects.length, projectsUnavailable }); + const { existingProjects } = await this.listExistingProjectsOrUnavailable(); if (existingProjects.length > 0) { - const projectChoice = await this.resolveProjectChoice(wizard, existingProjects, CREATE_NEW_PROJECT); + const projectChoice = await this.resolveProjectChoice(existingProjects, CREATE_NEW_PROJECT); if (projectChoice !== CREATE_NEW_PROJECT) { const picked = existingProjects.find(p => p.id === projectChoice)!; @@ -610,9 +499,9 @@ export class CapyCommand { // Prompt for project name const defaultName = this.projectManager.getDefaultProjectName(); - const projectName = await this.resolveProjectName(wizard, defaultName); + const projectName = await this.resolveProjectName(defaultName); - // Defense in depth: the prompt/wizard validators above already refuse + // Defense in depth: the prompt validators above already refuse // "_system" (CAP-664), but this is the actual choke point before the // service is asked to create anything, so it's checked again here. assertProjectNameAllowed(projectName); @@ -652,9 +541,9 @@ export class CapyCommand { // one, so pick the name: default 'development', or a custom name the // user enters. Protection isn't asked here - branches are unprotected // by default and can be protected later via a dedicated action. - const initialBranchChoice = await this.resolveInitialBranchChoice(wizard); + const initialBranchChoice = await this.resolveInitialBranchChoice(); const initialBranchName = initialBranchChoice === 'other' - ? await this.resolveCustomBranchName(wizard) + ? await this.resolveCustomBranchName() : 'development'; const initialBranchProtected = false; @@ -696,15 +585,11 @@ export class CapyCommand { if (hasLocalEnv) { const rawLocalEnv = this.fileManager.readEnvFile(this.options.envPath); const localVarCount = Object.keys(rawLocalEnv).length; - // The last stop stops being a blank the moment the directory is read: an - // empty .env is a stop this run will not visit, and the rail says so - // rather than leaving it looking outstanding. - wizard?.record({ localEnvCount: localVarCount }); if (localVarCount > 0) { - // Cross-org exfiltration guard — throws (via wizard.willBlock + CapyError) - // if any encrypted entry can't be read with this project's key. - const decryptedLocalEnv = this.resolveDecryptedLocalEnv(rawLocalEnv, encryptionKey, wizard); + // Cross-org exfiltration guard — throws a CapyError if any encrypted + // entry can't be read with this project's key. + const decryptedLocalEnv = this.resolveDecryptedLocalEnv(rawLocalEnv, encryptionKey); // Show found variables (max 5 names, "etc." for 6+) const varNames = Object.keys(decryptedLocalEnv); @@ -725,7 +610,7 @@ export class CapyCommand { // right project on first setup. After this step .env is rewritten // with ciphertext, so getting it wrong is painful to recover from. const confirmEncrypt = await this.resolveConfirmEncrypt( - wizard, localVarCount, varNames, projectName, selectedOrg.name, initBranch, + localVarCount, projectName, selectedOrg.name, initBranch, ); if (!confirmEncrypt) { @@ -753,20 +638,6 @@ export class CapyCommand { const syncError: any = syncResult.error; syncSpinner.fail(`Failed to sync variables: ${syncError.message}`); console.log(`You can run ${B('capy')} again to retry syncing`); - // This is the one failure that happens after the last question, and - // the terminal path swallows it and carries on — which under --web - // used to mean the run ended with `finish()` and the page drew a - // green check over a push that did not happen. The browser gets the - // same three facts the terminal cannot state: whether the values - // reached Keep, whether the plaintext copy was kept, and whether the - // .env in this directory is ciphertext now. - await wizard?.reportEncryptFailure({ - code: syncError instanceof CapyError ? syncError.code : ERROR_CODES.SERVICE_ERROR, - reason: syncError?.message ? String(syncError.message) : 'The push failed.', - envRewritten: syncResult.envRewritten, - backupWritten: syncResult.backupWritten, - pushed: syncResult.pushedToKeep, - }); } } else { console.log(`\nNo .env file found. Add secrets to .env, then run ${B('capy push')}`); @@ -776,7 +647,6 @@ export class CapyCommand { this.installGitHooks(); } } else { - wizard?.record({ localEnvCount: 0 }); console.log(`\nNo .env file found. Add secrets to .env, then run ${B('capy push')}`); console.log('to share them with your team.'); @@ -809,69 +679,22 @@ export class CapyCommand { return result; } - /** Asks (wizard or inquirer) which existing project to bootstrap, or "new". Throws on cancel. */ + /** Asks which existing project to bootstrap, or "new". */ private async resolveProjectChoice( - wizard: import('../ui/initWizardScreen').InitWizardSession | null, existingProjects: Array<{ id: string; name: string; organization_id: string }>, createNewProjectValue: string, ): Promise { - if (wizard) { - const chosen = await wizard.askProject( - existingProjects.map(p => ({ id: p.id, name: p.name })), - ); - if (chosen === null) { - throw new CapyError('Project selection cancelled', ERROR_CODES.AUTH_FAILED); - } - return chosen === 'new' ? createNewProjectValue : chosen; - } - const choices = [ - { name: 'New project', value: createNewProjectValue }, - ...existingProjects.map(p => ({ - name: p.name, - value: p.id, - })), - ]; - const { projectChoice } = await inquirer.prompt([{ - type: 'list', - name: 'projectChoice', - message: 'Which project do you want to use?', - choices, - default: createNewProjectValue, - }]); + const { projectChoice } = await inquirer.prompt([initProjectQuestion(existingProjects, createNewProjectValue)]); return projectChoice; } - /** Asks (wizard or the prompt engine) for the new project's name. Throws on cancel. */ - private async resolveProjectName( - wizard: import('../ui/initWizardScreen').InitWizardSession | null, - defaultName: string, - ): Promise { - if (wizard) { - // Same two refusals the TTY validator makes, in the same words — the - // screen holds its button on both, so either arriving here means the - // submit did not come from the screen. - const entered = await wizard.askProjectName(defaultName); - if (entered === null) { - throw new CapyError('Project naming cancelled', ERROR_CODES.AUTH_FAILED); - } - return entered; - } + /** Asks (via the prompt engine) for the new project's name. */ + private async resolveProjectName(defaultName: string): Promise { return this.promptEngine.promptForProjectName(defaultName); } - /** Asks (wizard or inquirer) whether the initial branch is 'development' or a custom name. Throws on cancel. */ - private async resolveInitialBranchChoice( - wizard: import('../ui/initWizardScreen').InitWizardSession | null, - ): Promise { - if (wizard) { - // No TTY under --web: without a browser screen here, init dies one step - // before createBranch/writeActiveBranch and leaves a branchless project. - const chosen = await wizard.askBranchChoice(); - if (chosen === null) { - throw new CapyError('Branch selection cancelled', ERROR_CODES.AUTH_FAILED); - } - return chosen; - } + /** Asks whether the initial branch is 'development' or a custom name. */ + private async resolveInitialBranchChoice(): Promise { const { initialBranchChoice } = await inquirer.prompt([{ type: 'list', name: 'initialBranchChoice', @@ -884,17 +707,8 @@ export class CapyCommand { return initialBranchChoice; } - /** Asks (wizard or inquirer) for the custom initial branch name. Throws on cancel. */ - private async resolveCustomBranchName( - wizard: import('../ui/initWizardScreen').InitWizardSession | null, - ): Promise { - if (wizard) { - const entered = await wizard.askBranchName(); - if (entered === null) { - throw new CapyError('Branch naming cancelled', ERROR_CODES.AUTH_FAILED); - } - return entered; - } + /** Asks for the custom initial branch name. */ + private async resolveCustomBranchName(): Promise { const { branchName } = await inquirer.prompt([{ type: 'input', name: 'branchName', @@ -909,13 +723,12 @@ export class CapyCommand { * encrypted value must decrypt with THIS project's key, or it was written * for a different project and must not be silently carried into this one. * Returns a new object — `localEnv` itself is never mutated — with every - * such entry replaced by its decrypted plaintext. Throws (after telling the - * wizard which stop this blocks) when any entry fails to decrypt. + * such entry replaced by its decrypted plaintext. Throws when any entry fails + * to decrypt. */ private resolveDecryptedLocalEnv( localEnv: Readonly>, encryptionKey: string, - wizard: import('../ui/initWizardScreen').InitWizardSession | null, ): Record { const encryptedEntries = Object.entries(localEnv) .filter(([, value]) => value.startsWith('capy:')); @@ -940,22 +753,6 @@ export class CapyCommand { console.error(` ${key}`); } console.error('\nTo fix: delete the .env file or replace encrypted values with plaintext before initializing a new project.'); - // The stop this run dies at is the consent gate, and the variables - // are the whole subject — so they go as NAMES, in the field that - // draws them as a list of things to go and find in a file, rather - // than as a count inside a red sentence. Names only: these values - // cannot be read by this key, which is the problem. - wizard?.willBlock( - 'encrypt', - { - code: ERROR_CODES.PERMISSION_DENIED, - title: 'This .env holds values encrypted to a different project', - detail: - 'These variables cannot be read with this organization\'s key, so they cannot be pushed to it. Delete the .env file, or replace those values with plaintext, and run capy again.', - remedy: 'capy', - }, - { names: foreignKeys }, - ); throw new CapyError( 'Cannot push secrets encrypted with a different project\'s key to a new org', ERROR_CODES.PERMISSION_DENIED, @@ -972,28 +769,13 @@ export class CapyCommand { }; } - /** Asks (wizard or inquirer) to confirm encrypting + pushing the local .env. A closed wizard window is a "no". */ + /** Asks to confirm encrypting + pushing the local .env. */ private async resolveConfirmEncrypt( - wizard: import('../ui/initWizardScreen').InitWizardSession | null, localVarCount: number, - varNames: string[], projectName: string, orgName: string, initBranch: string, ): Promise { - if (wizard) { - // NAMES and a count reach the page — never a value, and not even a - // snippet of one. The whole question this stop asks is whether these - // may stop being plaintext, and showing more than the terminal shows - // in order to ask it would answer part of it first. - // - // A closed window is a "no": `askEncrypt` resolves false on cancel, - // which is the same thing `chosen === 'yes'` already meant. - return wizard.askEncrypt( - { count: localVarCount, names: varNames }, - { projectName, orgName, branch: initBranch }, - ); - } const { confirmEncrypt } = await inquirer.prompt([{ type: 'confirm', name: 'confirmEncrypt', @@ -1672,17 +1454,6 @@ export class CapyCommand { const finalKeep = this.projectManager.readKeepFile(); this.fileManager.writeEncryptedEnvFile(localPlaintext, encryptionKey, undefined, finalKeep, branch); this.installGitHooks(); - // NO BROWSER PAGE HERE, deliberately. This is the path a synced - // directory takes on every single run: nothing was asked, nothing - // differed, and the one line above says so. Serving a report anyway - // opened a tab per run — and where `--web` actually lives, which is a - // headless or remote host, `open()` fails quietly and the listening - // socket holds the process for its whole 120-second timeout waiting for - // a browser that is never coming. A no-op that takes two minutes to - // exit is worse than a no-op nobody rendered. - // - // The three ENDS below still report: they follow a question somebody - // answered in a window that is demonstrably in use. return; } @@ -1751,11 +1522,9 @@ export class CapyCommand { console.log(` You have unsynced environment variables (${diffs.length} difference${diffs.length !== 1 ? 's' : ''} found).\n`); - // Display comparison table (TTY only — the --web resolver renders its own). - if (!this.options.web) { - this.displayComparisonTable(diffs, effectiveShowLocal, showRemote, pinned, localHashes, remoteHashes, localPlaintext, remotePlaintext, pinnedPlaintext); - console.log(`\n ${DIM}← → select value ↑ ↓ move between rows Enter confirm q cancel${RST}\n`); - } + // Display comparison table. + this.displayComparisonTable(diffs, effectiveShowLocal, showRemote, pinned, localHashes, remoteHashes, localPlaintext, remotePlaintext, pinnedPlaintext); + console.log(`\n ${DIM}← → select value ↑ ↓ move between rows Enter confirm q cancel${RST}\n`); // Build menu options based on what columns are visible const hasPinned = Object.keys(pinned).length > 0; @@ -1840,64 +1609,15 @@ export class CapyCommand { const menuChoices: MenuChoice[] = [...stateMenuChoices, { name: 'Continue working', value: 'skip' }].map((c) => localMode && c.value === 'commit_local' ? { ...c, name: 'Commit all local values' } : c, ); - // Which action the user picked, and (web only) the env individual - // resolution already produced — a discriminated result rather than - // `let action`/`let webFinalEnv` mutated in an if/else, with an `abort` - // case standing in for the early `return` the web "closed window" path - // used to take right from inside this same block. - const conflictDecision = await (async (): Promise< - | { kind: 'abort' } - | { kind: 'proceed'; action: string; webFinalEnv: Record | undefined } - > => { - if (this.options.web) { - // The browser now answers the same two-level question the terminal asks, - // so the whole-run menu goes to it verbatim — same wording, same order, - // and that order is the CLI's recommendation. It used to be discarded - // here and `individual` forced in its place. - const resolved = await this.resolveConflictViaBrowser( - diffs, effectiveShowLocal, showRemote, pinned, - localPlaintext, remotePlaintext, pinnedPlaintext, - projectState.projectName || 'project', branch, - { - localMode, - isOnboarding, - isBehind, - remoteState: showRemote ? 'ok' : 'empty', - actions: menuChoices.map(c => ({ value: c.value, label: c.name })), - }, - ); - if (resolved === null) { - return { kind: 'abort' }; - } - // Only individual resolution hands back an env; every other action is - // applied below by the same branch the terminal path takes. - return { kind: 'proceed', action: resolved.action, webFinalEnv: resolved.finalEnv }; - } - const res = await inquirer.prompt([{ - type: 'list', - name: 'action', - message: 'What would you like to do?', - choices: menuChoices, - }]); - return { kind: 'proceed', action: res.action as string, webFinalEnv: undefined }; - })(); - - if (conflictDecision.kind === 'abort') { - console.log('\n No changes applied.'); - // A closed window changed nothing on disk, and the report says exactly - // that rather than reporting a sync that did not happen. - await this.reportSyncResult(projectState, branch, { - outcome: 'nothing-to-do', - pulled: [], - pushed: [], - envRewritten: false, - }); - return; - } - const { action, webFinalEnv } = conflictDecision; + const { action } = await inquirer.prompt([{ + type: 'list', + name: 'action', + message: 'What would you like to do?', + choices: menuChoices, + }]) as { action: string }; - // Apply the chosen action. Same `abort`-or-`proceed` shape as above, in - // place of `let finalEnv` plus a bare `return` from two of its branches + // Apply the chosen action. An `abort`-or-`proceed` result stands in for + // `let finalEnv` plus a bare `return` from two of its branches // (a failed pinned-fetch, a cancelled individual resolution). const finalEnvDecision = await (async (): Promise< | { kind: 'abort' } @@ -1949,16 +1669,10 @@ export class CapyCommand { return { kind: 'proceed', finalEnv: { ...localPlaintext } }; } if (action === 'skip') { - await this.reportSyncResult(projectState, branch, { - outcome: 'nothing-to-do', - pulled: [], - pushed: [], - envRewritten: false, - }); return { kind: 'abort' }; } - // Individual resolution — already resolved in the browser when --web. - const resolved = webFinalEnv ?? await this.resolveIndividually(diffs, showLocal, showRemote, pinned, localPlaintext, remotePlaintext, pinnedPlaintext); + // Individual resolution. + const resolved = await this.resolveIndividually(diffs, showLocal, showRemote, pinned, localPlaintext, remotePlaintext, pinnedPlaintext); if (!resolved) return { kind: 'abort' }; // Cancelled return { kind: 'proceed', finalEnv: resolved }; })(); @@ -2061,27 +1775,6 @@ export class CapyCommand { // Install hooks on every run (idempotent) this.installGitHooks(); - - // Which way each variable moved is which list it lands in. - // - // Pulled is computed rather than assumed: it is the variables whose value - // in the file actually CHANGED, which is the only definition that stays - // true for individual resolution, where the answer is per variable and a - // row resolved to "keep mine" moved nowhere at all. - // - // Pushed is `commit_local` and only `commit_local`: it is the one action - // that sends anything up. Individual resolution rewrites the file and - // repins, and never pushes — see the guard above. - const changes = (rows: { variable: string; type: 'new' | 'changed' | 'deleted' }[]) => - rows.map(d => ({ variable: d.variable, type: d.type })); - await this.reportSyncResult(projectState, branch, { - outcome: 'synced', - pulled: action === 'commit_local' - ? [] - : changes(diffs.filter(d => finalEnv[d.variable] !== localPlaintext[d.variable])), - pushed: action === 'commit_local' ? changes(diffs) : [], - envRewritten: true, - }); } /** @@ -2102,35 +1795,6 @@ export class CapyCommand { } } - /** - * The end-of-run report, in the browser, under `--web`. - * - * `capy --web` is agent-driven, so the three console lines above go to a - * stream nobody is necessarily watching. The same facts render as the - * compiled `sync-result` screen instead — variable NAMES and directions, no - * values, and `envRewritten` carried rather than inferred, because the .env - * is rewritten on a path where nothing moved at all. - */ - private async reportSyncResult( - projectState: ProjectState, - branch: string | null, - result: { - outcome: 'synced' | 'nothing-to-do'; - pulled: { variable: string; type: 'new' | 'changed' | 'deleted' }[]; - pushed: { variable: string; type: 'new' | 'changed' | 'deleted' }[]; - envRewritten: boolean; - }, - ): Promise { - if (!this.options.web) return; - const { showSyncResultInBrowser } = await import('../ui/syncScreens'); - await showSyncResultInBrowser({ - projectName: projectState.projectName || 'project', - branch, - ...result, - open: !process.env.CAPY_WEB_NO_OPEN, - }); - } - private displayComparisonTable( diffs: { variable: string; type: string; pinned?: string; local?: string; remote?: string }[], showLocal: boolean, @@ -2273,7 +1937,7 @@ export class CapyCommand { const { refuseNonInteractive } = await import('../ui/interactive'); refuseNonInteractive( `${diffs.length} ${diffs.length === 1 ? 'variable has' : 'variables have'} changed on both sides and need a decision`, - 'Run `capy --web` to resolve them in a browser, or run `capy` in a terminal.', + 'Run `capy` in a terminal.', // COPY-FLAG ); } @@ -2286,9 +1950,8 @@ export class CapyCommand { /** * Map a per-variable resolve choice set ('pinned'|'local'|'remote'|'delete') - * to the final plaintext env. Shared by the TTY ResolveTable and the --web - * browser resolver so both paths produce byte-identical results. Variables not - * in `diffs` (unchanged) are carried over from local. + * to the final plaintext env. Variables not in `diffs` (unchanged) are + * carried over from local. */ private mapResolveChoicesToEnv( choices: Record, @@ -2298,9 +1961,7 @@ export class CapyCommand { remotePlaintext: Record, pinnedPlaintext: Record = {}, ): Record { - const result: Record = {}; - - for (const [variable, choice] of Object.entries(choices)) { + const chosenValue = (variable: string, choice: 'pinned' | 'local' | 'remote' | 'delete'): string | undefined => { if (choice === 'pinned') { const pinnedHash = pinned[variable]; // Prefer the resolved pinned plaintext (from the keep cache / remote @@ -2310,119 +1971,39 @@ export class CapyCommand { // the keep.lock cleanup then silently DELETED the variable. The cache // holds the baseline, so consult it first. // `!== undefined` throughout: '' is a valid pinned value. - if (pinnedPlaintext[variable] !== undefined) { - result[variable] = pinnedPlaintext[variable]; - } else if (localPlaintext[variable] !== undefined && hashValue(localPlaintext[variable]) === pinnedHash) { - result[variable] = localPlaintext[variable]; - } else if (remotePlaintext[variable] !== undefined && hashValue(remotePlaintext[variable]) === pinnedHash) { - result[variable] = remotePlaintext[variable]; + if (pinnedPlaintext[variable] !== undefined) return pinnedPlaintext[variable]; + if (localPlaintext[variable] !== undefined && hashValue(localPlaintext[variable]) === pinnedHash) { + return localPlaintext[variable]; } - } else if (choice === 'local' && localPlaintext[variable] !== undefined) { - result[variable] = localPlaintext[variable]; - } else if (choice === 'remote' && remotePlaintext[variable] !== undefined) { - result[variable] = remotePlaintext[variable]; - } - // 'delete' — don't add to result - } - - // Add unchanged variables from local - for (const [key, value] of Object.entries(localPlaintext)) { - if (!(key in result) && !diffs.some(d => d.variable === key)) { - result[key] = value; + if (remotePlaintext[variable] !== undefined && hashValue(remotePlaintext[variable]) === pinnedHash) { + return remotePlaintext[variable]; + } + return undefined; } - } - - return result; - } + if (choice === 'local') return localPlaintext[variable]; + if (choice === 'remote') return remotePlaintext[variable]; + return undefined; // 'delete' — don't add to result + }; - /** - * Render the sync conflict resolver in the browser (`capy --web`). - * - * Serves the compiled `sync-conflict` screen, which asks BOTH levels the - * terminal asks: the whole-run action first, in the CLI's own order so the - * recommended answer sits at the top, and the per-variable table only when - * the user chooses to resolve individually. The previous browser path threw - * the first level away and hard-coded individual resolution, so someone who - * wanted "take theirs" answered once per variable and never saw the ordering - * that carried the recommendation. - * - * SNIPPETS only, never full secret values — the same rule the TTY table - * follows. Returns the chosen action so the caller can apply a whole-run - * answer directly, or null when nothing was decided. - */ - private async resolveConflictViaBrowser( - diffs: { variable: string; type: string; pinned?: string; local?: string; remote?: string }[], - showLocal: boolean, - showRemote: boolean, - pinned: Record, - localPlaintext: Record, - remotePlaintext: Record, - pinnedPlaintext: Record, - projectName: string, - branch: string, - context: { - localMode: boolean; - isOnboarding: boolean; - isBehind: boolean; - remoteState: 'ok' | 'empty' | 'unreachable'; - actions: { value: string; label: string }[]; - }, - ): Promise<{ action: string; finalEnv?: Record } | null> { - const { resolveConflictInBrowser } = await import('../ui/syncConflictScreen'); - - // Which pins cannot be reconstructed. The terminal encodes this by writing - // an ANSI-italic `unresolvable` into the value column and testing for that - // string later; a variable whose snippet read "unresolvable" would defeat - // it. The screen takes a set of names, which no value can spoof. - const unresolvable = new Set( - diffs - .map(d => d.variable) - .filter(v => pinned[v] !== undefined && pinnedPlaintext[v] === undefined), + const chosen: Record = Object.fromEntries( + Object.entries(choices).flatMap(([variable, choice]) => { + const value = chosenValue(variable, choice); + return value === undefined ? [] : [[variable, value] as const]; + }), ); - const rows = diffs.map(diff => ({ - variable: diff.variable, - pinned: pinnedPlaintext[diff.variable] - ? formatSnippet(pinnedPlaintext[diff.variable]) - : pinned[diff.variable] !== undefined - ? '' - : null, - local: localPlaintext[diff.variable] ? formatSnippet(localPlaintext[diff.variable]) : null, - remote: remotePlaintext[diff.variable] ? formatSnippet(remotePlaintext[diff.variable]) : null, - })); - - const { action, choices, cancelled } = await resolveConflictInBrowser({ - rows, - unresolvable, - showLocal, - showRemote, - localMode: context.localMode, - isOnboarding: context.isOnboarding, - isBehind: context.isBehind, - remoteState: context.remoteState, - actions: context.actions.map(a => ({ value: a.value as never, label: a.label })), - projectName, - branch, - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI / headless - // verification drive the loopback without hijacking a real browser. - open: !process.env.CAPY_WEB_NO_OPEN, - }); - if (cancelled) return null; - - // A whole-run action is applied by the same code the terminal path uses; - // only individual resolution produces an env here. - if (action !== 'individual') return { action }; - - return { - action, - finalEnv: this.mapResolveChoicesToEnv( - choices, diffs, pinned, localPlaintext, remotePlaintext, pinnedPlaintext, + // Add unchanged variables from local + const unchanged: Record = Object.fromEntries( + Object.entries(localPlaintext).filter( + ([key]) => !(key in chosen) && !diffs.some(d => d.variable === key), ), - }; + ); + + return { ...chosen, ...unchanged }; } private async createNewOrganization(refreshToken: string, userId: string): Promise { const { createNewOrganization } = await import('./orgCreation'); - return createNewOrganization(this.authService, this.serviceClient, refreshToken, userId, this.options.web); + return createNewOrganization(this.authService, this.serviceClient, refreshToken, userId); } } diff --git a/src/commands/checkoutCommand.ts b/src/commands/checkoutCommand.ts index 1c15d144..8ddb28ee 100644 --- a/src/commands/checkoutCommand.ts +++ b/src/commands/checkoutCommand.ts @@ -4,39 +4,13 @@ import { FileManager } from '../files/fileManager'; import { AuthService } from '../auth/authService'; import { ServiceClient } from '../service/serviceClient'; import inquirer from 'inquirer'; -import { Branch, CapyError, ERROR_CODES, getSyncKeepHash, KeepFile } from '../types/index'; +import { CapyError, ERROR_CODES, getSyncKeepHash, KeepFile } from '../types/index'; import { resolveProjectKey, KeyServiceOps } from '../crypto/keyResolver'; import { SyncEngine } from '../sync/syncEngine'; import { hashValue } from './statusCommand'; -import { branchCreatePlan, unansweredStops } from '../core/branchCreatePlan'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; -/** - * How many variables each branch pins, from keep.lock alone. - * - * Feeds the branch list's per-row count. Local knowledge deliberately: the - * server's branch list carries no counts, and keep.lock is the file this - * directory already trusts for what a branch holds. A branch this checkout has - * never touched is simply absent from the map, which the screen renders as no - * count rather than as zero. - */ -export function countVariablesPerBranch(keep: KeepFile | null): Record { - const counts: Record = {}; - for (const entries of Object.values(keep?.variables ?? {})) { - // Per VARIABLE, not per entry: the number the screen prints is "variables - // held on this branch", and a variable with two entries pinned to one - // branch is still one variable. - const seen = new Set(); - for (const entry of entries) { - if (!entry?.branch || seen.has(entry.branch)) continue; - seen.add(entry.branch); - counts[entry.branch] = (counts[entry.branch] ?? 0) + 1; - } - } - return counts; -} - /** * Pure dirty-check behind the checkout guard: does the decrypted .env differ * from what keep.lock pins for `branch`? Returns the first offending variable @@ -140,16 +114,10 @@ export interface CheckoutOptions { create?: boolean; /** Settled by `--protected` / `--no-protected`; undefined means ask. */ protected?: boolean; - /** - * Ask this run's questions in a browser instead of at the TTY. - * - * Changes only where a question is RENDERED. The same plan decides which - * questions exist, the same answers reach the same code, and nothing about - * what is written to keep.lock or .env moves. - */ - web?: boolean; } +type DecryptData = Awaited>; + export class CheckoutCommand { private projectManager: ProjectManager; private fileManager: FileManager; @@ -184,6 +152,15 @@ export class CheckoutCommand { } } + /** Org-scoped silent session, then a plain silent one, then interactive: the last attempt's result. */ + private async authenticate(orgId: string | undefined) { + const scoped = await this.authService.authenticateSilent(orgId); + if (scoped.success) return scoped; + const plain = await this.authService.authenticateSilent(); + if (plain.success) return plain; + return this.authService.authenticate(orgId); + } + private async _execute(branchName: string, options: CheckoutOptions): Promise { // Read keep.lock — must be initialized const projectState = await this.projectManager.detectProjectState(); @@ -199,9 +176,7 @@ export class CheckoutCommand { // Authenticate const spinner = ora('Authenticating...').start(); - let authResult = await this.authService.authenticateSilent(projectState.organizationId); - if (!authResult.success) authResult = await this.authService.authenticateSilent(); - if (!authResult.success) authResult = await this.authService.authenticate(projectState.organizationId); + const authResult = await this.authenticate(projectState.organizationId); if (!authResult.success) { spinner.fail('Authentication failed'); throw new CapyError(authResult.error || 'Authentication failed', ERROR_CODES.AUTH_FAILED); @@ -235,22 +210,11 @@ export class CheckoutCommand { } if (options.create) { - const created = await this.createBranch( - projectId, - branchName, - encryptionKey, - options.protected, - options.web, - projectState.projectName || 'project', - ); - // Cancelling in the browser is a refusal, so nothing was registered and - // nothing below may run: the rest of this method switches the directory - // onto a branch that does not exist. + const created = await this.createBranch(projectId, branchName, options.protected); if (!created) { console.log('\nNo branch created.'); return; } - branchName = created.name; } else { // Verify the branch exists const branchSpinner = ora(`Switching to ${branchName}...`).start(); @@ -258,40 +222,17 @@ export class CheckoutCommand { const branch = branches.find(b => b.name === branchName); if (!branch) { branchSpinner.stop(); - - // The terminal's answer to a name that does not exist is a listing and - // exit 1: the branch the user meant is on the screen, and the only way - // to reach it is to type the command again. In the browser the listing - // IS the picker, so a wrong name becomes a question rather than a dead - // end. Only when there is something to pick — a list with no row this - // directory could move to is a page with no way out of it. - const switchable = branches.filter(b => b.name !== projectState.activeBranch); - if (options.web && switchable.length > 0) { - const picked = await this.pickBranchInBrowser( - branchName, - branches, - projectState.projectName || 'project', - projectState.activeBranch ?? null, - ); - if (!picked) { - console.log('\nNo branch selected — nothing changed.'); - return; - } - branchName = picked; - } else { - console.log(`Branch "${branchName}" not found\n`); - console.log('Available branches:'); - for (const b of branches) { - const label = b.name; - const prod = b.is_protected ? ' \x1b[90m(protected)\x1b[0m' : ''; - console.log(` ${label}${prod}`); - } - console.log(`\nCreate it with: ${B(`capy checkout -b ${branchName}`)}`); - process.exit(1); + console.log(`Branch "${branchName}" not found\n`); + console.log('Available branches:'); + for (const b of branches) { + const label = b.name; + const prod = b.is_protected ? ' \x1b[90m(protected)\x1b[0m' : ''; + console.log(` ${label}${prod}`); } - } else { - branchSpinner.stop(); + console.log(`\nCreate it with: ${B(`capy checkout -b ${branchName}`)}`); + process.exit(1); } + branchSpinner.stop(); } // Pull latest secrets for this branch from the server BEFORE switching @@ -299,9 +240,51 @@ export class CheckoutCommand { // on their current branch with their current .env intact. const syncSpinner = ora(`Syncing secrets for ${branchName}...`).start(); - let decryptData: Awaited>; + const decryptData = await this.fetchBranchSnapshot(projectId, branchName, syncSpinner); + + // Checkout is an explicit sync of the TARGET branch, so update that + // branch's pins from the server — but only that branch's (CAP-303). + // keep.lock holds all branches' metadata and is git-owned; the server's + // copy is whatever the last pusher had and must not rewrite branches this + // checkout didn't touch. + const keepForWrite = this.adoptServerBranchPins(decryptData, branchName); + + // Write .env BEFORE switching .capy/branch. The .env header records which + // branch its contents belong to, so if we fail between these writes we must + // never leave .capy/branch pointing to a branch whose secrets aren't in + // .env yet. Writing .env first means a crash here leaves us on the old + // branch with .env already updated — detectable on next run via + // capy-branch-header mismatch self-heal. + const { varCount, seededFromCurrent } = this.writeBranchEnv( + decryptData, + options, + encryptionKey, + keepForWrite, + branchName, + ); + + this.projectManager.writeActiveBranch(branchName); + syncSpinner.stop(); + + if (seededFromCurrent) { + console.log(`Seeded ${varCount} variable(s) from current branch into ${branchName} (unpushed — run ${B('capy')} to push)`); + } else if (varCount > 0) { + console.log(`Synced ${varCount} variable(s) for ${branchName}`); + } else { + console.log(`No secrets yet for ${branchName}`); + } + + console.log(`\nNow on branch: ${branchName}`); + } + + /** The branch's latest snapshot. A 403 and any other failure exit 1; a 404 is an empty branch. */ + private async fetchBranchSnapshot( + projectId: string, + branchName: string, + syncSpinner: { stop: () => unknown }, + ): Promise { try { - decryptData = await this.serviceClient.getDecryptData( + return await this.serviceClient.getDecryptData( projectId, branchName, undefined, // ask for latest @@ -317,127 +300,87 @@ export class CheckoutCommand { } if (status === 404) { // No snapshot yet for this branch — treat as empty and proceed to switch. - decryptData = { env_content: '', decrypt_key: '', expires_at: new Date().toISOString() }; - } else { - console.error(`Failed to sync secrets: ${error.message}`); - process.exit(1); + return { env_content: '', decrypt_key: '', expires_at: new Date().toISOString() }; } + console.error(`Failed to sync secrets: ${error.message}`); + process.exit(1); } + } - // Checkout is an explicit sync of the TARGET branch, so update that - // branch's pins from the server — but only that branch's (CAP-303). - // keep.lock holds all branches' metadata and is git-owned; the server's - // copy is whatever the last pusher had and must not rewrite branches this - // checkout didn't touch. - let keepForWrite = this.projectManager.readKeepFile()!; - if (decryptData.keep_file) { - const serverKeep = JSON.parse(decryptData.keep_file) as KeepFile; - keepForWrite = SyncEngine.spliceKeepBranch(keepForWrite, serverKeep, branchName); - this.fileManager.writeKeepFile(keepForWrite); + /** Splices the server's pins for `branchName` into keep.lock (writing it) when the server sent a copy. */ + private adoptServerBranchPins(decryptData: DecryptData, branchName: string): KeepFile { + const localKeep = this.projectManager.readKeepFile()!; + if (!decryptData.keep_file) return localKeep; + const serverKeep = JSON.parse(decryptData.keep_file) as KeepFile; + const spliced = SyncEngine.spliceKeepBranch(localKeep, serverKeep, branchName); + this.fileManager.writeKeepFile(spliced); + return spliced; + } + + /** The current .env's plaintext, or `{}` when it cannot be read. */ + private readCurrentEnv(encryptionKey: string): Record { + try { + return this.fileManager.readEncryptedEnvFile(encryptionKey); + } catch { + // Unreadable current .env — fall through to empty-stamped file. + return {}; } + } - // Write .env BEFORE switching .capy/branch. The .env header records which - // branch its contents belong to, so if we fail between these writes we must - // never leave .capy/branch pointing to a branch whose secrets aren't in - // .env yet. Writing .env first means a crash here leaves us on the old - // branch with .env already updated — detectable on next run via - // capy-branch-header mismatch self-heal. - let varCount = 0; - let seededFromCurrent = false; + /** Writes the target branch's .env, reporting how many variables it holds and whether they were seeded from the current branch. */ + private writeBranchEnv( + decryptData: DecryptData, + options: CheckoutOptions, + encryptionKey: string, + keepForWrite: KeepFile, + branchName: string, + ): { varCount: number; seededFromCurrent: boolean } { if (decryptData.env_content) { const remoteEnv = this.fileManager.parseEnvContent(decryptData.env_content); - const decrypted: Record = {}; - for (const [key, value] of Object.entries(remoteEnv)) { - try { - decrypted[key] = this.fileManager.decryptValue(value, encryptionKey); - } catch { - // Skip undecryptable - } - } + const decrypted: Record = Object.fromEntries( + Object.entries(remoteEnv).flatMap(([key, value]) => { + try { + return [[key, this.fileManager.decryptValue(value, encryptionKey)] as const]; + } catch { + return []; // Skip undecryptable + } + }), + ); this.fileManager.writeEncryptedEnvFile(decrypted, encryptionKey, undefined, keepForWrite, branchName); - varCount = Object.keys(decrypted).length; - } else if (options.create) { + return { varCount: Object.keys(decrypted).length, seededFromCurrent: false }; + } + if (options.create) { // `capy checkout -b ` with no remote snapshot: seed the new branch // from the current .env. Preserve the plaintext values and re-write them // under the new branch header (new resource_ids per (branch, key)), so // `capy` sees them as unpinned and offers to push them to . - let seed: Record = {}; - try { - seed = this.fileManager.readEncryptedEnvFile(encryptionKey); - } catch { - // Unreadable current .env — fall through to empty-stamped file. - } + const seed = this.readCurrentEnv(encryptionKey); this.fileManager.writeEncryptedEnvFile(seed, encryptionKey, undefined, keepForWrite, branchName); - varCount = Object.keys(seed).length; - seededFromCurrent = varCount > 0; - } else { - // Switching to an existing empty branch: overwrite .env with an empty - // (but branch-stamped) file so the header matches the active branch. - this.fileManager.writeEncryptedEnvFile({}, encryptionKey, undefined, keepForWrite, branchName); - } - - this.projectManager.writeActiveBranch(branchName); - syncSpinner.stop(); - - if (seededFromCurrent) { - console.log(`Seeded ${varCount} variable(s) from current branch into ${branchName} (unpushed — run ${B('capy')} to push)`); - } else if (varCount > 0) { - console.log(`Synced ${varCount} variable(s) for ${branchName}`); - } else { - console.log(`No secrets yet for ${branchName}`); + const varCount = Object.keys(seed).length; + return { varCount, seededFromCurrent: varCount > 0 }; } - - console.log(`\nNow on branch: ${branchName}`); + // Switching to an existing empty branch: overwrite .env with an empty + // (but branch-stamped) file so the header matches the active branch. + this.fileManager.writeEncryptedEnvFile({}, encryptionKey, undefined, keepForWrite, branchName); + return { varCount: 0, seededFromCurrent: false }; } - /** - * Create the branch, asking whatever the plan left unanswered. - * - * Returns the branch that now exists, or null when the browser was - * cancelled — a step nobody answered has not been approved, and the caller - * must not go on to switch this directory onto it. - */ + /** Create the branch, asking about protection when argv did not settle it. */ private async createBranch( projectId: string, branchName: string, - encryptionKey: string, isProtected: boolean | undefined, - web: boolean | undefined, - projectName: string, ): Promise<{ name: string } | null> { - // The whole route, computed before anything opens — the same array - // `capy checkout -b --json` prints. Asking it what is outstanding, - // rather than testing `isProtected === undefined` a second time here, is - // what keeps the questions this run asks and the rail it draws in step. - const outstanding = unansweredStops(branchCreatePlan({ branchName, isProtected })); - - if (web && outstanding.length > 0) { - const answer = await this.askCreateInBrowser( - projectId, branchName, encryptionKey, isProtected, projectName, - ); - if (answer.cancelled) return null; - branchName = answer.name; - isProtected = answer.isProtected; - } - - if (isProtected === undefined) { - const { protect } = await inquirer.prompt([{ - type: 'confirm', - name: 'protect', - message: `Make "${branchName}" a protected branch? \x1b[90m(invite-only)\x1b[0m`, - default: false, - }]); - isProtected = protect; - } + const protect = isProtected ?? (await this.askProtected(branchName)); const branchSpinner = ora(`Creating branch ${branchName}...`).start(); try { - await this.serviceClient.createBranch(projectId, branchName, isProtected); + await this.serviceClient.createBranch(projectId, branchName, protect); branchSpinner.stop(); console.log(`Branch "${branchName}" registered`); - if (isProtected) { + if (protect) { console.log(`\n"${branchName}" is a protected branch — access is invite-only`); } return { name: branchName }; @@ -449,83 +392,13 @@ export class CheckoutCommand { } } - /** - * The protection confirm (and the name, when argv gave none), in a browser. - * - * The seed preview is the reason this reads .env at all: `-b` skips both - * dirty guards, so whatever is in this directory right now is copied onto - * the new branch and left unpushed, and the terminal only says so afterwards - * as `Seeded 12 variable(s)`. NAMES and a count cross — never a value. The - * plaintext read here stays in this frame. - */ - private async askCreateInBrowser( - projectId: string, - branchName: string, - encryptionKey: string, - isProtected: boolean | undefined, - projectName: string, - ): Promise<{ name: string; isProtected: boolean; cancelled: boolean }> { - let seedVarNames: string[] = []; - let seedUnreadable = false; - try { - // Missing .env yields {} rather than throwing, which is an empty seed - // and not an unreadable one — the two say different things on the page. - seedVarNames = Object.keys(this.fileManager.readEncryptedEnvFile(encryptionKey)).sort(); - } catch { - seedUnreadable = true; - } - - // Existing names, so a collision is answered while the user is typing - // instead of arriving as the server's prose after a round trip. Best - // effort: without the list the screen simply cannot pre-empt a clash, and - // `POST /branches` still refuses one. - let existingBranches: Array<{ name: string; isProtected: boolean }> = []; - try { - const branches = await this.serviceClient.listBranches(projectId); - existingBranches = branches.map(b => ({ name: b.name, isProtected: b.is_protected })); - } catch { - /* leave it empty; the server remains the authority on a taken name */ - } - - const { createBranchInBrowser } = await import('../ui/branchScreens'); - return createBranchInBrowser({ - projectName, - branchName, - isProtected, - existingBranches, - seedFrom: this.fileManager.readEnvMeta().branch || this.projectManager.readActiveBranch() || null, - seedVarNames, - seedUnreadable, - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI / headless - // verification drive the loopback without hijacking a real browser. - open: !process.env.CAPY_WEB_NO_OPEN, - }); - } - - /** - * The branch list, served as a picker. - * - * Reached only from the not-found path, so it is a correction rather than a - * menu: the user asked for a branch this project does not have. Deleting is - * off — `capy checkout` has never deleted anything, and the screen draws no - * delete control without it. - */ - private async pickBranchInBrowser( - missing: string, - branches: Branch[], - projectName: string, - activeBranch: string | null, - ): Promise { - console.log(`Branch "${missing}" not found\n`); - const { chooseBranchInBrowser } = await import('../ui/branchScreens'); - const picked = await chooseBranchInBrowser({ - projectName, - activeBranch, - branches, - variableCounts: countVariablesPerBranch(this.projectManager.readKeepFile()), - canDelete: false, - open: !process.env.CAPY_WEB_NO_OPEN, - }); - return picked.cancelled ? null : picked.branch; + private async askProtected(branchName: string): Promise { + const { protect } = await inquirer.prompt([{ + type: 'confirm', + name: 'protect', + message: `Make "${branchName}" a protected branch? \x1b[90m(invite-only)\x1b[0m`, + default: false, + }]); + return protect; } } diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 8e8ad27f..029002bc 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -1,87 +1,11 @@ -import { resolveContext, writeAndSync, writeImportOutcome, listManagedKeys, ResolvedContext } from './connectors/shared'; -import { listProviders, loadProvider, ConnectOpts, ConnectorModule, ConnectResult } from './connectors/registry'; -import { connectPlan } from './connectors/plans'; +import { resolveContext, writeAndSync, writeImportOutcome, ResolvedContext } from './connectors/shared'; +import { listProviders, loadProvider, ConnectOpts, ConnectorModule } from './connectors/registry'; import { isInteractive } from '../ui/interactive'; -import { ProjectManager } from '../core/projectManager'; import { resolveOrgContext } from '../core/orgContext'; -import { confirmLiveActionInBrowser } from '../ui/connectScreens'; import type { DiscoveryContext } from './connectors/dokployDiscovery'; -import type { - ConnectLiveGateStop, - ConnectorChoice, - ConnectOutcome, - ConnectResultData, -} from '../ui/screens/contract'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; -/** Browser paths honour this so a test never opens the developer's real browser. */ -const shouldOpen = (): boolean => !process.env.CAPY_WEB_NO_OPEN; - -/** - * The connector list, with the three things the terminal's two columns cannot - * say until it is too late: that the connector wants a binary you may not have, - * that it hands you off to a browser pairing, and how many variables on this - * branch it already owns. - * - * Exported so a test can assert the shape without a browser. The keep.lock read - * is best-effort: `capy connect` runs outside an initialised project too, and a - * missing count is not a reason to refuse the list. - */ -/** Managed-variable counts per provider, on the active branch. `{}` on any failure (see `describeConnectors`'s own doc) — never partial. */ -function computeManagedCounts(): Record { - try { - const pm = new ProjectManager(); - const keep = pm.readKeepFile(); - const branch = pm.deriveActiveBranch(); - if (!keep || !branch) return {}; - return listManagedKeys(keep, branch).reduce>( - (acc, { connector }) => ({ ...acc, [connector.provider]: (acc[connector.provider] ?? 0) + 1 }), - {}, - ); - } catch { - return {}; - } -} - -export async function describeConnectors(): Promise { - const managed = computeManagedCounts(); - return Promise.all( - listProviders().map(async (p) => { - const mod = await loadProvider(p.name); - const found = mod.toolInstalled ? mod.toolInstalled() : undefined; - return { - id: p.name, - description: p.description, - ...(mod.requiresAuth ? { requiresAuth: true } : {}), - ...(mod.requiresTool ? { requiresTool: mod.requiresTool } : {}), - ...(found === undefined ? {} : { toolFound: found }), - // The identical refusal `capy connect ` would run into, previewed - // here rather than discovered one command later. Same object, so the two - // cannot word one condition differently. - ...(found === false && mod.toolMissing ? { blocked: mod.toolMissing } : {}), - ...(managed[p.name] ? { managedCount: managed[p.name] } : {}), - }; - }), - ); -} - -/** - * What the result page's Push stop should say, given how the run ended. - * - * One mapping, keyed off the outcome enum rather than off prose, so the rail - * and the body of the page cannot disagree: a `push-failed` page that reads - * "The push did not land" beside a rail drawing Push as a stop still ahead of - * the traveller is the drift the declared plan exists to remove. - */ -export function pushOutcomeFor(outcome: ConnectOutcome): 'landed' | 'failed' | 'not-reached' { - if (outcome === 'pushed') return 'landed'; - if (outcome === 'push-failed') return 'failed'; - // `local-only` never attempted it, `write-failed` never got that far, and - // `cancelled` stopped at the gate before it. - return 'not-reached'; -} - /** * Discovery's own context — org + auth + serviceClient, exactly what * `resolveOrgContext` already gives org-level commands (`invite`, `kick`, @@ -104,24 +28,8 @@ export class ConnectCommand { this.devMode = devMode; } - /** - * `capy connect` with no provider. - * - * In the terminal this prints a catalogue and stops: the user reads two - * columns and then types a second command. Under `--web` the catalogue is - * the picker, and picking a row continues into the same connect. - */ - async list(opts: ConnectOpts = {}): Promise { - if (opts.web) { - const picked = await this.chooseProviderInBrowser(opts); - if (!picked) { - console.log('\n No connector selected — nothing changed.\n'); - return; - } - await this.execute(picked, opts); - return; - } - + /** `capy connect` with no provider: prints the catalogue and stops. */ + async list(): Promise { console.log(''); console.log(' Available connectors:'); for (const p of listProviders()) { @@ -130,94 +38,24 @@ export class ConnectCommand { console.log(''); } - /** Serve the connector list and return the pick, or null on cancel. */ - private async chooseProviderInBrowser( - opts: ConnectOpts, - unknownProvider?: string, - ): Promise { - const pm = new ProjectManager(); - const keep = pm.readKeepFile(); - const { chooseConnectorInBrowser } = await import('../ui/connectScreens'); - const picked = await chooseConnectorInBrowser({ - projectName: keep?.project_name ?? 'project', - branch: pm.deriveActiveBranch() ?? '', - connectors: await describeConnectors(), - ...(unknownProvider ? { unknownProvider } : {}), - open: shouldOpen(), - }); - return picked.cancelled ? null : picked.provider; - } - - /** - * Loads the named connector, or — under `--web` with an unknown provider — - * lets the browser picker choose a real one and finishes the WHOLE command - * from there, which is why the failure path returns a `shortCircuit` - * carrying `execute`'s own result rather than just a module. - */ - private async resolveProviderOrShortCircuit( - provider: string, - opts: ConnectOpts, - ): Promise<{ kind: 'ok'; mod: ConnectorModule } | { kind: 'shortCircuit'; result: { linked: boolean } }> { + /** Loads the named connector; an unknown provider exits 1 with a pointer back to the catalogue. */ + private async resolveProvider(provider: string): Promise { try { - return { kind: 'ok', mod: await loadProvider(provider) }; + return await loadProvider(provider); } catch (err) { - if (opts.web) { - // The terminal answers a bad provider with `Unknown connector: x` and a - // pointer back to the bare `capy connect`, which is a second command - // for a list the CLI could have shown with the mistake. Here it does. - const picked = await this.chooseProviderInBrowser(opts, provider); - if (picked) { - // RETURN, never `process.exit(0)`: the run that just finished served - // its own ending page from a loopback server in this process, and - // exiting here would close the socket underneath it. Returning lets - // the process end on its own once that page has been read, carrying - // whatever exit code the inner run set. - return { kind: 'shortCircuit', result: await this.execute(picked, opts) }; - } - } console.error(`\n ${(err as Error).message}`); console.error(' Run `capy connect` to see available providers.\n'); process.exit(1); } } - /** - * `writeAndSync`, mapped down to the two states `execute`'s ending needs: a - * push that fails after the local write leaves `.env` holding a key nobody - * else has, which is a different next move than a clean write, so the - * outcome (and, on failure, the detail string) come back rather than being - * assigned onto a variable the try/catch closes over. - */ - private async writeConnectResult( - ctx: ResolvedContext, - opts: ConnectOpts, - result: Pick, - ): Promise<{ outcome: ConnectOutcome; detail?: string }> { - const initialOutcome: ConnectOutcome = opts.noPush ? 'local-only' : 'pushed'; - try { - await writeAndSync(ctx, result.varName, result.value, { - push: !opts.noPush, - connector: result.entry, - alsoConnect: result.also, - }); - return { outcome: initialOutcome }; - } catch (err) { - if (!opts.web) throw err; - return { - outcome: opts.noPush ? 'write-failed' : 'push-failed', - detail: err instanceof Error ? err.message : String(err), - }; - } - } - /** * Returns whether the link was actually recorded. * * A caller that has more journey after this one — `capy rotate` promoting an * unmanaged variable — has to know whether to carry on, and the honest * signal is a return value rather than re-reading keep.lock and inferring it. - * A decline and a failed push both leave `linked: false`; every path that - * ends in `process.exit` never returns at all. + * Every path that ends in `process.exit` never returns at all. */ async execute(provider: string, opts: ConnectOpts): Promise<{ linked: boolean }> { // Live-mode firewall: capy-dev never touches a live key. @@ -232,9 +70,7 @@ export class ConnectCommand { // exiting two screens later. const effective: ConnectOpts = { ...opts, devMode: this.devMode }; - const resolved = await this.resolveProviderOrShortCircuit(provider, opts); - if (resolved.kind === 'shortCircuit') return resolved.result; - const mod = resolved.mod; + const mod = await this.resolveProvider(provider); if (mod.precheck) mod.precheck(); @@ -274,86 +110,29 @@ export class ConnectCommand { // Confirmation gate for live mode in prod: a human typing the account ID. // In assisted non-interactive mode we skip the typed echo — when connect // ran `stripe login`, completing that browser pairing is the human-presence - // proof. The typed confirmation only runs in an interactive terminal, or in - // a browser when one was asked for. - if (!this.devMode && entry.mode === 'live' && (opts.web || isInteractive(opts.nonTty))) { - const ok = opts.web - ? await confirmLiveActionInBrowser({ - action: 'connect', - provider, - projectName: ctx.keep.project_name, - branch: ctx.branch, - varName, - accountId: entry.account_id ?? null, - // Read off the recorded metadata, not off a value: `connect` no - // longer carries one. `key_prefix` exists for exactly this — the - // fingerprint keeps three characters, which cannot tell `sk_test_` - // from `sk_live_` at the confirmation that exists to tell them - // apart. - ...(entry.key_prefix ? { keyPrefix: entry.key_prefix } : {}), - push: !opts.noPush, - pushFromFlag: opts.noPush === true, - accountFromFlag: Boolean(opts.account), - varFromFlag: Boolean(opts.var), - stops: connectPlan({ - provider, - branch: ctx.branch, - requiresTool: mod.requiresTool, - requiresAuth: mod.requiresAuth, - standing: null, - varName, - varFromFlag: Boolean(opts.var), - mode: 'live', - modeFromFlag: Boolean(opts.live), - account: entry.account_id, - accountFromFlag: Boolean(opts.account), - alreadySignedIn: true, - push: !opts.noPush, - pushFromFlag: opts.noPush === true, - }), - open: shouldOpen(), - }) - : await confirmLiveAction({ - action: 'connect', - varName, - accountId: entry.account_id ?? '(unknown)', - keyPrefix: entry.key_prefix ?? '(unknown)', - }); + // proof. The typed confirmation only runs in an interactive terminal. + if (!this.devMode && entry.mode === 'live' && isInteractive(opts.nonTty)) { + const ok = await confirmLiveAction({ + action: 'connect', + varName, + accountId: entry.account_id ?? '(unknown)', + keyPrefix: entry.key_prefix ?? '(unknown)', + }); if (!ok) { console.log(' Cancelled.'); - // The terminal path is unchanged: nothing was written, and the command - // is over. - if (!opts.web) process.exit(0); - // Under `--web` the decline gets a page saying what it left behind — - // and that page is served from THIS process, so the run ends by - // returning rather than by exiting. `process.exit(0)` here closed the - // loopback server microseconds after it started listening, which made - // the ending unreachable and the refusal indistinguishable from a - // successful connect. - await this.showResult(ctx.keep.project_name, ctx.branch, provider, mod.requiresTool, opts, { - outcome: 'cancelled', - varName, - mode: entry.mode as 'test' | 'live' | undefined, - requiresAuth: mod.requiresAuth === true, - }); - return { linked: false }; + // Nothing was written, and the command is over. + process.exit(0); } } - // A push that fails after the local write leaves .env holding a key nobody - // else has, and the terminal reports that as a stack trace. The two states - // need different next moves, so the browser result names which one happened. - const { outcome, detail } = await this.writeConnectResult(ctx, opts, { varName, value, entry, also }); + await writeAndSync(ctx, varName, value, { + push: !opts.noPush, + connector: entry, + alsoConnect: also, + }); - // The terminal's own lines first, then the page. The other order made the - // whole summary wait on a human loading a browser tab, because the ending - // page holds the run open until it has been delivered. - const failed = outcome === 'push-failed' || outcome === 'write-failed'; console.log(''); - if (failed) { - console.error(` ✗ ${B(varName)}: ${detail}`); - console.log(''); - } else if (opts.noPush) { + if (opts.noPush) { // Say what moved AND what did not. The old wording — "wrote VAR to .env" // — described a value write that no longer happens, and a success line // that overstates its own reach is how a user learns the wrong model of @@ -378,40 +157,12 @@ export class ConnectCommand { console.log(''); } - // No ending page for a step that is not the end. `showResult` serves a - // page that says the run is over and holds the process until a browser has - // read it; between the link and the rotation it would be a false ending - // and a second window. The failure endings below this branch are a - // different case — the outer command stops there, so the page is the only - // report there is. - if (opts.web && (failed || !opts.subStep)) { - await this.showResult(ctx.keep.project_name, ctx.branch, provider, mod.requiresTool, opts, { - outcome, - varName, - mode: entry.mode as 'test' | 'live' | undefined, - accountId: entry.account_id, - ...(entry.key_prefix ? { keyPrefix: entry.key_prefix } : {}), - fingerprint: entry.fingerprint, - expiresAt: entry.expires_at, - detail, - requiresAuth: mod.requiresAuth === true, - }); - } - - // `process.exitCode`, never `process.exit`. The failure endings above are - // only reachable under `--web` (without it the throw propagates), and the - // page explaining them is served from this process — an exit here is what - // made "the push did not land" a page nobody could open. The code is - // delivered when the loop drains, which is after the browser has the page. - if (failed) process.exitCode = 1; - return { linked: !failed }; + return { linked: true }; } /** * `capy connect ` — pulls the provider's variables into - * `.env` in one pass, then reports. No browser ending page exists for this - * yet (no screen has been built for an import run); `--json` and the - * terminal are the only two surfaces today. + * `.env` in one pass, then reports (`--json` or the terminal). * * Names and codes only, everywhere — never a value. `outcome.imported` * carries the actual values (needed for the write below); every printed or @@ -521,8 +272,7 @@ export class ConnectCommand { /** * `capy connect dokploy --discover` (CAP-657 follow-up) — reports the plan * (names/counts only, never a value), then — for a real, confirmed, - * non-empty run — what was actually written. No browser ending page exists - * for this yet, same as `executeImport`. + * non-empty run — what was actually written. */ private async executeDiscovery( mod: ConnectorModule, @@ -728,102 +478,6 @@ export class ConnectCommand { console.log(''); return { linked }; } - - /** The tail of the command, as a page. Reports only — nothing here decides. */ - private async showResult( - projectName: string, - branch: string, - provider: string, - requiresTool: string | undefined, - opts: ConnectOpts, - run: { - outcome: ConnectOutcome; - varName: string; - mode?: 'test' | 'live'; - accountId?: string; - keyPrefix?: string; - fingerprint?: string; - expiresAt?: number; - detail?: string; - /** The provider's own flag, not an assumption about every connector. */ - requiresAuth: boolean; - }, - ): Promise { - const { showConnectResultInBrowser } = await import('../ui/connectScreens'); - const expiresInDays = - typeof run.expiresAt === 'number' - ? Math.floor((run.expiresAt - Date.now() / 1000) / 86400) - : undefined; - const stops: ConnectResultData['stops'] = connectPlan({ - provider, - branch, - requiresTool, - requiresAuth: run.requiresAuth, - standing: null, - varName: run.varName, - varFromFlag: Boolean(opts.var), - ...(run.mode ? { mode: run.mode } : {}), - modeFromFlag: Boolean(opts.live), - ...(run.accountId ? { account: run.accountId } : {}), - accountFromFlag: Boolean(opts.account), - // Every outcome this page reports arrives AFTER `mod.connect()` returned - // a key, so the provider session existed by then however it got there. - // Drawing "Sign in" as still upcoming on a finished run is the same - // drift as drawing Push as upcoming on a run that pushed. - signedIn: true, - push: !opts.noPush, - pushFromFlag: opts.noPush === true, - pushOutcome: pushOutcomeFor(run.outcome), - }); - await showConnectResultInBrowser({ - outcome: run.outcome, - provider, - projectName, - branch, - varName: run.varName, - ...(run.mode ? { mode: run.mode } : {}), - ...(run.accountId ? { accountId: run.accountId } : {}), - ...(run.keyPrefix ? { keyPrefix: run.keyPrefix } : {}), - ...(run.fingerprint ? { fingerprint: run.fingerprint } : {}), - ...(expiresInDays !== undefined ? { expiresInDays } : {}), - ...(run.detail ? { detail: run.detail } : {}), - stops, - open: shouldOpen(), - }); - } -} - -/** - * The route a rotation's live gate draws. - * - * Rotation reaches `confirmLiveAction` with the variable and the account - * already settled — the variable positionally, the account off the keep.lock - * entry — so both stops are `done`. The variable stop carries no `flag` - * deliberately: `capy rotate` takes the variable positionally, and naming a - * flag would be the rail telling the reader to retype an argument the command - * would reject. - */ -export function rotateLiveGateStops(args: { - provider: string; - branch: string; - varName: string; - accountId?: string; - push: boolean; - pushFromFlag?: boolean; -}): ConnectLiveGateStop[] { - return connectPlan({ - provider: args.provider, - branch: args.branch, - requiresTool: args.provider === 'stripe' ? 'stripe' : undefined, - requiresAuth: true, - standing: null, - varName: args.varName, - mode: 'live', - ...(args.accountId ? { account: args.accountId } : {}), - alreadySignedIn: true, - push: args.push, - ...(args.pushFromFlag ? { pushFromFlag: true } : {}), - }); } /** diff --git a/src/commands/connectors/dokploy.ts b/src/commands/connectors/dokploy.ts index 6af224d8..091a6060 100644 --- a/src/commands/connectors/dokploy.ts +++ b/src/commands/connectors/dokploy.ts @@ -63,6 +63,7 @@ import { listOrgProjectsOrUnavailable } from '../capyCommand'; import { commitDiscoveryChanges, defaultDiscoveryCommitBranchName, isValidDiscoveryCommitBranchName, snapshotPathStatus } from '../../git/discoveryCommit'; import { fingerprint, writeImportedAndSync, writeImportOutcome, ResolvedContext } from './shared'; import { ConnectOpts, ConnectorModule, ConnectResult, ImportOutcome, ImportWarning, RotateResult } from './registry'; +import { discoveryProjectQuestion } from '../../ui/projectQuestions'; import { CandidateRepo, DISCOVERY_PROJECT_NAME_MAX_LENGTH, @@ -569,17 +570,13 @@ async function defaultAskDiscoveryBaseUrl(): Promise { export const DISCOVERY_NEW_PROJECT = '__capy_discovery_new_project__'; // COPY-FLAG: new user-facing string, minimal/neutral wording. -async function defaultAskExistingOrNewProject( +export async function defaultAskExistingOrNewProject( existing: ReadonlyArray<{ id: string; name: string }>, defaultName: string, ): Promise { const inquirer = (await import('inquirer')).default; - const choices = [ - { name: `New project (${defaultName})`, value: DISCOVERY_NEW_PROJECT }, - ...existing.map((p) => ({ name: p.name, value: p.id })), - ]; const { projectChoice } = await inquirer.prompt([ - { type: 'list', name: 'projectChoice', message: 'Which project should this folder use?', choices, default: DISCOVERY_NEW_PROJECT }, + discoveryProjectQuestion(existing, defaultName, DISCOVERY_NEW_PROJECT), ]); return projectChoice; } @@ -900,12 +897,6 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect }, async import(ctx: ResolvedContext, opts: ConnectOpts): Promise { - // No `--web` branching here by design: browser screens are a separate - // surface (built in the Keep workbench, later phase — see - // docs/dokploy-deploy-adapter.md's "Browser setup" section for the - // equivalent stance on the deploy side). `--web` flows through - // unchanged, exactly as the shared `connect` code path already handles - // it for every connector. // `--environment` is a DISCOVERY-mode concept (there is no "plan" to // filter for a single-service import) — refused outright, zero // requests, before anything else. Never true for discovery's OWN @@ -938,13 +929,11 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect // Resolved BEFORE any Dokploy request — a missing/refused token still // means zero requests (see the class doc's "READ-ONLY" note). A - // SEPARATE interactive flag from the one above: `--web` (the store's - // prompt is a raw terminal prompt, not a browser screen), `--json` - // (machine output must never have a prompt interleaved with it) and - // `--dry-run` (a preview must never have the side effect of saving a - // new key into the org store) all suppress ONLY this prompt, not the - // settings prompts above. - const secretsInteractive = dokploySecretsMayPrompt(interactive, !!opts.web || !!opts.json || dryRun); + // SEPARATE interactive flag from the one above: `--json` (machine output + // must never have a prompt interleaved with it) and `--dry-run` (a + // preview must never have the side effect of saving a new key into the + // org store) suppress ONLY this prompt, not the settings prompts above. + const secretsInteractive = dokploySecretsMayPrompt(interactive, !!opts.json || dryRun); const resolved = await resolveDokployApiKey({ tokenEnv, env: deps.env ?? process.env, @@ -1107,8 +1096,8 @@ export function createDokployConnector(deps: DokployConnectorDeps = {}): Connect } // Same suppression rule as the single-service import: never prompt for - // the key under --web/--json/--dry-run. - const secretsInteractive = dokploySecretsMayPrompt(interactive, !!opts.web || !!opts.json || dryRun); + // the key under --json/--dry-run. + const secretsInteractive = dokploySecretsMayPrompt(interactive, !!opts.json || dryRun); const resolved = await resolveDokployApiKey({ tokenEnv, env: deps.env ?? process.env, diff --git a/src/commands/connectors/plans.ts b/src/commands/connectors/plans.ts index a471961c..6ffe6c73 100644 --- a/src/commands/connectors/plans.ts +++ b/src/commands/connectors/plans.ts @@ -1,28 +1,14 @@ /** - * The routes `capy connect` and `capy rotate` travel, computed before either - * of them opens anything. + * The route `capy rotate` travels, computed before it runs. * - * ONE builder per command, because the whole claim behind the browser screens - * is that the rail a person reads, the diagram the terminal prints and the - * array a headless caller parses are the same object. `rotateCommand` already - * had a route — `renderRotationPlan` is the diagram every screen's rail was - * modelled on — but it built the stops inline, printed them and dropped them, - * so the browser had nothing to draw and `--json` had nothing to emit. The - * stops are built here now and all three surfaces render what these return. + * The stops are built here and the terminal's diagram (`renderRotationPlan`) + * renders what this returns. An explicit flag settles a stop before the run + * starts; a flag-answered stop is `done` carrying the flag that supplied it — + * never `skipped`, because the plan resolved it rather than dropping it. * - * The precedence is §8.2's, and it is why a stop can arrive already answered: - * an explicit flag settles a stop before the run starts, existing state - * settles the next ones, the browser is asked about whatever is left. A - * flag-answered stop is `done` carrying the flag that supplied it — never - * `skipped`, because the plan resolved it rather than dropping it, and "Mode · - * live" with no marker is indistinguishable from a question the user answered - * two seconds ago. - * - * Neither plan carries key material: names, modes, account ids and counts. + * The plan carries no key material: names, modes, account ids and counts. */ import type { - ConnectStep, - ConnectStop, RotatePlanStop, RotateStep, StopState, @@ -43,191 +29,6 @@ function stateFor(id: string, answered: boolean, standing: string | null | undef return standing === id ? 'current' : 'upcoming'; } -// --------------------------------------------------------------------------- -// capy connect -// --------------------------------------------------------------------------- - -export interface ConnectPlanInput { - /** Registry name of the provider being connected, e.g. `stripe`. */ - provider: string; - /** The branch the key is written to and pushed to. */ - branch: string; - /** - * Local binary the provider's `precheck` looks for, e.g. `stripe`. Omitted - * for a connector that checks nothing — the stop then does not exist, - * rather than existing and claiming a tool was found. - */ - requiresTool?: string; - /** The provider hands off to a sign-in the user completes by hand. */ - requiresAuth?: boolean; - /** - * Where the run is standing. `null` or omitted once every question is - * behind it — which is what the result screen's rail wants. - */ - standing?: ConnectStep | null; - /** Settled by `--var`, or by the browser's variable step. */ - varName?: string; - /** `--var` supplied it, so it was never asked. */ - varFromFlag?: boolean; - mode?: 'test' | 'live'; - /** `--live` supplied it. There is no `--test`: test is the default. */ - modeFromFlag?: boolean; - account?: string; - /** `--account` supplied it. */ - accountFromFlag?: boolean; - /** - * A paired provider session was already on this machine, so the sign-in - * stop is one this run does not travel. Undefined until the config is read. - */ - alreadySignedIn?: boolean; - /** The hand-off ran and came back, so the stop is behind the traveller. */ - signedIn?: boolean; - /** False under `--no-push`: the key lands in .env and goes no further. */ - push: boolean; - /** `--no-push` was explicit rather than the default. */ - pushFromFlag?: boolean; - /** - * What became of the push, once the run is over. - * - * Absent while the route is still ahead of the traveller, which is every - * screen that asks a question. It exists for the result page: a rail drawn - * on a FINISHED run that still says `○ Push — encrypt and push to Capy` - * beside a body reading "the push did not land" is the drift these plans - * were built to remove, and it is worse than no rail at all because it is - * the half of the page that looks authoritative. - * - * `landed` — the push went through. - * `failed` — it ran and did not land; the run stopped here. - * `not-reached` — the run ended before the push was attempted (a declined - * gate, a local write that failed), so the stop is still ahead of it. - */ - pushOutcome?: 'landed' | 'failed' | 'not-reached'; -} - -/** - * The stops `capy connect ` travels, in the order it travels them. - * - * The order is `connect()`'s own — variable, mode, sign-in, account, push — - * not the dependency order the screen's documentation describes. A rail - * is a claim about the run in front of you, and a rail whose stops are ordered - * one way while the questions arrive another would be describing a different - * command. - * - * The overwrite guard is deliberately not a stop. It is not a step on the - * route, it is the route being interrupted by something already in the way, - * and drawing a station for it would imply the plan expected it. - */ -export function connectPlan(input: ConnectPlanInput): ConnectStop[] { - const stops: ConnectStop[] = []; - - if (input.requiresTool) { - // `precheck` exits before anything can be asked, so by the time any of - // this is drawn the tool was found. A failed precheck arrives as - // `blocked`, not as a question — but the stop stays on the rail, because - // the route really did run through it. - stops.push({ - id: 'cli', - label: `${cap(input.provider)} CLI`, - state: 'done', - detail: `${input.requiresTool} on your PATH`, - answer: 'found', - }); - } - - stops.push({ - id: 'var', - label: 'Variable', - state: stateFor('var', input.varName !== undefined, input.standing), - detail: 'which .env name holds the key', - ...(input.varName !== undefined ? { answer: input.varName } : {}), - ...(input.varFromFlag ? { flag: '--var' } : {}), - }); - - stops.push({ - id: 'mode', - label: 'Mode', - state: stateFor('mode', input.mode !== undefined, input.standing), - detail: 'a test key cannot charge anyone; a live key can', - ...(input.mode !== undefined ? { answer: input.mode } : {}), - // Only `--live` exists. `--test` does not, so a test-mode run answered by - // the absence of a flag has no flag to name, and naming one would tell the - // reader to retype an argument the command would reject. - ...(input.modeFromFlag ? { flag: '--live' } : {}), - }); - - if (input.requiresAuth) { - stops.push({ - id: 'auth', - label: 'Sign in', - // A browser pairing the user completes by hand while the CLI polls. - // Capy cannot do it, and the diagram should not imply it can. - manual: true, - state: input.alreadySignedIn - ? 'skipped' - : stateFor('auth', input.signedIn === true, input.standing), - detail: `${input.provider} login opens in your browser`, - ...(input.alreadySignedIn - ? { answer: 'already paired' } - : input.signedIn - ? { answer: 'paired' } - : {}), - }); - } - - stops.push({ - id: 'account', - label: 'Account', - state: stateFor('account', input.account !== undefined, input.standing), - detail: `which ${cap(input.provider)} account the key comes from`, - ...(input.account !== undefined ? { answer: input.account } : {}), - ...(input.accountFromFlag ? { flag: '--account' } : {}), - }); - - stops.push({ - id: 'push', - label: 'Push', - // Never a question: `--no-push` settles it and nothing else asks. It is - // the terminal stop of the route, which is why it is `upcoming` rather - // than `current` even when everything before it is answered — until the - // run is over and `pushOutcome` says what became of it. - // - // A failed push is drawn as the stop the traveller is STANDING on, which - // is where the run stopped. There is no `failed` in `StopState` (the four - // states are done, current, upcoming, skipped), and the other three would - // each be a lie: `done` claims it worked, `upcoming` claims it has not - // happened yet, `skipped` claims it was not needed. `blank` on top of it - // is the CLI's own ◌ — the plan has a hole here — which is exactly the - // fact. REPORTED, not patched: the screens' `StopState` has no way to say - // "this stop was attempted and failed", and it should. - state: - input.pushOutcome === 'landed' - ? 'done' - : input.pushOutcome === 'failed' - ? 'current' - : 'upcoming', - // What travels here is the keep.lock connector record, not a credential. - // `connect` does not write a value, so "encrypt and push" — which this - // said while the command was overwriting `.env` — now describes a thing - // that does not happen. The env blob is re-encrypted on the way past, but - // its contents are the same ones that went in. - detail: - input.pushOutcome === 'failed' - ? `did not reach Capy (branch: ${input.branch})` - : input.push - ? `record the link with Capy (branch: ${input.branch})` - : 'record the link on this machine only', - ...(input.pushOutcome === 'failed' ? { blank: true } : {}), - ...(input.pushOutcome === 'landed' - ? { answer: `pushed to ${input.branch}` } - : input.push - ? {} - : { answer: 'local only' }), - ...(input.pushFromFlag ? { flag: '--no-push' } : {}), - }); - - return stops; -} - // --------------------------------------------------------------------------- // capy rotate // --------------------------------------------------------------------------- diff --git a/src/commands/connectors/registry.ts b/src/commands/connectors/registry.ts index 3e4fd5e0..fd85b332 100644 --- a/src/commands/connectors/registry.ts +++ b/src/commands/connectors/registry.ts @@ -35,15 +35,6 @@ export interface ConnectOpts { * way to ask for it deliberately. */ reauth?: boolean; - /** - * Render this command's questions as compiled screens in a local browser. - * - * Not the same axis as `nonTty`, and the pairing is the whole point: the - * caller `--web` exists for runs with piped stdio, so `isInteractive()` is - * already false and every picker below has either defaulted or refused - * without asking. `--web` is what turns those back into questions. - */ - web?: boolean; /** * A `capy-dev` binary. Live mode is refused outright, so the mode question * says so beside the option rather than accepting it and exiting afterwards. @@ -168,8 +159,6 @@ export interface RotateOpts { noPush?: boolean; /** Disable interactive prompts: resolve every choice from flags or fail fast. */ nonTty?: boolean; - /** Render this command's questions as compiled screens in a local browser. */ - web?: boolean; } /** Result of provider.connect(): the provider hands us a value + the connector metadata to record on the keep.lock entry. */ diff --git a/src/commands/connectors/stripe.ts b/src/commands/connectors/stripe.ts index d88c38e2..1be61edb 100644 --- a/src/commands/connectors/stripe.ts +++ b/src/commands/connectors/stripe.ts @@ -4,25 +4,14 @@ import { join } from 'path'; import { homedir } from 'os'; import { ConnectorModule, ConnectResult, RotateResult, ConnectOpts, RotateOpts } from './registry'; import { ConnectorMetadata } from '../../types/index'; -import { ResolvedContext, fingerprint, findManagedConnector, keyTypePrefix } from './shared'; -import { formatRelativeTime } from '../../ui/relativeTime'; +import { ResolvedContext, fingerprint, keyTypePrefix } from './shared'; import { isInteractive, refuseNonInteractive } from '../../ui/interactive'; -import { connectPlan, type ConnectPlanInput } from './plans'; -import type { - Blocked, - ConnectIncomingKey, - ConnectModeOption, - ConnectVarSlot, - ConnectVarState, - StripeAccount, -} from '../../ui/screens/contract'; -import type { ConnectQuestion, WebConnectSetupParams } from '../../ui/connectScreens'; +import type { Blocked } from '../../ui/screens/contract'; type StripeMode = 'test' | 'live'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; const DIM = (s: string) => `\x1b[90m${s}\x1b[0m`; -const DEFAULT_VAR = 'STRIPE_SECRET_KEY'; // Name patterns we associate with stripe. Value-based matching isn't worth it // — encrypted vars round-trip through capy: blobs, and the name alone is @@ -39,13 +28,10 @@ export function looksStripey(name: string): boolean { * upstream if they want alphabetical ordering). */ export function rankStripeVars(names: string[]): { matches: string[]; others: string[] } { - const matches: string[] = []; - const others: string[] = []; - for (const n of names) { - if (looksStripey(n)) matches.push(n); - else others.push(n); - } - return { matches, others }; + return { + matches: names.filter((n) => looksStripey(n)), + others: names.filter((n) => !looksStripey(n)), + }; } export function validateVarName(name: string): string | true { @@ -219,7 +205,7 @@ export interface StripePairing { /** * How a sign-in ended, as a code rather than a sentence. * - * Four outcomes and the caller has to tell them apart, because three of them + * Three failures and the caller has to tell them apart, because three of them * send the reader somewhere different: an unapproved pairing is a person who * walked away, an unsupported CLI is a `brew upgrade`, and a config that came * back unusable after a pairing that DID complete is the only one of the three @@ -230,14 +216,6 @@ export type StripeLoginOutcome = | { ok: true } /** The hand-off ran and nobody approved it. A wall: the run could not go on. */ | { ok: false; reason: 'pairing-not-completed' } - /** - * Somebody said no — Cancel, or the window closed on the pairing screen. - * - * Apart from the others because it is not a failure. Nothing was written and - * that was the intention, so it ends the run at 0; reporting a decision as a - * fault is how a caller learns the wrong thing from a run that did nothing. - */ - | { ok: false; reason: 'declined' } /** This `stripe` binary has no two-step non-interactive flow. */ | { ok: false; reason: 'pairing-unsupported' } /** `stripe login` itself exited non-zero. Only reachable on the TTY path. */ @@ -277,6 +255,25 @@ function pollUrlFrom(nextStep: string, browserOrigin: string): string | null { } } +/** The parsed JSON, or `undefined` when the text is not JSON. */ +function parseJsonOrUndefined(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** The origin of an https URL, or `null` for anything else (including a non-URL). */ +function httpsOriginOf(url: string): string | null { + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' ? parsed.origin : null; + } catch { + return null; + } +} + /** * `stripe login --non-interactive`'s stdout, as the three things we act on. * @@ -291,26 +288,15 @@ export function parseStripePairing(stdout: string): StripePairing | null { const start = stdout.indexOf('{'); const end = stdout.lastIndexOf('}'); if (start === -1 || end <= start) return null; - let raw: unknown; - try { - raw = JSON.parse(stdout.slice(start, end + 1)); - } catch { - return null; - } + const raw = parseJsonOrUndefined(stdout.slice(start, end + 1)); if (!raw || typeof raw !== 'object') return null; const o = raw as Record; const browserUrl = typeof o.browser_url === 'string' ? o.browser_url : ''; const nextStep = typeof o.next_step === 'string' ? o.next_step : ''; const verificationCode = typeof o.verification_code === 'string' ? o.verification_code : ''; if (!browserUrl || !nextStep) return null; - let browserOrigin: string; - try { - const parsed = new URL(browserUrl); - if (parsed.protocol !== 'https:') return null; - browserOrigin = parsed.origin; - } catch { - return null; - } + const browserOrigin = httpsOriginOf(browserUrl); + if (browserOrigin === null) return null; const pollUrl = pollUrlFrom(nextStep, browserOrigin); if (!pollUrl) return null; return { browserUrl, verificationCode, pollUrl }; @@ -360,40 +346,21 @@ function completeStripePairing(pairing: StripePairing, projectName?: string): bo } /** - * Sign in to Stripe, on whichever of the three surfaces this run actually has. - * - * `handoff` is the browser one and it owns the whole middle of the flow: it is - * given the pairing to render and the thunk that blocks, because the only place - * a loopback wizard may block is inside its own reducer. Without one — a script, - * a CI job, a piped run with no `--web` — the pairing is announced on the - * terminal and completed inline. + * Sign in to Stripe, on whichever of the two surfaces this run actually has. * * A REAL TTY IS UNTOUCHED. `stripe login` with an inherited terminal does all of * this itself, better, and has always worked; the two-step exists precisely - * because that is the path a non-TTY caller never gets. + * because that is the path a non-TTY caller never gets. Without a TTY — a + * script, a CI job, a piped run — the pairing is announced on the terminal and + * completed inline. */ async function runStripeLogin(args: { projectName?: string; nonTty?: boolean; - /** - * This run's stdout is a relay to an AI agent rather than a terminal someone - * is reading — which is what `--web` means, and the whole reason it exists. - * - * It gates ONE line: the pairing URL, which carries the session token in its - * query string. The verification code goes out either way, because it is a - * string to compare against the one Stripe shows and is worth nothing without - * the URL. A caller that sets this and supplies no `handoff` is trading a - * fallback for a leak, and that is the right trade — see `rotate`. - */ - web?: boolean; - handoff?: ( - pairing: StripePairing, - complete: () => boolean, - ) => Promise<'paired' | 'declined' | 'not-approved'>; }): Promise { - const { projectName, nonTty, web, handoff } = args; + const { projectName, nonTty } = args; - if (!handoff && isInteractive(nonTty)) { + if (isInteractive(nonTty)) { const result = spawnSync('stripe', ['login', ...projectArgs(projectName)], { stdio: 'inherit' }); return result.status === 0 ? { ok: true } : { ok: false, reason: 'login-failed' }; } @@ -401,18 +368,6 @@ async function runStripeLogin(args: { const pairing = startStripePairing(projectName); if (!pairing) return { ok: false, reason: 'pairing-unsupported' }; - const complete = (): boolean => completeStripePairing(pairing, projectName); - - if (handoff) { - // NOTHING ABOUT THE PAIRING IS PRINTED HERE. The browser surface shows the - // code on a page only the user can see, and `browser_url` carries the - // pairing token in its query string — putting it on stdout would hand the - // agent the very thing this branch exists to keep from it. - const ending = await handoff(pairing, complete); - if (ending === 'paired') return { ok: true }; - return { ok: false, reason: ending === 'declined' ? 'declined' : 'pairing-not-completed' }; - } - // writeSync (not console.log) so this flushes before the blocking // `--complete` spawnSync — otherwise a backgrounded run buffers it and it // appears only after auth completes, out of order with the pairing code. @@ -421,20 +376,14 @@ async function runStripeLogin(args: { `\n ${B('Approve this pairing in your browser.')} Check that Stripe shows you the\n` + ' same code, then approve. Waiting for you to authenticate…\n\n' + ` Code: ${B(pairing.verificationCode)}\n` + - // THE URL IS WITHHELD ON A RELAYED STDOUT. It carries the pairing token, - // and the reader there is the agent. Losing the fallback costs a browser - // that failed to open; printing it costs the thing being paired. - (web - ? ' A Stripe tab should have opened. If it did not, run `stripe login`\n' + - ' in a terminal and then re-run this command.\n\n' - : ` Open: ${pairing.browserUrl}\n\n`), + ` Open: ${pairing.browserUrl}\n\n`, ); // Best effort. A hand-off wants the whole browser — an address bar, a profile // picker — so it is never the chromeless dialog a loopback screen gets. const { openScreen } = await import('../../ui/openScreen'); await openScreen(pairing.browserUrl, { kind: 'handoff' }); - return complete() ? { ok: true } : { ok: false, reason: 'pairing-not-completed' }; + return completeStripePairing(pairing, projectName) ? { ok: true } : { ok: false, reason: 'pairing-not-completed' }; } /** @@ -445,8 +394,7 @@ async function runStripeLogin(args: { * about to print its own status anyway. */ function runStripeLogout(projectName?: string): boolean { - const args = ['logout']; - if (projectName && projectName !== 'default') args.push(`--project-name=${projectName}`); + const args = ['logout', ...projectArgs(projectName)]; const result = spawnSync('stripe', args, { stdio: 'pipe' }); return result.status === 0; } @@ -524,48 +472,12 @@ async function pickMode(forceLive: boolean, nonTty?: boolean): Promise 7 ? fingerprint(value) : undefined; -} - -/** The section a run would use, without exiting when there isn't one. */ -function resolveSection( - sections: StripeConfigSection[], - requested?: string, -): StripeConfigSection | undefined { - if (requested) { - return sections.find( - (s) => s.account_id === requested || s.name === requested || s.display_name === requested, - ); - } - return sections.length === 1 ? sections[0] : undefined; -} - /** The key a section holds for a mode, without exiting when it holds none. */ function keyForMode( section: StripeConfigSection, @@ -606,199 +518,6 @@ export function usableFor(sections: StripeConfigSection[], mode: StripeMode): St }); } -/** config.toml's sections, as the account picker's rows. */ -export function toStripeAccounts(sections: StripeConfigSection[]): StripeAccount[] { - return sections.map((s) => ({ - id: s.name, - ...(s.display_name ? { displayName: s.display_name } : {}), - ...(s.account_id ? { accountId: s.account_id } : {}), - hasTestKey: Boolean(s.test_mode_api_key), - hasLiveKey: Boolean(s.live_mode_api_key), - })); -} - -/** - * The two modes, and whether this machine can actually serve each of them. - * - * The terminal offers `Test` and `Live` unconditionally and finds out - * afterwards, in `readKeyFromSection`, that there is no key of that mode — an - * exit, one question too late. Availability is known here, from the same - * config the key is read out of. - */ -export function stripeModeOptions( - sections: StripeConfigSection[], - requested: string | undefined, - devMode: boolean, -): ConnectModeOption[] { - const section = resolveSection(sections, requested); - return (['test', 'live'] as const).map((id) => { - if (id === 'live' && devMode) { - // capy-dev points at a dev service and a live key must never cross that - // line. The command refuses it twice already; saying so beside the - // option is the same refusal, one question earlier. - return { id, available: false, blockedBy: 'DEV_MODE' as const }; - } - const key = section ? keyForMode(section, id) : undefined; - // With no section resolved — several accounts and none named, or nothing - // paired yet — availability is genuinely unknown, so the mode is offered - // when ANY section could serve it. A definite `false` here would be a - // claim this code cannot make. - const possible = section - ? Boolean(key) - : sections.length === 0 || sections.some((s) => keyForMode(s, id)); - return { - id, - available: possible, - ...(possible ? {} : { blockedBy: 'NO_KEY' as const }), - ...(key && keyTypePrefix(key.value) ? { keyPrefix: keyTypePrefix(key.value) as string } : {}), - ...(key && daysUntil(key.expiresAt) !== undefined - ? { expiresInDays: daysUntil(key.expiresAt) as number } - : {}), - }; - }); -} - -/** - * The condition where the mode question has no answer — or nothing, when at - * least one mode can still run. - * - * `stripeModeOptions` can refuse BOTH: one config section holding only a live - * key, read by `capy-dev`, makes live `DEV_MODE` and test `NO_KEY`. The screen - * disables an unavailable row and `askConnectInBrowser` refuses one on submit, - * so what is left is a page with two dead options and no way forward. A state - * with no runnable answer is a wall, and a wall says why. - * - * Keyed off `blockedBy` codes, never off the copy the screen renders for them. - */ -export function noRunnableMode(modes: ConnectModeOption[]): Blocked | undefined { - if (modes.some((m) => m.available)) return undefined; - const blockedBy = new Set(modes.map((m) => m.blockedBy)); - if (blockedBy.has('DEV_MODE')) { - return { - code: 'DEV_MODE_NO_TEST_KEY', - title: 'capy-dev cannot use the only key Stripe is holding', - detail: - 'The Stripe config has a live-mode key and no test-mode key, and capy-dev refuses live mode outright — a dev build points at a dev service, and a live key must never cross that line.', - remedy: 'stripe login', - }; - } - return { - code: 'NO_STRIPE_KEY', - title: 'No Stripe API key on this machine', - detail: - 'The Stripe CLI config holds neither a test-mode nor a live-mode key for this account, so there is no key for Capy to read. Pairing again writes one.', - remedy: 'stripe login', - }; -} - -/** The variables already on this branch, as the variable step's rows. */ -export function stripeVarSlots(ctx: ResolvedContext): ConnectVarSlot[] { - const { matches, others } = rankStripeVars(Object.keys(ctx.localPlaintext).sort()); - return [...matches, ...others].map((name) => ({ - name, - looksRelated: looksStripey(name), - // Every row here is a name .env already holds. `hasValue` records that - // fact rather than testing for emptiness. It no longer routes anything - // through an overwrite guard — `connect` stopped writing values, so there - // is no overwrite left to guard — and the screen uses it to say which - // slots are occupied. - hasValue: true, - ...(findManagedConnector(ctx.keep, name, ctx.branch)?.provider - ? { managedBy: findManagedConnector(ctx.keep, name, ctx.branch)!.provider } - : {}), - })); -} - -/** What is sitting in the slot the key would replace. Shape only, never the value. */ -export function currentVarState(ctx: ResolvedContext, varName: string): ConnectVarState { - const connector = findManagedConnector(ctx.keep, varName, ctx.branch); - const entry = (ctx.keep.variables[varName] ?? []).find((e) => e.branch === ctx.branch); - const snippet = safeSnippet(ctx.localPlaintext[varName] ?? ''); - return { - ...(snippet ? { fingerprint: snippet } : {}), - ...(connector?.provider ? { managedBy: connector.provider } : {}), - ...(connector?.mode === 'test' || connector?.mode === 'live' ? { mode: connector.mode } : {}), - ...(typeof connector?.created_at === 'number' - ? { age: formatRelativeTime(new Date(connector.created_at * 1000).toISOString()) } - : {}), - // A keep.lock entry seeded by `attachConnector` before any push carries an - // empty resource_id and value_hash. Anything else came back from the - // service, which means teammates on this branch are holding it. - pushed: Boolean(entry && entry.value_hash !== ''), - }; -} - -/** - * The key that would replace it, as far as the run has committed to one. - * - * NO LONGER ON THE CONNECT PATH. It described the incoming half of the - * overwrite guard, and `connect` no longer overwrites anything — it records - * the link and leaves the value alone. Kept because the `overwrite` screen is - * still declared in the contract and this is the only honest describer for it, - * but nothing in the command calls it. - * - * The limitation that made it worth removing, recorded so a future caller does - * not reintroduce it: it runs before the account is settled and before any - * `stripe login`, so with several accounts paired and none named there is no - * key to describe. The contract makes `keyPrefix` and `fingerprint` required, - * so they arrive as empty strings — a guard that renders a blank where the - * replacement should be. Any flow that adopts this must settle the account - * first. - */ -export function describeIncomingKey( - sections: StripeConfigSection[], - mode: StripeMode, - requested?: string, -): ConnectIncomingKey { - const section = resolveSection(sections, requested); - const key = section ? keyForMode(section, mode) : undefined; - return { - keyPrefix: (key && keyTypePrefix(key.value)) || '', - mode, - ...(section?.account_id ? { accountId: section.account_id } : {}), - fingerprint: key ? (safeSnippet(key.value) ?? '') : '', - }; -} - -/** Everything `connectPlan` needs that is not an answer this session collects. */ -function planFor( - ctx: ResolvedContext, - opts: ConnectOpts, - settled: Partial = {}, -): ConnectPlanInput { - return { - provider: 'stripe', - branch: ctx.branch, - requiresTool: 'stripe', - requiresAuth: true, - ...(opts.var ? { varName: opts.var.trim(), varFromFlag: true } : {}), - ...(opts.live ? { mode: 'live' as const, modeFromFlag: true } : {}), - ...(opts.account ? { account: opts.account, accountFromFlag: true } : {}), - push: !opts.noPush, - ...(opts.noPush ? { pushFromFlag: true } : {}), - ...settled, - }; -} - -/** The params every `connect-setup` serve in this file shares. */ -function webParams( - ctx: ResolvedContext, - opts: ConnectOpts, - questions: ConnectQuestion[], - settled: Partial = {}, -): WebConnectSetupParams { - return { - provider: 'stripe', - projectName: ctx.keep.project_name, - branch: ctx.branch, - plan: planFor(ctx, opts, settled), - questions, - // Honoured by every browser path so a test never opens the developer's - // real browser; the URL is printed either way. - open: !process.env.CAPY_WEB_NO_OPEN, - }; -} - function readKeyFromSection(section: StripeConfigSection, mode: StripeMode): { value: string; expiresAt?: number } { const value = mode === 'live' ? section.live_mode_api_key : section.test_mode_api_key; const expiresAt = mode === 'live' ? section.live_mode_key_expires_at : section.test_mode_key_expires_at; @@ -876,15 +595,11 @@ async function pickVarName(ctx: ResolvedContext, opts: ConnectOpts): Promise 0) { - for (const n of matches) { - choices.push({ name: `${n} ${DIM('(looks like a Stripe var)')}`, value: n }); - } - if (others.length > 0) choices.push(new inquirer.Separator()); - } - for (const n of others) choices.push({ name: n, value: n }); + const choices: any[] = [ + ...matches.map((n) => ({ name: `${n} ${DIM('(looks like a Stripe var)')}`, value: n })), + ...(matches.length > 0 && others.length > 0 ? [new inquirer.Separator()] : []), + ...others.map((n) => ({ name: n, value: n })), + ]; const { picked } = await inquirer.prompt([{ type: 'list', @@ -898,149 +613,14 @@ async function pickVarName(ctx: ResolvedContext, opts: ConnectOpts): Promise { - const { askConnectInBrowser } = await import('../../ui/connectScreens'); - - // `--var` settles the variable, with the same validation the terminal path - // applies — a flag is not an excuse to write a name .env cannot hold. - let varName = ''; - if (opts.var) { - const problem = validateVarName(opts.var); - if (problem !== true) { - console.error(`\n ${problem} (got "${opts.var}")\n`); - process.exit(1); - } - varName = opts.var.trim(); - } - let mode: StripeMode | undefined = opts.live ? 'live' : undefined; - - // Read once, before anything opens: it is a pure read of Stripe's own - // config, and it is what lets the rail say whether a sign-in is coming and - // the mode question say which modes this machine can actually serve. - const sections = readStripeConfig(); - // `connect()` runs `stripe login` exactly when there is no section, so this - // is not a guess about the future — it is the condition that decides it. - const settled: Partial = { - // APPROXIMATE, and deliberately so: the mode may not be settled yet, so - // the precise question `connect()` will ask — "is there a usable key for - // THIS mode" — cannot be answered here. "Some section holds a key of - // either kind" is the closest honest preview, and it is already stricter - // than the `sections.length > 0` this used to be, which counted a section - // carrying nothing but an account id. - alreadySignedIn: - opts.reauth !== true && - sections.some((sec) => keyForMode(sec, 'test') || keyForMode(sec, 'live')), - ...(resolveSection(sections, opts.account)?.account_id - ? { account: resolveSection(sections, opts.account)!.account_id } - : {}), - }; - - // Both in one session, so answering the first is a page reload rather than a - // second URL to relay. They are separable from the guard below because - // neither depends on the other's answer — the guard depends on both. - const questions: ConnectQuestion[] = []; - if (!varName) { - questions.push({ kind: 'var', vars: stripeVarSlots(ctx), defaultVarName: DEFAULT_VAR }); - } - if (!mode) { - const modes = stripeModeOptions(sections, opts.account, opts.devMode === true); - const wall = noRunnableMode(modes); - if (wall) { - // Both modes refused. The screen disables an unavailable row and the - // reducer refuses one, so asking here would be serving a question with - // no answer: the run would sit on that page until the wizard's - // five-minute timeout, having told the user nothing. A wall is an - // ending, not a question — it goes out through the ending server, which - // waits for the browser to have it and then lets the command finish. - const { showConnectBlockedInBrowser } = await import('../../ui/connectScreens'); - await showConnectBlockedInBrowser({ - provider: 'stripe', - projectName: ctx.keep.project_name, - branch: ctx.branch, - step: 'mode', - stops: connectPlan({ - ...planFor(ctx, opts, { ...settled, ...(varName ? { varName } : {}) }), - standing: 'mode', - }), - blocked: wall, - open: !process.env.CAPY_WEB_NO_OPEN, - }); - return { varName, mode: 'test', cancelled: true, blocked: true }; - } - questions.push({ kind: 'mode', modes }); - } - if (questions.length > 0) { - const out = await askConnectInBrowser( - webParams(ctx, opts, questions, { ...settled, ...(varName ? { varName } : {}) }), - ); - if (out.cancelled) return { varName, mode: mode ?? 'test', cancelled: true }; - varName = out.answers.var ?? varName; - mode = out.answers.mode ?? mode; - } - // A session that came back without an answer to a question it was given has - // not answered it. Falling through on a default would write a key nobody - // asked for, into a variable nobody named. - if (!varName || !mode) return { varName, mode: mode ?? 'test', cancelled: true }; - const settledMode: StripeMode = mode; - - // NO OVERWRITE GUARD, because there is no longer an overwrite. `connect` - // records the link and leaves the value alone, so the question this used to - // ask — "STRIPE_SECRET already has a value, replace it?" — has no subject. - // - // It is worth saying why it went rather than just that it did. The guard was - // the only thing standing between a user and a silently replaced credential, - // and in the configuration that matters most it could not describe what it - // was about to write: `describeIncomingKey` resolves a section only when - // `--account` was passed or exactly one account is paired, so with two - // paired accounts both `keyPrefix` and `fingerprint` arrived as empty - // strings. The screen showed the current value's fingerprint beside a blank. - // Approving that reads as approving nothing. Removing the write removes the - // hazard the guard was failing to describe. - - return { varName, mode: settledMode, cancelled: false }; -} - /** * What a sign-in that did not sign anyone in says, per reason. * - * A switch and not a ternary chain, so the compiler is what proves every reason - * has a wall — including `declined`, which must never reach here: it is a - * refusal, ends the run at 0, and draws nothing. A chain would have quietly - * handed it the last branch's message and called a decision a failure. - * - * ONE OBJECT PER REASON, read by both surfaces. The browser renders it as the - * wall and the terminal prints the same three lines, which is the only way the - * two can be claimed to be describing the same run. + * A switch and not a ternary chain, so the compiler is what proves every + * reason has a wall. One object per reason; the terminal prints its three lines. */ function stripeLoginWall( - reason: Exclude['reason'], 'declined'>, + reason: Extract['reason'], varName: string, ): Blocked { switch (reason) { @@ -1070,55 +650,64 @@ function stripeLoginWall( } } -/** The account picker, in a browser. Rows are config.toml's sections, verbatim. */ -async function pickAccountInBrowser( - ctx: ResolvedContext, +/** + * Signs in to Stripe and re-reads its config. A pairing that does not complete, + * or a config that still cannot serve `mode`, ends the run with a wall (exit 1). + */ +async function signInFor( opts: ConnectOpts, - sections: StripeConfigSection[], - settled: { varName: string; mode: StripeMode } & Partial, -): Promise { - const requested = resolveSection(sections, opts.account); - if (opts.account && !requested) { - console.error(`\n No Stripe account matching "${opts.account}" in config.toml.`); - process.exit(1); - } - if (requested) return requested; - - const { askConnectInBrowser } = await import('../../ui/connectScreens'); - const out = await askConnectInBrowser( - webParams(ctx, opts, [{ kind: 'account', accounts: toStripeAccounts(sections) }], settled), + mode: StripeMode, + varName: string, + reason: 'reauth' | 'none' | 'unusable', +): Promise { + // `stripe login` is a browser pairing the user completes by hand; the CLI + // does the polling — see `runStripeLogin` for why that is two calls and not + // one. + const why = + reason === 'reauth' + ? '--reauth: pairing with Stripe again' + : reason === 'none' + ? 'No Stripe CLI session found' + : `No usable ${mode}-mode key in the Stripe CLI config (missing or expired)`; + console.log( + isInteractive(opts.nonTty) + ? `${why}. Running ${B('stripe login')}...` + : ` ${why} — starting browser pairing.`, ); - if (out.cancelled || !out.answers.account) { - console.log(' Cancelled.'); - process.exit(0); + const outcome = await runStripeLogin({ projectName: opts.account, nonTty: opts.nonTty }); + + const sections = readStripeConfig(); + + // FOUR WALLS, NOT ONE. This used to print "stripe login completed but the + // config still holds no usable key" whatever happened, including for the + // common case where login did not complete at all — which sends the reader + // to look at config parsing when the cause is a pairing nobody approved. + const usable = usableFor(sections, mode).length > 0; + if (!outcome.ok || !usable) { + const wall: Blocked = outcome.ok + ? { + // The one case the old message was ever right about: the pairing + // DID complete, and the config that came back still cannot serve + // the mode this run needs. + code: 'PROVIDER_MODE_UNAVAILABLE', + title: `Signed in, but there is no usable ${mode}-mode key.`, + detail: `The pairing completed and Stripe wrote its config, and that config holds no ${mode}-mode key Capy can read — so there is nothing to record a link against.`, + remedy: 'stripe config --list', + } + : stripeLoginWall(outcome.reason, varName); + + console.error(`\n ${wall.title}`); + console.error(` ${wall.detail}`); + if (wall.remedy) console.error(` ${B(wall.remedy)}`); + console.error(''); + process.exit(1); } - return sections.find((s) => s.name === out.answers.account)!; + return sections; } - async function connect(ctx: ResolvedContext, opts: ConnectOpts): Promise { - const web = opts.web === true; - - let varName: string; - let mode: StripeMode; - if (web) { - const answered = await askConnectPhaseA(ctx, opts); - if (answered.cancelled) { - // Two endings, and they are not the same fact. A decline wrote nothing - // and that was the point (0); a wall wrote nothing because the run could - // not proceed (1). Both pages have already been delivered by the time - // this line runs — `askConnectInBrowser` returns after its wizard has - // closed, and `showConnectBlockedInBrowser` waits for the browser to - // hold the page — so exiting here can no longer outrun either. - console.log(answered.blocked ? ' Nothing to connect.' : 'Cancelled.'); - process.exit(answered.blocked ? 1 : 0); - } - varName = answered.varName; - mode = answered.mode; - } else { - varName = await pickVarName(ctx, opts); - mode = await pickMode(opts.live ?? false, opts.nonTty); - } + const varName = await pickVarName(ctx, opts); + const mode = await pickMode(opts.live ?? false, opts.nonTty); // The variable has to already exist, because `connect` no longer creates it. // Attaching a connector to a name `.env` does not hold would put a keep.lock @@ -1143,145 +732,16 @@ async function connect(ctx: ResolvedContext, opts: ConnectOpts): Promise = - reason === null ? { alreadySignedIn: true } : { signedIn: true }; - if (reason !== null) { - // `stripe login` is a browser pairing the user completes by hand. Under - // `--web` it is a screen like every other stop on this route; without one - // it is announced on the terminal. Either way the CLI does the polling — - // see `runStripeLogin` for why that is two calls and not one. - const why = - reason === 'reauth' - ? '--reauth: pairing with Stripe again' - : reason === 'none' - ? 'No Stripe CLI session found' - : `No usable ${mode}-mode key in the Stripe CLI config (missing or expired)`; - console.log( - isInteractive(opts.nonTty) - ? `${why}. Running ${B('stripe login')}...` - : ` ${why} — starting browser pairing.`, - ); - const outcome = await runStripeLogin({ - projectName: opts.account, - nonTty: opts.nonTty, - web, - ...(web - ? { - handoff: async (pairing, complete) => { - const { signInInBrowser } = await import('../../ui/connectScreens'); - return signInInBrowser( - webParams( - ctx, - opts, - [ - { - kind: 'auth', - // What the screen names as the command being handed off - // to. `stripe login` and not the two-step it expands - // into: the expansion is our implementation detail, and - // this line exists so the hand-off is never a mystery, - // not so it can be copied. - command: 'stripe login', - state: 'ready', - ...(pairing.verificationCode - ? { pairingCode: pairing.verificationCode } - : {}), - }, - ], - // `auth` is deliberately NOT folded in here, and it is the - // one screen on this route where it must not be. It carries - // `signedIn: true` — the fact the run is about to establish — - // and `connectPlan` resolves an answered stop to `done` - // before it looks at where the traveller is standing. Folding - // it forward would draw "Sign in ✓ paired" on the very page - // asking someone to go and do it. - { varName, mode }, - ), - async () => { - // The hand-off proper: the whole browser, because the person - // has to be able to see the address bar and pick the profile - // their Stripe session lives in. - const { openScreen } = await import('../../ui/openScreen'); - await openScreen(pairing.browserUrl, { kind: 'handoff' }); - return complete(); - }, - ); - }, - } - : {}), - }); - // A DECLINE IS NOT A FAILURE, so it gets no wall and no non-zero exit. The - // page that asked has already closed itself — `signInInBrowser` does not - // resolve until it has — so there is nothing left to serve and nothing to - // outrun by exiting here. Same shape as the decline in `askConnectPhaseA`. - if (!outcome.ok && outcome.reason === 'declined') { - console.log(' Cancelled.'); - process.exit(0); - } - - sections = readStripeConfig(); - - // FOUR WALLS, NOT ONE. This used to print "stripe login completed but the - // config still holds no usable key" whatever happened, including for the - // common case where login did not complete at all — which sends the reader - // to look at config parsing when the cause is a pairing nobody approved. - const usable = usableFor(sections, mode).length > 0; - if (!outcome.ok || !usable) { - const wall: Blocked = outcome.ok - ? { - // The one case the old message was ever right about: the pairing - // DID complete, and the config that came back still cannot serve - // the mode this run needs. - code: 'PROVIDER_MODE_UNAVAILABLE', - title: `Signed in, but there is no usable ${mode}-mode key.`, - detail: `The pairing completed and Stripe wrote its config, and that config holds no ${mode}-mode key Capy can read — so there is nothing to record a link against.`, - remedy: 'stripe config --list', - } - : stripeLoginWall(outcome.reason, varName); - - if (web) { - // SERVED BEFORE THE EXIT, not after it. `showConnectBlockedInBrowser` - // does not return until the browser is holding the page, and the page - // comes off a loopback server inside THIS process — so exiting first, - // which is what this site used to do, closed the server microseconds - // before it could answer and turned the failure into a window that - // simply stopped. Same hazard, and same order, as the live-gate - // decline in connectCommand.ts. - const { showConnectBlockedInBrowser } = await import('../../ui/connectScreens'); - await showConnectBlockedInBrowser({ - provider: 'stripe', - projectName: ctx.keep.project_name, - branch: ctx.branch, - step: 'auth', - // Same reason the sign-in screen drops `auth`: the run stopped ON - // this stop, so a rail claiming it was travelled is a rail - // contradicting the page it is drawn on. - stops: connectPlan({ ...planFor(ctx, opts, { varName, mode }), standing: 'auth' }), - blocked: wall, - open: !process.env.CAPY_WEB_NO_OPEN, - }); - } - console.error(`\n ${wall.title}`); - console.error(` ${wall.detail}`); - if (wall.remedy) console.error(` ${B(wall.remedy)}`); - console.error(''); - process.exit(1); - } - } - const section = web - ? await pickAccountInBrowser(ctx, opts, sections, { ...auth, varName, mode }) - : await pickAccount(sections, opts.account, opts.nonTty); + const sections = reason === null ? initialSections : await signInFor(opts, mode, varName, reason); + const section = await pickAccount(sections, opts.account, opts.nonTty); // NO NEAR-EXPIRY REFRESH OFFER. It used to live here, and it re-ran // `stripe login` — which rewrites the user's Stripe pairing and can make @@ -1366,12 +826,7 @@ async function rotate( // signing anyone in, so this used to spray the pairing URL and code onto the // channel the AI agent reads and then fail on a config nothing had written. // - // NO BROWSER SCREEN HERE YET. `rotate --web` gets the hand-off — the Stripe - // tab opens and the poll runs — but not a Capy page around it, because the - // rail this screen draws is `connect`'s and drawing a connect route through a - // rotation would be a worse lie than drawing nothing. `rotate-progress` - // already carries a `pairing` field for it; wiring that is its own change. - const loginOutcome = await runStripeLogin({ projectName: sectionName, nonTty, web: opts.web === true }); + const loginOutcome = await runStripeLogin({ projectName: sectionName, nonTty }); if (!loginOutcome.ok) { console.error(''); console.error( diff --git a/src/commands/decryptCommand.ts b/src/commands/decryptCommand.ts index 8d93388b..3f6bb6ea 100644 --- a/src/commands/decryptCommand.ts +++ b/src/commands/decryptCommand.ts @@ -15,39 +15,11 @@ import { saveRecoverySession, } from '../config/globalConfig'; import { ERROR_CODES } from '../types/index'; -import { formatRelativeTime } from '../ui/relativeTime'; const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; -/** - * How long the open recovery session has been open, humanised. - * - * `saveRecoverySession` writes `created_at` and `readRecoverySession`'s return - * type does not mention it, so it is read defensively: a session file written - * by an older CLI has no stamp, and a stop that cannot say when says nothing - * rather than "Invalid Date". - */ -function sessionAge(session: { org_id: string; master_key: string }): string { - const createdAt = (session as { created_at?: unknown }).created_at; - return typeof createdAt === 'string' ? formatRelativeTime(createdAt) : '—'; -} - -export interface DecryptOpts { - /** - * Ask for the phrase in a browser instead of on this terminal. - * - * Agent-only. A seed phrase typed at an inquirer password prompt is fine - * when a human is at the keyboard; headless, that prompt blocks on a piped - * stdin and then — at EOF — prints `Cancelled.` and exits 0, which an agent - * reads as a decrypt that succeeded and wrote nothing. - */ - web?: boolean; - /** false when --no-open was passed: print the URL, do not open a browser. */ - open?: boolean; -} - export class DecryptCommand { - async execute(opts: DecryptOpts = {}): Promise { + async execute(): Promise { try { const pm = new ProjectManager(); const fm = new FileManager(); @@ -113,13 +85,10 @@ export class DecryptCommand { // version against the encrypted .env (the oracle) and keep the one that // decrypts — this is how legacy (v1) and current (v2) orgs are told apart // without any stored version marker. - let masterKeyHex: string; - let decrypted: Record; - // The whole trial, as one function, because `--web` needs to run it per - // submitted phrase rather than once at the top of the command. Returns - // null when no known KDF version opened this .env — which is the same - // condition the terminal path calls a wrong seed phrase. + // The whole trial, as one function. Returns null when no known KDF + // version opened this .env — which is the same condition the terminal + // path calls a wrong seed phrase. const resolveFromPhrase = ( seedPhrase: string, ): { hex: string; decrypted: Record } | null => { @@ -139,104 +108,15 @@ export class DecryptCommand { // (PEM keys, certs) are quoted/`\n`-escaped and survive being re-read by // dotenv — a bare `KEY=value` line would truncate at the first newline. const outputFile = `.env.${branch}.decrypted`; - const writeDecrypted = (values: Record): number => { + const writeDecrypted = (values: Record): void => { const content = Object.entries(values) .map(([key, value]) => `${key}=${dotenvEscape(value)}`) .join('\n'); writeFileSync(join(process.cwd(), outputFile), content + '\n', 'utf-8'); fm.updateGitignore(['.env.*.decrypted']); - return Object.keys(values).length; }; - if (opts.web) { - // The phrase is asked for in the browser. It reaches this process once, - // in the body of one loopback POST, and is not held past the attempt - // that uses it — never printed, never logged, never in a payload. - const session = isRecoveryActive() ? readRecoverySession() : null; - if (isRecoveryActive() && !session) { - console.error('\n Recovery session is corrupt. Run `capy end-recover` and try again.\n'); - process.exit(1); - } - if (session && session.org_id !== orgId) { - console.error( - `\n Recovery session is for a different org.` + - `\n Run ${bold('capy end-recover')} first, then try again.\n` - ); - process.exit(1); - } - - const { decryptInBrowser, showDecryptResult } = await import('../ui/decryptScreen'); - const { isLocalOnly } = await import('../config/profileConfig'); - const params = { - projectName: keep.project_name, - branch, - outputFile, - session: session - ? { - // The session file holds an org id and no name, and inventing - // one would be the screen claiming knowledge the CLI does not - // have. The id is what identifies the org here. - orgName: session.org_id, - startedAt: sessionAge(session), - } - : undefined, - localOnly: isLocalOnly(), - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI and - // headless runs drive the loopback without hijacking a real browser. - open: opts.open !== false && !process.env.CAPY_WEB_NO_OPEN, - }; - - const outcome = await decryptInBrowser(params, async (input) => { - let values: Record; - if ('useSession' in input) { - try { - values = decryptWith(session!.master_key); - } catch (error: any) { - if (error?.code === ERROR_CODES.DECRYPT_KEY_MISMATCH) { - return { ok: false, reason: 'KEY_MISMATCH' }; - } - throw error; - } - } else { - if (!validateSeedPhrase(input.phrase)) return { ok: false, reason: 'INVALID' }; - const resolved = resolveFromPhrase(input.phrase); - if (!resolved) return { ok: false, reason: 'KEY_MISMATCH' }; - values = resolved.decrypted; - saveRecoverySession(resolved.hex, orgId); - console.log(' ✓ Recovery session started'); - } - - // An .env with no `capy:` values in it has nothing to decrypt. Not a - // failure: the terminal exits 0 here and writes no file. - if (Object.keys(values).length === 0) return { ok: true, count: 0, wrote: false }; - return { ok: true, count: writeDecrypted(values), wrote: true }; - }); - - if (outcome.action === 'cancelled') { - // Three ways to refuse and one outcome: nothing on disk changed. The - // sentence differs because "the browser never answered" is not a - // thing the person did, and used to arrive as a thrown error on a - // command that had done no harm. - const why = - outcome.reason === 'timeout' - ? 'The browser never answered.' - : outcome.reason === 'closed' - ? 'Browser closed.' - : 'Cancelled.'; - console.log(`\n ${why} Nothing was decrypted.\n`); - return; - } - if (!outcome.wrote) { - console.log('\n No encrypted secrets found in .env\n'); - } else { - console.log(` ✓ Decrypted ${outcome.count} secret(s) to ${bold(outputFile)}`); - console.log(`\n Run ${bold('capy end-recover')} when done to clean up.\n`); - } - await showDecryptResult(params, { count: outcome.count, wrote: outcome.wrote }); - return; - } - - if (isRecoveryActive()) { + const decryptFromSession = (): Record => { const session = readRecoverySession(); if (!session) { console.error('\n Recovery session is corrupt. Run `capy end-recover` and try again.\n'); @@ -249,14 +129,15 @@ export class DecryptCommand { ); process.exit(1); } - masterKeyHex = session.master_key; try { - decrypted = decryptWith(masterKeyHex); + return decryptWith(session.master_key); } catch (error: any) { - if (error?.code === ERROR_CODES.DECRYPT_KEY_MISMATCH) wrongSeedExit(); + if (error?.code === ERROR_CODES.DECRYPT_KEY_MISMATCH) return wrongSeedExit(); throw error; } - } else { + }; + + const decryptFromPrompt = async (): Promise> => { // Prompt for seed phrase const inquirer = (await import('inquirer')).default; const { seedPhrase } = await inquirer.prompt([{ @@ -274,11 +155,12 @@ export class DecryptCommand { const resolved = resolveFromPhrase(seedPhrase); if (!resolved) return wrongSeedExit(); - masterKeyHex = resolved.hex; - decrypted = resolved.decrypted; - saveRecoverySession(masterKeyHex, orgId); + saveRecoverySession(resolved.hex, orgId); console.log(' ✓ Recovery session started'); - } + return resolved.decrypted; + }; + + const decrypted = isRecoveryActive() ? decryptFromSession() : await decryptFromPrompt(); if (Object.keys(decrypted).length === 0) { console.log('\n No encrypted secrets found in .env\n'); diff --git a/src/commands/deployCommand.ts b/src/commands/deployCommand.ts index fd2f529c..f1067e6e 100644 --- a/src/commands/deployCommand.ts +++ b/src/commands/deployCommand.ts @@ -83,18 +83,8 @@ import { createDokployClient, } from '../deploy/dokployApi'; import { classify, isBuildTime } from '../deploy/classify'; -import type { WebDeployAdapterContext } from '../ui/deployScreens'; import { deployPlan, unansweredDeployStops, type DeployStopId } from '../core/deployPlan'; -import type { - DeployAdapterChoice, - DeployPlanConfirmStop, - DeployPlanTarget, - DeployPreflightCheck, - DeployRunResultData, - DeployRunStep, - DeploySetupStep, - DeployTargetRow, -} from '../ui/screens/contract'; +import type { DeployPlanConfirmStop } from '../ui/screens/contract'; import { CHECKBOX_INSTRUCTIONS, CHECKBOX_THEME, LIST_THEME } from '../ui/promptStyle'; import { keypressConfirm } from '../ui/keypressConfirm'; import { @@ -131,35 +121,15 @@ export interface DeployCliOptions { * (api.capy.sc) and co-decrypt fails for dev-only orgs. */ devMode?: boolean; - /** - * Ask this run's questions in a browser instead of at the TTY. - * - * Changes only where a question is RENDERED. The same plan decides which - * questions exist, the same answers reach the same code, and nothing about - * what is decrypted, committed, pushed or written to `.capy/deploy.json` - * moves. - */ - web?: boolean; /** * Describe the route instead of travelling it. * - * The SAME stop array the browser screens are served, so a headless caller + * The route as a stop array, so a headless caller * can see which stops argv already settled and which it would be asked * about. Printed before any network call and before anything is decrypted, * because a plan you had to deploy to obtain is not a plan. - * - * (argv wiring for this — and for `--web` — lives in src/index.ts, which the - * coordinator owns; both are threaded here and settable by any caller.) */ json?: boolean; - /** - * The platform answer from earlier in the run, when `capy deploy` reached - * here through the destination picker. It is a stop this run really - * travelled, so the rail continues rather than restarting. - */ - platformAnswer?: string; - /** The mode answer, when that question was really asked. */ - modeAnswer?: string; /** * Write and verify the target's configuration, but skip the platform * deploy/redeploy (CAP-679). The user, or the platform's own auto-deploy, @@ -168,16 +138,6 @@ export interface DeployCliOptions { noDeploy?: boolean; } -/** Whether a question is asked in a browser, and what the rail already holds. */ -interface WebContext { - web?: boolean; - platformAnswer?: string; - modeAnswer?: string; -} - -/** Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI / headless in. */ -const openBrowser = (): boolean => !process.env.CAPY_WEB_NO_OPEN; - /** * Dokploy only: resolves the org system store's API key ONCE for this whole * command, wiring the REAL `system/systemStore.ts#getConnectorSecret` — this @@ -934,223 +894,6 @@ async function promptVercelGitBranch( return (typed.gitBranch as string).trim(); } -/** - * The adapter list the picker offers, planned rows included. - * - * Announced-but-unbuilt adapters are offered and immediately refused — the - * terminal renders them disabled with the `capy export` pipeline that stands in - * until they land. Keeping them is right: "Fly.io is coming" is the answer a - * Fly user actually has. - */ -/** - * Adapters whose settings step has no browser screen yet. They are left out of - * the browser's adapter list, and a run that already knows it is one of them - * asks its questions in the terminal instead. - */ -const TERMINAL_ONLY_SETUP: ReadonlySet = new Set(['dokploy']); - -function adapterChoices(): DeployAdapterChoice[] { - return [ - ...ALL_ADAPTERS.filter((a) => !TERMINAL_ONLY_SETUP.has(a.id)).map((a) => ({ - id: a.id, - label: a.label, - detail: [a.description], - })), - ...listPlanned() - .filter((p) => !ALL_ADAPTERS.some((a) => a.id === p.id)) - .map((p) => ({ - id: p.id, - label: p.label, - detail: [p.description], - planned: true, - plannedCommand: p.fallbackHint, - })), - ]; -} - -/** - * Settings defaults for the browser, with the terminal's own precedence. - * - * Every line here is the `default:` of one inquirer prompt, lifted verbatim — - * saved value first, then what the adapter sniffed out of the directory, then - * the CLI's fallback. Two lists of defaults would be two answers to "what does - * this box start as", and the box is what a target gets saved with. - */ -export function settingsDefaults( - adapterId: string, - cwd: string, - existingOpts: Record, - detectedOpts: Record, -): Record { - switch (adapterId) { - case 'cf-worker': - return { - workerName: existingOpts.workerName ?? detectedOpts.workerName ?? '', - workerDir: existingOpts.workerDir ?? detectedOpts.workerDir ?? '.', - }; - case 'cf-pages': - return { - projectName: existingOpts.projectName ?? detectedOpts.projectName ?? '', - buildCwd: existingOpts.buildCwd ?? detectedOpts.buildCwd ?? '.', - buildCmd: existingOpts.buildCmd ?? detectedOpts.buildCmd ?? 'bun run build', - distDir: existingOpts.distDir ?? detectedOpts.distDir ?? 'dist', - }; - case 'vercel': - return { - projectDir: existingOpts.projectDir ?? detectedOpts.projectDir ?? '.', - vercelEnv: existingOpts.vercelEnv ?? 'preview', - // The terminal asks this in a separate prompt one question later, with - // the saved value as its `preferred`. Here it is the same step, which - // is the point: a *git* branch asked one prompt after a *capy* branch, - // in almost the same words, is the failure this screen exists to stop. - gitBranch: existingOpts.gitBranch ?? '', - }; - case 'aws-ssm': - return { - region: - existingOpts.region ?? detectedOpts.region ?? detectAwsRegion() ?? 'us-east-1', - pathPrefix: - existingOpts.pathPrefix ?? - detectedOpts.pathPrefix ?? - `/capy/${basename(cwd).toLowerCase().replace(/[^a-z0-9-]/g, '-')}/`, - naming: existingOpts.naming ?? detectedOpts.naming ?? 'verbatim', - }; - case 'dokploy': - // tokenEnv is no longer asked by default (CAP-664: the org system - // store is the default source) — only an EXISTING target's own value - // is shown here, never defaulted to the fallback env var name. - // - // CAP-679 follow-up (item 6, validator fix-first): the terminal picker - // now asks Compose vs. Application up front (`resolveAdapterOptions`'s - // dokploy branch) and defaults `kind`/`composeId` from an existing - // target. This is the web surface's copy of that same default — the - // web flow doesn't have its own Dokploy settings screen (it isn't - // being built here), but keeping these two defaults in sync means - // neither surface silently disagrees about what a re-edit starts from - // if/when one is added. - return { - baseUrl: existingOpts.baseUrl ?? '', - applicationId: existingOpts.applicationId ?? '', - composeId: existingOpts.composeId ?? '', - kind: existingOpts.applicationId && !existingOpts.composeId ? 'application' : 'compose', - ...(existingOpts.tokenEnv ? { tokenEnv: existingOpts.tokenEnv } : {}), - }; - default: - return {}; - } -} - -/** - * Which variables are pre-ticked, and what chose them. - * - * The same two lines the checkbox prompt computes. `presetLabel` is the - * parenthetical in its message, which is the only thing on that prompt saying - * why anything is ticked at all — and on a build-time adapter what is ticked - * decides what ends up in a bundle the browser downloads. - */ -function presumedVars( - adapter: DeployAdapter, - branchVars: string[], - existing?: TargetConfig, -): { defaultPicks: string[]; presetLabel: string } { - const cls = classify(branchVars); - const presumedRelevant = adapter.presumeVars - ? adapter.presumeVars(cls) - : adapter.varKind === 'build-time' - ? cls.buildTime - : cls.runtime; - return { - defaultPicks: existing?.vars ?? presumedRelevant, - presetLabel: adapter.presumeVars - ? 'all vars' - : adapter.varKind === 'build-time' - ? 'VITE_/NEXT_PUBLIC_/PUBLIC_/REACT_APP_' - : 'non-public-prefixed', - }; -} - -/** - * The PR base the CI question defaults to: the branch you are on, then the - * target's saved value, then main/master. The terminal's own fallback chain. - */ -function defaultPrBase(cwd: string, existing?: TargetConfig): string { - const local = listLocalBranches(cwd); - return ( - currentBranch(cwd) ?? - existing?.gitBaseBranch ?? - (local.includes('main') ? 'main' : local.includes('master') ? 'master' : 'main') - ); -} - -/** - * Everything downstream of the adapter, resolved once it is known. - * - * Async because `adapter.detect(cwd)` is, and because the adapter can be - * chosen IN the browser — so this runs in the wizard's reducer, exactly where - * the terminal runs it: one line after the picker. - */ -function adapterContextFor( - cwd: string, - branchVars: string[], - existing?: TargetConfig, -): (id: string) => Promise { - return async (id: string) => { - const adapter = getAdapter(id); - if (!adapter) throw new Error(`Unknown adapter: ${id}`); - const detected = await adapter.detect(cwd); - const detectedOpts = (detected.options ?? {}) as Record; - const existingOpts = (existing?.options ?? {}) as Record; - const { defaultPicks, presetLabel } = presumedVars(adapter, branchVars, existing); - - // Drift, so the checkbox can mark what appeared and what vanished rather - // than presenting a stale list as if it were current. - const known = existing?.knownVars ?? branchVars; - const { added, removed } = existing - ? reconcileVars(existing.vars, known, branchVars) - : { added: [] as string[], removed: [] as string[] }; - - return { - id, - label: adapter.label, - detail: [adapter.description], - detected: detected.summary, - defaults: settingsDefaults(id, cwd, existingOpts, detectedOpts), - vars: [ - ...branchVars.map((name) => ({ - name, - buildTime: isBuildTime(name), - checked: defaultPicks.includes(name), - addedSinceSave: added.includes(name), - })), - // A variable the project no longer has cannot ship, but dropping it - // from the list is how a removed secret goes unnoticed. - ...removed.map((name) => ({ - name, - buildTime: isBuildTime(name), - checked: false, - goneSinceSave: true, - })), - ], - presetLabel, - delivery: { - mode: existing?.mode ?? adapter.defaultMode, - prBase: defaultPrBase(cwd, existing), - ciOnly: adapter.ciOnly, - }, - gitBranches: listAllBranches(cwd), - // The terminal presents a guessed region and a real one identically, so - // a wrong region is only found once the parameters land in it. - regionDetected: Boolean(existingOpts.region ?? detectedOpts.region ?? detectAwsRegion()), - exampleVar: classify(branchVars).runtime[0] ?? 'DATABASE_URL', - }; - }; -} - -/** How this picker run was reached, for the browser's copy and its rail. */ -interface WebPickerOptions extends WebContext { - intent?: 'create' | 'edit' | 'reconfirm'; -} - /** * Which adapter this run targets. An existing target's kind wins (the picker * is re-editing it), then a caller-preselected id, and only then does the @@ -1712,7 +1455,6 @@ async function runPicker( existing?: TargetConfig, /** When set, skip adapter-selection (caller has already picked). */ preselectedAdapterId?: string, - web?: WebPickerOptions, ): Promise { // Scope the picker to the ACTIVE branch's vars. keep.lock's `variables` is the // union across EVERY branch, so it would offer vars that only exist on @@ -1727,65 +1469,6 @@ async function runPicker( const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); const branchVars = keep.variables.filter((v) => branchVarSet.has(v)); - // Dokploy has no browser settings screen yet (browser screens are built in - // the Keep workbench first), so its setup stays in the terminal. - const knownAdapterId = existing?.kind ?? preselectedAdapterId; - const browserSetup = !!web?.web && !(knownAdapterId && TERMINAL_ONLY_SETUP.has(knownAdapterId)); - if (web?.web && !browserSetup) { - console.log(` ${DIM('Dokploy setup runs in the terminal.')}`); - } - - if (web?.web && browserSetup) { - // Same computation, same defaults, same validators — only the surface the - // questions are drawn on differs. The route is declared before the first - // page opens, including the adapter stop a preselected run never visits. - if (branchVars.length === 0) { - throw new Error( - `no variables on the active branch — run \`capy\` to sync, or switch branches.`, - ); - } - const adapterId = existing?.kind ?? preselectedAdapterId; - const intent = web.intent ?? (existing ? 'edit' : 'create'); - const steps: DeploySetupStep[] = []; - if (!adapterId) steps.push('adapter'); - steps.push('branch', 'settings'); - // A re-confirm is the same variable question with the drift spelled out. - steps.push(intent === 'reconfirm' ? 'drift' : 'variables'); - steps.push('delivery', 'name'); - - const { setUpDeployTargetInBrowser } = await import('../ui/deployScreens'); - const picked = await setUpDeployTargetInBrowser({ - intent, - steps, - platformAnswer: web.platformAnswer, - modeAnswer: web.modeAnswer, - adapters: adapterId ? undefined : adapterChoices(), - adapterId, - capyBranches: keep.branches, - branch: - existing?.branch ?? - (keep.branches.includes('production') ? 'production' : keep.branches[0] ?? 'development'), - existingNames: listTargets(cwd).map((t) => t.name), - existingName: existing?.name, - resolveAdapter: adapterContextFor(cwd, branchVars, existing), - open: openBrowser(), - }); - // Cancelling is a refusal: nothing is saved and nothing is deployed. The - // caller stops rather than falling through with a half-built target. - if (picked.cancelled) return null; - - return { - name: picked.name.trim(), - kind: picked.adapterId, - branch: picked.branch, - vars: picked.vars, - knownVars: branchVars, - options: picked.options, - mode: picked.mode, - gitBaseBranch: picked.gitBaseBranch, - }; - } - // 1. Pick adapter (when not pre-selected). Real adapters are selectable; // planned-but-not-shipped ones appear disabled with a fallback hint, so // the picker doubles as a roadmap and points users at `capy export` until @@ -1942,110 +1625,9 @@ function renderResult(result: DeployResult): void { // ── Subcommand: list / remove ────────────────────────────────────────────── -/** - * Saved targets as rows the browser can draw. - * - * Two fields the terminal listing omits are first-class here, and both decide - * what a deploy actually does: `mode` (whether Capy ships live or opens a PR) - * and `prBase` (what that PR opens against). A target saved before `mode` - * existed resolves to `direct`, so the printed listing can show a row whose - * next `capy deploy` performs a live vendor deploy with nothing on screen - * having said so. - * - * Variable NAMES only — `.capy/deploy.json` holds no value, which is why it is - * committed to the repository. - */ -function targetRows(cwd: string, targets: TargetConfig[]): DeployTargetRow[] { - const keep = readKeep(cwd); - const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); - const currentVars = (keep?.variables ?? []).filter((v) => branchVarSet.has(v)); - return targets.map((t) => { - const adapter = getAdapter(t.kind); - const known = t.knownVars ?? currentVars; - const { added, removed, drifted } = reconcileVars(t.vars, known, currentVars); - return { - name: t.name, - kind: t.kind, - // Absent when the id is no longer in the registry: the terminal prints - // the raw id and says nothing else, so the row reads like any other - // right up until a deploy dies on `Unknown adapter`. - adapterLabel: adapter?.label, - branch: t.branch, - mode: t.mode, - prBase: t.gitBaseBranch, - options: Object.entries(t.options).map(([key, value]) => ({ - key, - value: String(value), - })), - vars: t.vars, - drift: drifted ? { added, removed } : undefined, - }; - }); -} - -export async function deployList( - cwd: string = process.cwd(), - opts: { web?: boolean } = {}, -): Promise { +export async function deployList(cwd: string = process.cwd()): Promise { const targets = listTargets(cwd); - if (opts.web) { - // The printed listing stops. Here it answers: the rows carry mode, PR base - // and the drift the CLI otherwise only computes mid-deploy, and editing or - // removing one is the same act as `capy deploy --edit` and - // `capy deploy targets-remove ` — both named on the page. - const keep = readKeep(cwd); - const { chooseDeployTargetInBrowser } = await import('../ui/deployScreens'); - let picked: { action: string | null; target: string }; - try { - picked = await chooseDeployTargetInBrowser({ - projectName: basename(cwd), - configPath: deployConfigPath(cwd), - purpose: 'browse', - targets: targetRows(cwd, targets), - allow: ['edit', 'remove', 'new'], - open: openBrowser(), - }); - } catch (err) { - // The screen resolves every refusal — closed window, deadline, Ctrl-C — - // so anything that reaches here is the SERVER, not the user. A listing - // that never opened must not exit 0 with nothing on screen. - console.error(`${RED('✗')} could not open the targets page: ${err instanceof Error ? err.message : err}`); - return 1; - } - - // Nobody chose a row. A listing that ends having changed nothing is an - // ending, and it says so rather than returning silently. - if (picked.action === null) { - console.log('No changes.'); - return 0; - } - - if (picked.action === 'remove') return deployRemove(picked.target, cwd); - if (picked.action === 'edit' || picked.action === 'new') { - if (!keep) { - console.error( - `No keep.lock in ${basename(cwd)}. Run ${B('capy')} here first to sync.`, - ); - return 1; - } - const edited = await runPicker( - cwd, - keep, - picked.action === 'edit' ? getTarget(cwd, picked.target) ?? undefined : undefined, - undefined, - { web: true, intent: picked.action === 'edit' ? 'edit' : 'create' }, - ); - if (!edited) { - console.log('No changes.'); - return 0; - } - upsertTarget(cwd, edited); - console.log(GREEN(`✓ Saved target "${edited.name}" to .capy/deploy.json`)); - } - return 0; - } - if (targets.length === 0) { console.log(`No targets configured. Run ${B('capy deploy')} to set one up.`); return 0; @@ -2067,42 +1649,8 @@ export async function deployList( export async function deployRemove( name: string, cwd: string = process.cwd(), - opts: { web?: boolean; devMode?: boolean; noDeploy?: boolean } = {}, + opts: { devMode?: boolean; noDeploy?: boolean } = {}, ): Promise { - if (opts.web) { - // The terminal removes on a bare argument with no question at all. The - // settings behind that name took seven prompts to produce and - // `.capy/deploy.json` keeps no history, so the browser wants it typed back. - const targets = listTargets(cwd); - if (!targets.some((t) => t.name === name)) { - console.error(`No target named "${name}".`); - return 1; - } - const { chooseDeployTargetInBrowser } = await import('../ui/deployScreens'); - const picked = await chooseDeployTargetInBrowser({ - projectName: basename(cwd), - configPath: deployConfigPath(cwd), - purpose: 'browse', - targets: targetRows(cwd, targets), - view: 'confirm-remove', - subjectTarget: name, - allow: ['remove'], - open: openBrowser(), - }).catch((err: unknown) => { - // An unanswered delete is a refusal and the screen resolves it as one — - // clicking "Keep it" ends the run at once, and a window nobody came back - // to ends on the screen's deadline. So a throw here is the SERVER, which - // is a different fact and must not read as a decline. - console.error(`${RED('✗')} could not open the confirm page: ${err instanceof Error ? err.message : err}`); - return null; - }); - if (picked === null) return 1; - if (picked.action !== 'remove') { - console.log(`Kept target ${B(name)}.`); - return 0; - } - } - // Best-effort cleanup for adapters that left something outside // `.capy/deploy.json` (Dokploy's Capy-managed env block). Never gates the // local removal below — it is an offer, not a precondition. @@ -2128,12 +1676,7 @@ export async function deployRemove( // itself via `resolveOrgContext` — passing `undefined` here still // reaches the store, it just skips the keep.lock-org shortcut. const orgId = readKeep(cwd)?.orgId; - // A SEPARATE interactive flag from the removal `confirm` above: `--web` - // suppresses the store's own terminal prompt (it isn't a browser - // screen) even though the removal confirm itself already went through - // the browser earlier in this function. - const secretsInteractive = dokploySecretsMayPrompt(interactive, !!opts.web); - const resolvedApiKey = await resolveDokployApiKeyOnce(adapter!, target, orgId, opts.devMode, secretsInteractive); + const resolvedApiKey = await resolveDokployApiKeyOnce(adapter!, target, orgId, opts.devMode, interactive); return onRemove(target, { cwd, interactive, @@ -2270,10 +1813,7 @@ export async function deployRemove( * cancels). Requires a TTY for the picker; callers in non-interactive contexts * should pre-resolve via a target name instead. */ -export async function ensureDeployTarget( - cwd: string = process.cwd(), - web: WebContext = {}, -): Promise { +export async function ensureDeployTarget(cwd: string = process.cwd()): Promise { const existing = listTargets(cwd); if (existing.length === 1) return existing[0]; @@ -2286,7 +1826,7 @@ export async function ensureDeployTarget( } const setUpNew = async (): Promise => { - const target = await runPicker(cwd, keep, undefined, undefined, web.web ? web : undefined); + const target = await runPicker(cwd, keep); if (!target) return null; upsertTarget(cwd, target); console.log(GREEN(`✓ Saved target "${target.name}" to .capy/deploy.json`)); @@ -2296,13 +1836,6 @@ export async function ensureDeployTarget( if (existing.length === 0) return setUpNew(); // Multiple saved targets — pick one (or set up a new one). - if (web.web) { - const picked = await pickTargetInBrowser(cwd, existing); - if (picked.action === 'new') return setUpNew(); - if (picked.action !== 'use') return null; - return existing.find((t) => t.name === picked.target) ?? null; - } - const ans = await inquirer.prompt([ { type: 'list', @@ -2323,267 +1856,14 @@ export async function ensureDeployTarget( return existing.find((t) => t.name === ans.name) ?? null; } -/** - * The listing, as the answer to "which target?". - * - * Three prompts in this command ask that question with three different - * sentences — `Which target?`, `Which deploy target?`, `Use saved target?` — - * and they are one question about one list. `filterKind` carries the situation - * (a run narrowed by `--target `) and the screen supplies one wording for - * all of them. - * - * `null` is the refusal, and it is the ONLY thing an unanswered page produces: - * the pick view has no Cancel — its two buttons are `Use this target` and `Set - * up a new target` — so closing the window is the whole vocabulary a user has - * for "not this". `chooseDeployTargetInBrowser` resolves that rather than - * rejecting, so it arrives here as an answer and every caller already treats it - * as one ("Cancelled.", exit 0). A throw from this call is a broken server, and - * still a throw. - */ -async function pickTargetInBrowser( - cwd: string, - targets: TargetConfig[], - filterKind?: string, -): Promise<{ action: 'use' | 'new' | null; target: string }> { - const { chooseDeployTargetInBrowser } = await import('../ui/deployScreens'); - const picked = await chooseDeployTargetInBrowser({ - projectName: basename(cwd), - configPath: deployConfigPath(cwd), - purpose: 'pick', - filterKind, - filterKindLabel: filterKind ? (getAdapter(filterKind)?.label ?? undefined) : undefined, - targets: targetRows(cwd, targets), - allow: ['use', 'new'], - open: openBrowser(), - }); - return { - action: picked.action === 'use' || picked.action === 'new' ? picked.action : null, - target: picked.target, - }; -} - -/** - * The Vercel Preview git branch, asked on the settings step and nowhere else. - * - * A target saved before `options.gitBranch` existed scoped its Preview - * environment to the CAPY branch name, which fails at `vercel env add` with - * "Branch not found in the connected Git repository" whenever the two names do - * not coincide. The terminal heals it with a three-prompt free-text-or-list - * question; here it is one step, on the page that states in as many words that - * this is a git branch rather than a capy branch. - * - * The whole Vercel settings block comes back rather than `gitBranch` alone: - * the step also asks which environment this target is, and switching it to - * Production is a legitimate answer to a target whose Preview branch is - * missing. Taking only the branch would discard that silently and leave the - * run stuck on the same question next time. - */ -async function promptVercelGitBranchInBrowser( - cwd: string, - keep: KeepInfo, - target: TargetConfig, -): Promise | null> { - const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); - const branchVars = keep.variables.filter((v) => branchVarSet.has(v)); - const { setUpDeployTargetInBrowser } = await import('../ui/deployScreens'); - const answered = await setUpDeployTargetInBrowser({ - intent: 'edit', - steps: ['settings'], - adapterId: target.kind, - capyBranches: keep.branches, - branch: target.branch, - existingNames: listTargets(cwd).map((t) => t.name), - existingName: target.name, - resolveAdapter: adapterContextFor(cwd, branchVars, target), - open: openBrowser(), - }); - return answered.cancelled ? null : answered.options; -} - -/** - * The plan gate, on a page. - * - * The terminal prints a seven-line block and reads ONE raw keypress. Three - * things move here and nothing else does: every preflight check keeps a row - * rather than only the first failure being printed, `delete` asks a second - * question before removing a target that took seven prompts to build, and the - * three endings a gate can have are never rendered as each other. - * - * `edit` is an ANSWER the caller acts on — it re-enters the picker and comes - * back here — not a way out of the browser. - */ -async function confirmDeployOnScreen( - cwd: string, - target: TargetConfig, - adapter: DeployAdapter, - mode: DeployMode, - options: DeployCliOptions, - web: WebContext, - preflight: { ok: boolean; reason?: string; hint?: string }, - changeGate?: { baseBranch: string; changed: boolean }, -): Promise<{ action: 'confirm' | 'edit' | 'delete' | 'cancel'; force: boolean }> { - const saved = getTarget(cwd, target.name) !== null; - const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); - const keep = readKeep(cwd); - const currentVars = (keep?.variables ?? []).filter((v) => branchVarSet.has(v)); - const { added, removed, drifted } = reconcileVars( - target.vars, - target.knownVars ?? currentVars, - currentVars, - ); - - const plan: DeployPlanTarget = { - name: target.name, - adapterId: adapter.id, - adapterLabel: adapter.label, - branch: target.branch, - mode, - prBase: mode === 'ci' ? target.gitBaseBranch : undefined, - options: Object.entries(target.options).map(([key, value]) => ({ - key, - value: String(value), - })), - vars: target.vars, - saved, - }; - - // One row, because one check is what the adapter reports: `preflight()` - // returns the FIRST problem it hits and stops. Inventing per-check rows the - // adapter never ran would be the browser claiming knowledge the CLI has not - // got. - const checks: DeployPreflightCheck[] = [ - { - id: 'preflight', - label: `${adapter.label} preflight`, - state: preflight.ok ? 'ok' : 'fail', - detail: preflight.reason, - fix: preflight.hint, - }, - ]; - - const { confirmDeployInBrowser } = await import('../ui/deployScreens'); - const out = await confirmDeployInBrowser({ - target: plan, - action: mode, - dryRun: !!options.dryRun, - preflight: checks, - // Preflight is the only place Capy learns whether the vendor session the - // user established by hand actually exists. - signedIn: preflight.ok, - drift: drifted ? { added, removed } : undefined, - changeGate, - modeAnswer: web.modeAnswer, - // Past the change gate the plan was already approved and the secrets are - // already decrypted: there is no picker left to re-enter and no target - // that could be deleted without stranding the run half-done. - allow: changeGate ? ['deploy'] : ['deploy', 'edit', 'delete'], - open: openBrowser(), - }); - - if (out.cancelled || out.decision === null) return { action: 'cancel', force: false }; - if (out.decision === 'edit') return { action: 'edit', force: false }; - if (out.decision === 'delete') return { action: 'delete', force: false }; - return { action: 'confirm', force: out.force }; -} - -/** - * The step log, as a page. - * - * `renderResult` prints a glyph, a label and a dim detail per step and then - * returns an exit code, and the exit code is where it goes wrong: a CI run - * that pushed secrets and opened a pull request has not deployed, and a run - * whose change gate found nothing has not even queued one. Both print ticks - * and exit 0. The outcome is computed here, from what actually happened. - */ -async function showRunResult( - cwd: string, - target: TargetConfig, - adapter: DeployAdapter, - mode: DeployMode, - options: DeployCliOptions, - result: DeployResult, - extra: { - stashed: boolean; - keepLockChanged: boolean; - baseBranch: string; - pr?: { branch: string; base: string; url?: string; title?: string; manualUrl?: string }; - }, -): Promise { - const steps: DeployRunStep[] = result.steps.map((s, i) => ({ - id: `${i}`, - label: stripAnsiText(s.label), - status: s.status, - detail: s.detail === undefined ? undefined : stripAnsiText(s.detail), - url: s.url, - })); - - const outcome: DeployRunResultData['outcome'] = options.dryRun - ? 'dry-run' - : !result.ok - ? 'failed' - : mode !== 'ci' - ? 'deployed' - : extra.keepLockChanged - ? 'opened-pr' - : 'nothing-to-deploy'; - - // The epilogue's first line is its own heading in the terminal, so it stays - // the heading here rather than the browser inventing one. - let epilogue: DeployRunResultData['epilogue']; - if (result.epilogue) { - const lines = stripAnsiText(result.epilogue).split('\n'); - const first = lines.findIndex((l) => l.trim() !== ''); - epilogue = { - title: (lines[first] ?? '').trim(), - snippet: lines.slice(first + 1).join('\n').replace(/^\n+/, ''), - }; - } - - const { showDeployRunResultInBrowser } = await import('../ui/deployScreens'); - await showDeployRunResultInBrowser( - { - outcome, - projectName: basename(cwd), - target: { - name: target.name, - adapterLabel: adapter.label, - branch: target.branch, - mode, - prBase: mode === 'ci' ? target.gitBaseBranch : undefined, - adhoc: getTarget(cwd, target.name) === null, - }, - steps, - epilogue, - git: mode === 'direct' ? { stashed: extra.stashed } : undefined, - pr: extra.pr, - stall: - outcome === 'nothing-to-deploy' - ? { - code: 'no-secret-changes', - title: 'Nothing will deploy', - detail: `The keep.lock this deploy would commit is identical to the one already on origin/${extra.baseBranch}, so no pull request was opened and nothing will rebuild.`, - remedy: `capy deploy ${target.name} --force`, - } - : undefined, - nonTty: { - command: `capy deploy ${target.name} --yes`, - why: '--yes is not optional off a TTY: without it the confirm resolves to cancel and the run exits 0 having deployed nothing.', - }, - }, - { open: openBrowser() }, - ); -} - /** The terminal's own colours, off anything on its way into a payload. */ const stripAnsiText = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); /** * The route this invocation would travel, and what is still unanswered on it. * - * One builder, one array: `deployPlan` is what the three browser screens draw - * and this is what `--json` emits, so the rail a person reads and the array an - * agent parses cannot describe different routes. `unanswered` is DERIVED from - * the same array rather than recomputed, for the same reason. + * One builder, one array: this is what `--json` emits. `unanswered` is DERIVED + * from the same array rather than recomputed, so the two cannot disagree. * * Only argv and `.capy/deploy.json` settle a stop here. Nothing is guessed: a * stop no flag answered comes back in `unanswered`, which is precisely the @@ -2609,8 +1889,6 @@ export function describeDeployRoute( const targetAdapter = !saved && options.target ? getAdapter(options.target) : null; const answers: Partial> = { - ...(options.platformAnswer ? { platform: stripAnsiText(options.platformAnswer) } : {}), - ...(options.modeAnswer ? { mode: stripAnsiText(options.modeAnswer) } : {}), ...(saved ? { platform: stripAnsiText(savedAdapter?.label ?? saved.kind), @@ -2625,7 +1903,7 @@ export function describeDeployRoute( : {}), }; const skipped: DeployStopId[] = [ - ...(options.modeAnswer ? [] : (['mode'] as DeployStopId[])), + 'mode' as DeployStopId, ...(!saved && options.target ? (['name'] as DeployStopId[]) : []), ]; @@ -2651,34 +1929,15 @@ export function describeDeployRoute( /** * Whether to touch `keep.lock`'s `changed_at` and re-trigger CI when the * decrypted secrets did not actually change vs. `baseBranch` — `--force`, - * or (only absent that) a confirm, on the terminal or the browser page, - * whichever is asking this run's questions. Declining (or nobody able to - * ask — no TTY, no `--web`) leaves it false, the CLI's own default. + * or (only absent that) a confirm on the terminal. Declining (or nobody able + * to ask — no TTY) leaves it false, the CLI's own default. */ async function resolveForceRedeploy( options: DeployCliOptions, - web: WebContext, - cwd: string, - target: TargetConfig, - adapter: DeployAdapter, - mode: DeployMode, - preflight: PreflightResult, baseBranch: string, ): Promise { if (options.force) return true; if (options.yes) return false; - if (web.web) { - // Its own gate, because the change gate can only be evaluated after the - // secrets are decrypted — the terminal asks it here for the same reason. - // Declining is the CLI's own default of `false`, and the page says out - // loud what the terminal leaves implicit: without a forced redeploy this - // run pushes the secrets, opens no pull request, and nothing deploys. - const gate = await confirmDeployOnScreen(cwd, target, adapter, mode, options, web, preflight, { - baseBranch, - changed: false, - }); - return gate.action === 'confirm' && gate.force; - } if (process.stdin.isTTY) { const ans = await inquirer.prompt([ { @@ -2734,10 +1993,7 @@ async function resolveCiGateOutcome( baseBranch: string, target: TargetConfig, adapter: DeployAdapter, - mode: DeployMode, options: DeployCliOptions, - web: WebContext, - preflight: PreflightResult, ): Promise { const fetched = fetchRemoteBranch(cwd, baseBranch); if (!fetched.ok) { @@ -2773,7 +2029,7 @@ async function resolveCiGateOutcome( // No secret change vs the target. --force (or a confirm) touches // keep.lock's changed_at so there's a real diff to PR + re-trigger CI. - const force = await resolveForceRedeploy(options, web, cwd, target, adapter, mode, preflight, baseBranch); + const force = await resolveForceRedeploy(options, baseBranch); if (force) return { kind: 'proceed', baseKeep }; console.log( @@ -2816,9 +2072,6 @@ function buildFinalCiKeep( : { ok: true, keepLockChanged: true, deployKeepContent: touchDeployKeep(baseKeep, target.vars, target.branch) }; } -/** Everything `deployRemove` needs after `showRunResult`'s `pr` field. */ -type OpenedPr = { branch: string; base: string; url?: string; title?: string; manualUrl?: string }; - /** * Commit the deploy-PR keep.lock in the isolated worktree, push it, and open * the PR — the part of the CI-mode flow that happens INSIDE the worktree @@ -2832,7 +2085,7 @@ async function commitAndOpenDeployPr( msg: string, target: TargetConfig, deployKeepContent: string, -): Promise<{ ok: true; openedPr?: OpenedPr; prUrl?: string } | { ok: false }> { +): Promise<{ ok: true; prUrl?: string } | { ok: false }> { try { const relKeep = repoRelPath(cwd, 'keep.lock'); writeFileSync(join(wt, relKeep), deployKeepContent); @@ -2861,11 +2114,11 @@ async function commitAndOpenDeployPr( const pr = createPr(wt, title, body, baseBranch); if (pr.ok) { console.log(` ${GREEN('✓')} PR ${pr.url ?? '(open)'}`); - return { ok: true, openedPr: { branch: branchName, base: baseBranch, url: pr.url, title }, prUrl: pr.url }; + return { ok: true, prUrl: pr.url }; } if (pr.manualHint) { console.log(` ${YELLOW('!')} ${pr.manualHint}`); - return { ok: true, openedPr: { branch: branchName, base: baseBranch, title } }; + return { ok: true }; } console.error(`${RED('✗')} gh pr create: ${pr.error}`); return { ok: false }; @@ -2889,7 +2142,7 @@ async function openCiDeployPr( msg: string, target: TargetConfig, deployKeepContent: string, -): Promise<{ ok: true; openedPr?: OpenedPr } | { ok: false }> { +): Promise<{ ok: true } | { ok: false }> { const now = new Date(); const ts = now.toISOString().slice(0, 10).replace(/-/g, '') + '-' + now.toISOString().slice(11, 19).replace(/:/g, ''); const rand = Math.random().toString(36).slice(2, 6); @@ -2910,7 +2163,315 @@ async function openCiDeployPr( if (committed.prUrl) console.log(` ${committed.prUrl}`); console.log(` ${DIM('branch')} ${branchName} ${DIM(`→ ${baseBranch}`)}`); console.log(''); - return { ok: true, openedPr: committed.openedPr }; + return { ok: true }; +} + +// ── Target resolution steps ──────────────────────────────────────────────── + +/** A step either yields a value or ends the run with an exit code. */ +type StepResult = { ok: true; value: T } | { ok: false; code: number }; + +const stepOk = (value: T): StepResult => ({ ok: true, value }); +const stepStop = (code: number): StepResult => ({ ok: false, code }); + +/** Runs the picker and saves what it built; cancelling ends the run with 0. */ +async function buildAndSaveTarget( + cwd: string, + keep: KeepInfo, + existing: TargetConfig | undefined, + preselectedAdapterId: string | undefined, + announceSaved: boolean, +): Promise> { + const built = await runPicker(cwd, keep, existing, preselectedAdapterId); + if (!built) { + console.log('Cancelled.'); + return stepStop(0); + } + upsertTarget(cwd, built); + if (announceSaved) console.log(GREEN(`✓ Saved target "${built.name}" to .capy/deploy.json`)); + return stepOk(built); +} + +/** `--target --yes`: a transient target built from auto-detected defaults. */ +async function adhocTarget(cwd: string, keep: KeepInfo, adapter: DeployAdapter): Promise { + const detected = await adapter.detect(cwd); + const cls = classify(keep.variables); + return { + name: `${adapter.id}-adhoc`, + kind: adapter.id, + branch: keep.branches.includes('production') ? 'production' : keep.branches[0] ?? 'development', + vars: adapter.presumeVars + ? adapter.presumeVars(cls) + : adapter.varKind === 'build-time' + ? cls.buildTime + : cls.runtime, + options: detected.options ?? {}, + ...(adapter.ciOnly ? { mode: 'ci' as const } : {}), + }; +} + +/** A saved target of this adapter's kind, or `'__new__'` to re-enter the picker. */ +async function chooseSavedTarget( + sameKind: readonly TargetConfig[], + adapter: DeployAdapter, +): Promise { + if (sameKind.length === 1) { + const ans = await inquirer.prompt([ + { + type: 'list', + name: 'pick', + message: `Use saved target?`, + theme: LIST_THEME, + choices: [ + { + name: `${sameKind[0].name} ${DIM(`(branch=${sameKind[0].branch}, mode=${sameKind[0].mode ?? 'direct'})`)}`, + value: '__use__', + }, + { name: '+ new target (re-enter picker)', value: '__new__' }, + ], + default: '__use__', + } as any, + ]); + return ans.pick === '__use__' ? sameKind[0] : '__new__'; + } + if (sameKind.length > 1) { + const ans = await inquirer.prompt([ + { + type: 'list', + name: 'pick', + message: `Use a saved ${adapter.label} target?`, + theme: LIST_THEME, + choices: [ + ...sameKind.map((t) => ({ + name: `${t.name} ${DIM(`(branch=${t.branch}, mode=${t.mode ?? 'direct'})`)}`, + value: t.name, + })), + new inquirer.Separator() as any, + { name: '+ new target (re-enter picker)', value: '__new__' }, + ], + } as any, + ]); + return ans.pick === '__new__' ? '__new__' : sameKind.find((t) => t.name === ans.pick)!; + } + return '__new__'; +} + +/** `--target `: ad-hoc with `--yes`, otherwise offer saved targets of that kind first. */ +async function resolveTargetById( + cwd: string, + keep: KeepInfo, + targetId: string, + options: DeployCliOptions, +): Promise> { + const adapter = getAdapter(targetId); + if (!adapter) { + console.error( + `Unknown adapter "${targetId}". Known: ${ALL_ADAPTERS.map((a) => a.id).join(', ')}`, + ); + return stepStop(1); + } + if (options.yes) return stepOk(await adhocTarget(cwd, keep, adapter)); + + // Interactive but adapter is pre-chosen — handoff path from the + // existing platform picker. If the user already saved targets for + // this adapter, offer them first so day-2 doesn't re-fill the whole + // picker. New target is always available as a "+ new" option. + const sameKind = listTargets(cwd).filter((t) => t.kind === adapter.id); + const chosen = await chooseSavedTarget(sameKind, adapter); + if (chosen !== '__new__') return stepOk(chosen); + return buildAndSaveTarget(cwd, keep, undefined, adapter.id, true); +} + +/** No name, no `--target`: picker on first use or `--edit`, the only saved target, or a list. */ +async function resolveTargetInteractively( + cwd: string, + keep: KeepInfo, + options: DeployCliOptions, +): Promise> { + const targets = listTargets(cwd); + if (targets.length === 0 || options.edit) { + return buildAndSaveTarget(cwd, keep, options.edit && targets.length === 1 ? targets[0] : undefined, undefined, true); + } + if (targets.length === 1) return stepOk(targets[0]); + + const ans = await inquirer.prompt([ + { + type: 'list', + name: 'name', + message: 'Which target?', + theme: LIST_THEME, + choices: [ + ...targets.map((t) => ({ + name: `${t.name} ${DIM(`(${t.kind}, branch=${t.branch})`)}`, + value: t.name, + })), + new inquirer.Separator() as any, + { name: '+ new target', value: '__new__' }, + ], + }, + ]); + if (ans.name === '__new__') return buildAndSaveTarget(cwd, keep, undefined, undefined, false); + return stepOk(targets.find((t) => t.name === ans.name)!); +} + +/** explicit name → load from config; `--target=id` → ad-hoc; else the picker. */ +async function resolveTarget( + cwd: string, + keep: KeepInfo, + nameArg: string | undefined, + options: DeployCliOptions, +): Promise> { + if (nameArg) { + const target = getTarget(cwd, nameArg); + if (!target) { + console.error(`No target named "${nameArg}". Run \`capy deploy list\`.`); + return stepStop(1); + } + return stepOk(target); + } + if (options.target) return resolveTargetById(cwd, keep, options.target, options); + return resolveTargetInteractively(cwd, keep, options); +} + +/** + * Heal Vercel Preview targets saved before options.gitBranch existed. The + * old fallback scoped the Preview env to the CAPY branch name, which fails + * at `vercel env add` with "Branch not found in the connected Git + * repository" whenever the names don't coincide. Ask once and persist. + */ +async function healVercelGitBranch( + cwd: string, + target: TargetConfig, + options: DeployCliOptions, +): Promise> { + const targetOpts = target.options as Record; + if (!(target.kind === 'vercel' && targetOpts.vercelEnv === 'preview' && !targetOpts.gitBranch)) { + return stepOk(target); + } + if (options.yes) { + console.error( + `${RED('✗')} target "${target.name}" is missing options.gitBranch ` + + `(the git branch its Vercel Preview env is wired to).`, + ); + console.error(`\nRun \`capy deploy --edit\` once interactively to set it.`); + return stepStop(1); + } + const gitBranch = await promptVercelGitBranch(cwd); + const healed: TargetConfig = { ...target, options: { ...target.options, gitBranch } }; + upsertTarget(cwd, healed); + console.log( + GREEN( + gitBranch + ? `✓ Saved gitBranch=${gitBranch} to target "${healed.name}"` + : `✓ Saved vercelEnv=${targetOpts.vercelEnv} to target "${healed.name}"`, + ), + ); + return stepOk(healed); +} + +/** + * Var-set reconcile: the saved selection can go stale when the project's + * variables change. Re-confirm rather than silently deploying a stale set — + * dropping a newly-added secret, or shipping a removed one. + */ +async function reconcileTargetVars( + cwd: string, + keep: KeepInfo, + target: TargetConfig, + options: DeployCliOptions, +): Promise> { + const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); + const currentVars = keep.variables.filter((v) => branchVarSet.has(v)); + // Legacy targets have no `knownVars` baseline; treat current as known so we + // don't false-flag intentionally-unselected vars as "newly added". + const known = target.knownVars ?? currentVars; + const { added, removed, drifted } = reconcileVars(target.vars, known, currentVars); + if (!drifted) return stepOk(target); + + if (added.length) + console.log(` ${YELLOW('!')} new project var(s) not in this target: ${B(added.join(', '))}`); + if (removed.length) + console.log(` ${YELLOW('!')} target var(s) no longer in the project: ${B(removed.join(', '))}`); + if (!options.yes && !options.dryRun && process.stdin.isTTY) { + // Only the variable list changed, so only the variables are asked about. + // Every other setting (adapter, branch, options, mode, name) is kept. + const includedNew = added.length ? await pickNewVars(target, added) : []; + const updated = applyVarDrift(target, currentVars, includedNew); + upsertTarget(cwd, updated); + console.log(GREEN(`✓ Updated target "${updated.name}" in .capy/deploy.json`)); + return stepOk(updated); + } + if (options.yes && added.length) { + console.error( + `${RED('✗')} the project gained variable(s) since this target was saved: ${added.join(', ')}.\n` + + ` Re-run \`capy deploy ${target.name}\` interactively to include or skip them — refusing to silently drop a secret.`, + ); + return stepStop(1); + } + if (options.yes && removed.length) { + // Non-interactive: a removed var can't be pushed; drop it and carry on. + return stepOk({ ...target, vars: target.vars.filter((v) => currentVars.includes(v)), knownVars: currentVars }); + } + return stepOk(target); +} + +/** + * Confirm-or-edit loop. Single-keypress picker (c/e/d/esc) so the user can fix + * a saved target inline instead of having to abort, run `capy deploy --edit`, + * then re-run. + */ +async function confirmOrEdit( + cwd: string, + keep: KeepInfo, + target: TargetConfig, + adapter: DeployAdapter, + mode: DeployMode, + adapterCallCtx: { orgId: string | undefined; devMode: boolean | undefined; interactive: boolean; resolvedApiKey: ResolveDokployApiKeyResult | undefined }, +): Promise> { + const summary = + mode === 'ci' + ? `Open a deploy PR (commit keep.lock + push secrets, no live deploy)?` + : `Deploy now (commit keep.lock + ship from HEAD; your WIP is stashed and restored)?`; + const action = await keypressConfirm({ message: summary }); + if (action === 'confirm') return stepOk(target); + if (action === 'cancel') { + console.log('Cancelled.'); + return stepStop(0); + } + if (action === 'delete') { + // Only saved targets can be deleted; ad-hoc transient ones aren't on + // disk. Either way, stop after delete — there's nothing left to do. + const removed = removeTarget(cwd, target.name); + if (removed) { + console.log(`Removed target ${B(target.name)}.`); + } else { + console.log(`(target was not saved — nothing to delete)`); + } + return stepStop(0); + } + if (action === 'edit') { + const edited = await runPicker(cwd, keep, target); + if (!edited) { + console.log('Cancelled.'); + return stepStop(0); + } + upsertTarget(cwd, edited); + console.log(GREEN(`✓ Saved target "${edited.name}" to .capy/deploy.json`)); + renderPlan(edited, adapter); + // Re-run preflight after edit — paths/options may have changed. + // Reuses the SAME resolvedApiKey from above: it was resolved once + // for this command, and an edit here changes vars/branch/mode, never + // the Dokploy token source. + const recheck = await adapter.preflight(edited, { cwd, ...adapterCallCtx }); + if (!recheck.ok) { + console.error(`${RED('✗')} preflight: ${recheck.reason}`); + if (recheck.hint) console.error('\n' + recheck.hint); + return stepStop(1); + } + // Loop back to the confirm prompt with the edited target. + return confirmOrEdit(cwd, keep, edited, adapter, mode, adapterCallCtx); + } + return confirmOrEdit(cwd, keep, target, adapter, mode, adapterCallCtx); } // ── Main: capy deploy [name] ─────────────────────────────────────────────── @@ -2936,191 +2497,14 @@ export async function deployCommand( return 1; } - // Where this run's questions are drawn, and what the rail already holds - // from the stops travelled before this command was entered. - const web: WebContext = { - web: options.web, - platformAnswer: options.platformAnswer, - modeAnswer: options.modeAnswer, - }; - // Resolve target: explicit name → load from config; --target=id → ad-hoc; // else picker (or confirm-last if a single target exists and no --edit). - let target: TargetConfig | null = null; - - if (nameArg) { - target = getTarget(cwd, nameArg); - if (!target) { - console.error(`No target named "${nameArg}". Run \`capy deploy list\`.`); - return 1; - } - } else if (options.target) { - const adapter = getAdapter(options.target); - if (!adapter) { - console.error( - `Unknown adapter "${options.target}". Known: ${ALL_ADAPTERS.map((a) => a.id).join(', ')}`, - ); - return 1; - } - if (options.yes) { - // Ad-hoc CI path: build a transient target from auto-detected defaults. - const detected = await adapter.detect(cwd); - const cls = classify(keep.variables); - target = { - name: `${adapter.id}-adhoc`, - kind: adapter.id, - branch: keep.branches.includes('production') ? 'production' : keep.branches[0] ?? 'development', - vars: adapter.presumeVars - ? adapter.presumeVars(cls) - : adapter.varKind === 'build-time' - ? cls.buildTime - : cls.runtime, - options: detected.options ?? {}, - ...(adapter.ciOnly ? { mode: 'ci' as const } : {}), - }; - } else { - // Interactive but adapter is pre-chosen — handoff path from the - // existing platform picker. If the user already saved targets for - // this adapter, offer them first so day-2 doesn't re-fill the whole - // picker. New target is always available as a "+ new" option. - const sameKind = listTargets(cwd).filter((t) => t.kind === adapter.id); - let chosen: TargetConfig | '__new__' | null = '__new__'; - if (web.web && sameKind.length > 0) { - const picked = await pickTargetInBrowser(cwd, sameKind, adapter.id); - chosen = - picked.action === 'new' - ? '__new__' - : picked.action === 'use' - ? sameKind.find((t) => t.name === picked.target) ?? null - : null; - } else if (sameKind.length === 1) { - const ans = await inquirer.prompt([ - { - type: 'list', - name: 'pick', - message: `Use saved target?`, - theme: LIST_THEME, - choices: [ - { - name: `${sameKind[0].name} ${DIM(`(branch=${sameKind[0].branch}, mode=${sameKind[0].mode ?? 'direct'})`)}`, - value: '__use__', - }, - { name: '+ new target (re-enter picker)', value: '__new__' }, - ], - default: '__use__', - } as any, - ]); - chosen = ans.pick === '__use__' ? sameKind[0] : '__new__'; - } else if (sameKind.length > 1) { - const ans = await inquirer.prompt([ - { - type: 'list', - name: 'pick', - message: `Use a saved ${adapter.label} target?`, - theme: LIST_THEME, - choices: [ - ...sameKind.map((t) => ({ - name: `${t.name} ${DIM(`(branch=${t.branch}, mode=${t.mode ?? 'direct'})`)}`, - value: t.name, - })), - new inquirer.Separator() as any, - { name: '+ new target (re-enter picker)', value: '__new__' }, - ], - } as any, - ]); - chosen = - ans.pick === '__new__' - ? '__new__' - : sameKind.find((t) => t.name === ans.pick)!; - } - if (chosen === null) { - console.log('Cancelled.'); - return 0; - } - if (chosen !== '__new__') { - target = chosen; - } else { - const built = await runPicker(cwd, keep, undefined, adapter.id, web.web ? web : undefined); - if (!built) { - console.log('Cancelled.'); - return 0; - } - target = built; - upsertTarget(cwd, target); - console.log(GREEN(`✓ Saved target "${target.name}" to .capy/deploy.json`)); - } - } - } else { - // No name, no --target. Interactive. - const targets = listTargets(cwd); - if (targets.length === 0 || options.edit) { - const built = await runPicker( - cwd, - keep, - options.edit && targets.length === 1 ? targets[0] : undefined, - undefined, - web.web ? web : undefined, - ); - if (!built) { - console.log('Cancelled.'); - return 0; - } - target = built; - upsertTarget(cwd, target); - console.log(GREEN(`✓ Saved target "${target.name}" to .capy/deploy.json`)); - } else if (targets.length === 1) { - target = targets[0]; - } else if (web.web) { - const picked = await pickTargetInBrowser(cwd, targets); - if (picked.action === 'new') { - const built = await runPicker(cwd, keep, undefined, undefined, web); - if (!built) { - console.log('Cancelled.'); - return 0; - } - target = built; - upsertTarget(cwd, target); - } else if (picked.action === 'use') { - target = targets.find((t) => t.name === picked.target) ?? null; - } - if (!target) { - console.log('Cancelled.'); - return 0; - } - } else { - const ans = await inquirer.prompt([ - { - type: 'list', - name: 'name', - message: 'Which target?', - theme: LIST_THEME, - choices: [ - ...targets.map((t) => ({ - name: `${t.name} ${DIM(`(${t.kind}, branch=${t.branch})`)}`, - value: t.name, - })), - new inquirer.Separator() as any, - { name: '+ new target', value: '__new__' }, - ], - }, - ]); - if (ans.name === '__new__') { - const built = await runPicker(cwd, keep); - if (!built) { - console.log('Cancelled.'); - return 0; - } - target = built; - upsertTarget(cwd, target); - } else { - target = targets.find((t) => t.name === ans.name)!; - } - } - } + const resolved = await resolveTarget(cwd, keep, nameArg, options); + if (!resolved.ok) return resolved.code; - const adapter = getAdapter(target.kind); + const adapter = getAdapter(resolved.value.kind); if (!adapter) { - console.error(`Unknown adapter "${target.kind}" in target "${target.name}".`); + console.error(`Unknown adapter "${resolved.value.kind}" in target "${resolved.value.name}".`); return 1; } @@ -3135,145 +2519,54 @@ export async function deployCommand( { const activeBranch = new ProjectManager(cwd).deriveActiveBranch(); // Unknown is deliberately NOT refused here (unlike `pushKeepTransform`'s - // stricter guard at the actual write point) — a project with no branch - // signal at all (plaintext `.env`, several keep.lock branches; common in - // tests and some early setups) stays silent exactly as it did before - // this check existed. A KNOWN mismatch is always refused. - const branchProblem = activeBranch ? branchPushProblem(activeBranch, target.branch) : null; + // stricter guard at the actual write point). A KNOWN mismatch is always refused. + const branchProblem = activeBranch ? branchPushProblem(activeBranch, resolved.value.branch) : null; if (branchProblem) { console.error(`${RED('✗')} ${describeBranchProblem(branchProblem)}`); console.error( - `\nRun \`capy checkout ${target.branch}\` first, or edit the target with \`capy deploy --edit\`.`, + `\nRun \`capy checkout ${resolved.value.branch}\` first, or edit the target with \`capy deploy --edit\`.`, ); return 1; } } - // Heal Vercel Preview targets saved before options.gitBranch existed. The - // old fallback scoped the Preview env to the CAPY branch name, which fails - // at `vercel env add` with "Branch not found in the connected Git - // repository" whenever the names don't coincide. Ask once and persist. - const targetOpts = target.options as Record; - if ( - target.kind === 'vercel' && - targetOpts.vercelEnv === 'preview' && - !targetOpts.gitBranch - ) { - if (options.yes) { - console.error( - `${RED('✗')} target "${target.name}" is missing options.gitBranch ` + - `(the git branch its Vercel Preview env is wired to).`, - ); - console.error(`\nRun \`capy deploy --edit\` once interactively to set it.`); - return 1; - } - if (web.web) { - // One step of the setup screen — which is where this question belongs: - // the page states in as many words that this is a GIT branch and not the - // capy branch, which is the confusion that produced the gap being healed. - const healed = await promptVercelGitBranchInBrowser(cwd, keep, target); - if (!healed) { - console.log('Cancelled.'); - return 0; - } - // Production is not branch-scoped, so a switch to it drops the key - // rather than leaving a stale one behind — the same reason the picker - // omits it. - delete targetOpts.gitBranch; - Object.assign(targetOpts, healed); - } else { - targetOpts.gitBranch = await promptVercelGitBranch(cwd); - } - upsertTarget(cwd, target); - console.log( - GREEN( - targetOpts.gitBranch - ? `✓ Saved gitBranch=${targetOpts.gitBranch} to target "${target.name}"` - : `✓ Saved vercelEnv=${targetOpts.vercelEnv} to target "${target.name}"`, - ), - ); - } + // Heal Vercel Preview targets saved before options.gitBranch existed. + const healed = await healVercelGitBranch(cwd, resolved.value, options); + if (!healed.ok) return healed.code; // Var-set reconcile: the saved selection can go stale when the - // project's variables change. Re-confirm rather than silently deploying a - // stale set — dropping a newly-added secret, or shipping a removed one. - { - const branchVarSet = new Set(Object.keys(new FileManager(cwd).readEnvFile())); - const currentVars = keep.variables.filter((v) => branchVarSet.has(v)); - // Legacy targets have no `knownVars` baseline; treat current as known so we - // don't false-flag intentionally-unselected vars as "newly added". - const known = target.knownVars ?? currentVars; - const { added, removed, drifted } = reconcileVars(target.vars, known, currentVars); - if (drifted) { - if (added.length) - console.log(` ${YELLOW('!')} new project var(s) not in this target: ${B(added.join(', '))}`); - if (removed.length) - console.log(` ${YELLOW('!')} target var(s) no longer in the project: ${B(removed.join(', '))}`); - if (!options.yes && !options.dryRun && !web.web && process.stdin.isTTY) { - // Only the variable list changed, so only the variables are asked about. - // Every other setting (adapter, branch, options, mode, name) is kept. - const includedNew = added.length ? await pickNewVars(target, added) : []; - target = applyVarDrift(target, currentVars, includedNew); - upsertTarget(cwd, target); - console.log(GREEN(`✓ Updated target "${target.name}" in .capy/deploy.json`)); - } else if (!options.yes && !options.dryRun && web.web) { - console.log(` ${DIM('The project\'s variables changed — re-confirm this target.')}`); - const reconfirmed = await runPicker( - cwd, - keep, - target, - undefined, - web.web ? { ...web, intent: 'reconfirm' } : undefined, - ); - if (!reconfirmed) { - console.log('Cancelled.'); - return 0; - } - target = reconfirmed; - upsertTarget(cwd, target); - console.log(GREEN(`✓ Updated target "${target.name}" in .capy/deploy.json`)); - } else if (options.yes && added.length) { - console.error( - `${RED('✗')} the project gained variable(s) since this target was saved: ${added.join(', ')}.\n` + - ` Re-run \`capy deploy ${target.name}\` interactively to include or skip them — refusing to silently drop a secret.`, - ); - return 1; - } else if (options.yes && removed.length) { - // Non-interactive: a removed var can't be pushed; drop it and carry on. - target = { ...target, vars: target.vars.filter((v) => currentVars.includes(v)), knownVars: currentVars }; - } - } - } + // project's variables change. + const reconciled = await reconcileTargetVars(cwd, keep, healed.value, options); + if (!reconciled.ok) return reconciled.code; - renderPlan(target, adapter); + renderPlan(reconciled.value, adapter); // CI-only adapters (Vercel) always take the CI/PR path, even if a legacy or // ad-hoc target carries a stale 'direct' mode — capy never runs their CLI. - const mode: DeployMode = adapter.ciOnly ? 'ci' : (target.mode ?? 'direct'); + const mode: DeployMode = adapter.ciOnly ? 'ci' : (reconciled.value.mode ?? 'direct'); // Dokploy only: resolve the org system store's API key ONCE for this whole // command and reuse it across preflight, the edit-loop's re-preflight, and // deploy — so the store is asked (and an admin prompted) at most once, no // matter how many of those run. `undefined` for every other adapter; they - // never read `ctx.resolvedApiKey`. Never prompts under `--web` (a browser - // run — the store's own prompt renders on a terminal, not a screen), - // `--yes`, or `--dry-run` (a preview must never save a new key) — see + // never read `ctx.resolvedApiKey`. Never prompts under `--yes` or + // `--dry-run` (a preview must never save a new key) — see // `dokploySecretsMayPrompt`'s own doc. Never resolves at all when the // target's shape is already broken — `resolveDokployApiKeyOnce` skips it, // and `preflight()`'s own shape check fails first regardless of any token. const secretsInteractive = dokploySecretsMayPrompt( process.stdin.isTTY === true, - !!web.web || !!options.yes || !!options.dryRun, + !!options.yes || !!options.dryRun, ); - const dokployApiKey = await resolveDokployApiKeyOnce(adapter, target, keep.orgId, options.devMode, secretsInteractive); + const dokployApiKey = await resolveDokployApiKeyOnce(adapter, reconciled.value, keep.orgId, options.devMode, secretsInteractive); const adapterCallCtx = { orgId: keep.orgId, devMode: options.devMode, interactive: secretsInteractive, resolvedApiKey: dokployApiKey }; // Preflight (fail BEFORE decryption). At a terminal, a failed preflight is // not a dead end: the user can edit the target (e.g. a wrong composeId) and - // preflight runs again. Non-interactive runs (--yes, --dry-run, --json, - // --web, no TTY) refuse exactly as before. + // preflight runs again. Non-interactive runs (--yes, --dry-run, --json, no + // TTY) refuse exactly as before. const canFixInteractively = - process.stdin.isTTY === true && !options.yes && !options.dryRun && !options.json && !web.web; + process.stdin.isTTY === true && !options.yes && !options.dryRun && !options.json; const preflightOrEdit = async ( t: TargetConfig, ): Promise<{ ok: true; target: TargetConfig; preflight: PreflightResult } | { ok: false }> => { @@ -3286,16 +2579,15 @@ export async function deployCommand( const action = await keypressConfirm({ message: 'Preflight failed. Press e to edit this target, c to check again, esc to cancel.' }); if (action === 'confirm') return preflightOrEdit(t); if (action !== 'edit') return { ok: false }; - const edited = await runPicker(cwd, keep, t, undefined, undefined); + const edited = await runPicker(cwd, keep, t); if (!edited) return { ok: false }; upsertTarget(cwd, edited); console.log(GREEN(`✓ Saved target "${edited.name}" to .capy/deploy.json`)); renderPlan(edited, adapter); return preflightOrEdit(edited); }; - const preflightOutcome = await preflightOrEdit(target); + const preflightOutcome = await preflightOrEdit(reconciled.value); if (!preflightOutcome.ok) return 1; - target = preflightOutcome.target; const preflight = preflightOutcome.preflight; for (const w of preflight.warnings ?? []) { console.log(` ${YELLOW('!')} ${w.message}`); @@ -3308,58 +2600,12 @@ export async function deployCommand( // Confirm-or-edit loop. Single-keypress picker (c/e/d/esc) so the user // can fix a saved target inline instead of having to abort, run // `capy deploy --edit`, then re-run. - if (!options.yes && !options.dryRun) { - while (true) { - const summary = - mode === 'ci' - ? `Open a deploy PR (commit keep.lock + push secrets, no live deploy)?` - : `Deploy now (commit keep.lock + ship from HEAD; your WIP is stashed and restored)?`; - // Same four answers, drawn on a page instead of read off one keypress — - // and `delete` gets the second question the keypress never asked. - const action = web.web - ? (await confirmDeployOnScreen(cwd, target, adapter, mode, options, web, preflight)).action - : await keypressConfirm({ message: summary }); - if (action === 'confirm') break; - if (action === 'cancel') { - console.log('Cancelled.'); - return 0; - } - if (action === 'delete') { - // Only saved targets can be deleted; ad-hoc transient ones aren't on - // disk. Either way, stop after delete — there's nothing left to do. - const removed = removeTarget(cwd, target.name); - if (removed) { - console.log(`Removed target ${B(target.name)}.`); - } else { - console.log(`(target was not saved — nothing to delete)`); - } - return 0; - } - if (action === 'edit') { - const edited = await runPicker(cwd, keep, target, undefined, web.web ? web : undefined); - if (!edited) { - console.log('Cancelled.'); - return 0; - } - target = edited; - upsertTarget(cwd, target); - console.log(GREEN(`✓ Saved target "${target.name}" to .capy/deploy.json`)); - renderPlan(target, adapter); - // Re-run preflight after edit — paths/options may have changed. - // Reuses the SAME resolvedApiKey from above: it was resolved once - // for this command, and an edit here changes vars/branch/mode, never - // the Dokploy token source. - const recheck = await adapter.preflight(target, { cwd, ...adapterCallCtx }); - if (!recheck.ok) { - console.error(`${RED('✗')} preflight: ${recheck.reason}`); - if (recheck.hint) console.error('\n' + recheck.hint); - return 1; - } - // Loop back to confirm prompt with the edited target. - continue; - } - } - } + const confirmed: StepResult = + !options.yes && !options.dryRun + ? await confirmOrEdit(cwd, keep, preflightOutcome.target, adapter, mode, adapterCallCtx) + : { ok: true, value: preflightOutcome.target }; + if (!confirmed.ok) return confirmed.code; + const target = confirmed.value; const msg = `chore(deploy): ${target.name} → ${target.branch} (${target.kind})`; const baseBranch = target.gitBaseBranch ?? 'main'; @@ -3371,7 +2617,7 @@ export async function deployCommand( // — `gateOutcome` stays `null` and the fallback below covers them. const ciGated = gitOk && mode === 'ci' && !options.dryRun; const gateOutcome: CiGateOutcome | null = ciGated - ? await resolveCiGateOutcome(cwd, baseBranch, target, adapter, mode, options, web, preflight) + ? await resolveCiGateOutcome(cwd, baseBranch, target, adapter, options) : null; if (gateOutcome?.kind === 'error') return 1; if (gateOutcome?.kind === 'unchanged') return 0; @@ -3476,13 +2722,6 @@ export async function deployCommand( renderResult(result); if (!result.ok) { await unwindGitState(cwd, null, directStashed); - if (web.web) { - await showRunResult(cwd, target, adapter, mode, options, result, { - stashed: directStashed, - keepLockChanged, - baseBranch, - }); - } return 1; } if (mode === 'direct') await unwindGitState(cwd, null, directStashed); @@ -3513,28 +2752,14 @@ export async function deployCommand( await recordDeployTargetsCi(cwd, target, adapter, valueHashes, deployToken?.deployId, options.devMode, !!options.noDeploy); } - // The pull request this run opened, for the result page. Held rather than - // printed-and-forgotten: `✓ PR (open)` with no URL row is the terminal - // saying a pull request exists and giving you no way to reach it. - // // CI mode: open the keep.lock PR in an ISOLATED git worktree (see // `openCiDeployPr`). The user's working tree and current branch are NEVER // touched — no stash, no checkout-back, nothing to strand on failure. const prOutcome = mode === 'ci' && !options.dryRun && keepLockChanged ? await openCiDeployPr(cwd, baseBranch, msg, target, deployKeepContent) - : { ok: true as const, openedPr: undefined as OpenedPr | undefined }; + : { ok: true as const }; if (!prOutcome.ok) return 1; - const openedPr = prOutcome.openedPr; - - if (web.web) { - await showRunResult(cwd, target, adapter, mode, options, result, { - stashed: directStashed, - keepLockChanged, - baseBranch, - pr: openedPr, - }); - } return 0; } diff --git a/src/commands/deployTokenCommand.ts b/src/commands/deployTokenCommand.ts index 9142fc2b..65b0953a 100644 --- a/src/commands/deployTokenCommand.ts +++ b/src/commands/deployTokenCommand.ts @@ -16,7 +16,6 @@ import { import ora from '../ui/spinner'; import inquirer from 'inquirer'; import { generateDeployHtml } from '../ui/deployPage/html'; -import { formatRelativeTime } from '../ui/relativeTime'; import { hashValue } from '../deploy/keepGate'; import { stripTargetsForDeployId } from '../deploy/targetsGate'; import { ERROR_CODES } from '../types/index'; @@ -166,48 +165,6 @@ function decorateChoices( }); } -/** - * The same list the terminal picker offers, as rows the browser can draw. - * - * `hasConnector` is the fork the terminal renders as a dim ` (connector - * available)` suffix — which is also the only warning that picking a platform - * without one silently skips the next question. `connectorLabel` and - * `connectorDetail` are the adapter's own `label` and `description`, carried - * verbatim: `aws-ecs` maps to the AWS SSM Parameter Store adapter, so choosing - * "AWS ECS" in the terminal lands you in a picker that never says ECS again. - */ -async function platformRows(): Promise< - Array<{ - id: string; - name: string; - hasConnector: boolean; - connectorId?: string; - connectorLabel?: string; - connectorDetail?: Array; - }> -> { - const { getAdapter } = await import('../deploy/registry'); - // "Other..." last. The inquirer list renders it FIRST, which makes the - // default landing row on a fresh project "none of these" — the one answer - // that skips every connector Capy has. - const ordered = [ - ...PLATFORMS.filter((p) => p.value !== 'other'), - ...PLATFORMS.filter((p) => p.value === 'other'), - ]; - return ordered.map((p) => { - const connectorId = PLATFORM_TO_CONNECTOR[p.value]; - const adapter = connectorId ? getAdapter(connectorId) : null; - return { - id: p.value, - name: p.name, - hasConnector: !!connectorId, - connectorId, - connectorLabel: adapter?.label, - connectorDetail: adapter ? [adapter.description] : undefined, - }; - }); -} - const BLOB_SIZE_WARN_THRESHOLD = 32 * 1024; // 32KB /** Result of one full deploy-token mint. Shared by the token+docs flow and the @@ -357,9 +314,8 @@ function writeConfig(projectRoot: string, config: CapyConfig): void { async function openInBrowser(url: string): Promise { const { openScreen } = await import('../ui/openScreen'); - // Wide, and not because of `Page`: this one is not a compiled screen but the - // deploy instructions — fenced blocks of platform config someone is going to - // read and copy. A 520px dialog would wrap every one of them. + // Wide: this is the deploy instructions — fenced blocks of platform config + // someone is going to read and copy. A 520px dialog would wrap every one of them. await openScreen(url, { kind: 'dialog', wide: true }); } @@ -380,14 +336,74 @@ export interface DeployCommandOptions { yes?: boolean; /** Forwarded to the connector flow: force a redeploy even if keep.lock is unchanged. */ force?: boolean; - /** - * Ask this run's questions in a browser instead of at the TTY. - * - * Changes only where a question is RENDERED. The same flags settle the same - * steps, the same answers reach the same code, and nothing about what is - * minted, written to `.capy/config`, or handed to the connector moves. - */ - web?: boolean; +} + +/** org-scoped silent → unscoped silent → interactive: the first success, else the last attempt. */ +async function authenticateOrgFirst(authService: AuthService, orgId: string) { + const scoped = await authService.authenticateSilent(orgId); + if (scoped.success) return scoped; + const unscoped = await authService.authenticateSilent(); + return unscoped.success ? unscoped : await authService.authenticate(orgId); +} + +/** + * Serve the deploy instructions on a loopback port and open them (the + * clipboard API needs a localhost origin). Resolves `false` when the page + * could not be served, so the caller falls back to the terminal. Once the page + * is being served this never resolves: the process stays up until the + * five-minute timer or Ctrl+C ends it. + */ +async function serveDeployInstructions(html: string): Promise { + try { + const server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); + }); + + await new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', () => resolve()); + server.on('error', reject); + }); + + const addr = server.address(); + if (!(addr && typeof addr === 'object')) return false; + + // Use 127.0.0.1 explicitly: `localhost` resolves to ::1 (IPv6) first + // on macOS/modern Linux, but the server above binds to 127.0.0.1 only, + // so default browsers opened via the printed terminal URL would hit + // a dead IPv6 port. The popup path happened to retry families and + // hid this from users who relied on the auto-opened window. + const url = `http://127.0.0.1:${addr.port}`; + console.log(`\n Temporary deploy instructions — if the browser doesn't open, visit:`); + console.log(` ${url}`); + console.log(' Press Ctrl+C to close.\n'); + + await openInBrowser(url); + + // Auto-shutdown after 5 minutes + const shutdownTimer = setTimeout(() => { + server.close(); + process.exit(0); + }, 5 * 60 * 1000); + shutdownTimer.unref(); + + // Clean shutdown on Ctrl+C + process.on('SIGINT', () => { + server.close(); + process.exit(0); + }); + process.on('SIGTERM', () => { + server.close(); + process.exit(0); + }); + + // Keep process alive + await new Promise(() => {}); + return true; + } catch { + // Fall through to terminal output + return false; + } } export class DeployCommand { @@ -401,6 +417,77 @@ export class DeployCommand { this.options = options; } + /** The platform: the `--platform` flag, or the picker. An unknown flag value exits 1. */ + private async resolvePlatform(defaultPlatform: string | undefined): Promise { + const flagPlatform = this.options.platform; + if (flagPlatform !== undefined) { + if (!PLATFORMS.some(p => p.value === flagPlatform)) { + console.error(` --platform must be one of: ${PLATFORMS.map(p => p.value).join(', ')}`); + process.exit(1); + } + return flagPlatform; + } + // Show "Other..." at the top as a ready-made escape hatch, with a + // non-selectable Separator between it and the alphabetical list so + // it doesn't read as "just another platform". + const choices = [ + ...decorateChoices(PLATFORMS.filter(p => p.value === 'other')), + new inquirer.Separator() as any, + ...decorateChoices(PLATFORMS.filter(p => p.value !== 'other')), + ]; + const answer = await inquirer.prompt([{ + type: 'list', + name: 'platform', + message: 'Where does this project deploy?', + choices, + default: defaultPlatform, + pageSize: 20, + }]); + return answer.platform; + } + + /** The mode: the `--mode` flag, or the picker. */ + private async resolveMode(platform: string, isGhActions: boolean): Promise<'connector' | 'token'> { + if (this.options.mode) return this.options.mode; + const connectorChoice = isGhActions + ? 'Push SECRETS_BLOB + PROJECT_KEY to GitHub secrets via gh' + : 'Deploy now via direct target deploy (push secrets + ship code)'; + const r = await inquirer.prompt([{ + type: 'list', + name: 'mode', + message: `${PLATFORMS.find(p => p.value === platform)?.name} — what do you want to do?`, + choices: [ + // `value: 'connector'` is the internal identifier, untouched — + // only the displayed `name`/`short` moved to "target" wording. + { name: connectorChoice, value: 'connector', short: 'target' }, + { + name: 'Set up CI deploy token + docs (capy run in your CI)', + value: 'token', + short: 'token+docs', + }, + ], + default: 'connector', + }]); + return r.mode; + } + + /** Mints the deploy credentials behind a spinner; any failure exits 1. */ + private async mintWithSpinner(deps: MintDeployTokenDeps): Promise { + const spinner = ora('Generating deploy credentials...').start(); + try { + const minted = await mintDeployToken(deps); + if (minted.blobBytes > BLOB_SIZE_WARN_THRESHOLD) { + spinner.warn(`SECRETS_BLOB is ${Math.round(minted.blobBytes / 1024)}KB — some platforms have 32-64KB env var limits. Consider splitting into multiple projects.`); + } else { + spinner.succeed(`Deploy credentials generated (${minted.secretCount} secrets)`); + } + return minted; + } catch (err: any) { + spinner.fail(err?.message ?? 'Failed to generate deploy credentials'); + process.exit(1); + } + } + async execute(): Promise { try { const pm = new ProjectManager(); @@ -420,9 +507,7 @@ export class DeployCommand { const authService = new AuthService(this.apiUrl, this.devMode, projectState.userId); const serviceClient = new ServiceClient(this.apiUrl, this.devMode); serviceClient.setTokenProvider(() => authService.getValidToken()); - let authResult = await authService.authenticateSilent(orgId); - if (!authResult.success) authResult = await authService.authenticateSilent(); - if (!authResult.success) authResult = await authService.authenticate(orgId); + const authResult = await authenticateOrgFirst(authService, orgId); if (!authResult.success) { console.error('Authentication failed'); process.exit(1); @@ -430,95 +515,11 @@ export class DeployCommand { const userId = authResult.user_id!; - // Steps 1 and 2: where this project deploys, and — for the five - // platforms with a connector — whether Capy drives the deploy or just - // mints credentials. Two questions in the terminal with nothing between - // them; one route in the browser, declared before it opens. + // Step 1: where this project deploys. const config = readConfig(projectRoot); - const defaultPlatform = config.platform; - const flagPlatform = this.options.platform; - const badPlatformFlag = - flagPlatform !== undefined && !PLATFORMS.some(p => p.value === flagPlatform); - - let platform: string; - // A mode the browser answered, so the rail on the NEXT screen can say the - // question happened. Undefined means nobody was asked. - let modeAnswer: string | undefined; - let webMode: 'connector' | 'token' | null | undefined; - - // Under `--web` the browser is opened only when a question is actually - // left. `--platform heroku` settles the whole route on its own — that - // platform has no connector, so the mode question does not exist for it - // — and opening a page with nothing to answer is a wait, not a wizard. - const rows = this.options.web ? await platformRows() : []; - const flagRow = flagPlatform ? rows.find(r => r.id === flagPlatform) : undefined; - const webAsks = - !!this.options.web && - (badPlatformFlag || - !flagRow || - (flagRow.hasConnector && this.options.mode === undefined)); - - if (webAsks) { - if (badPlatformFlag) { - // The terminal answers a bad --platform by printing all thirty-one - // ids and exiting: six lines of machine text, and redundant with the - // picker it refuses to show. The screen carries the refusal and asks - // the question underneath it. - console.error(` --platform must be one of: ${PLATFORMS.map(p => p.value).join(', ')}`); - } - const { chooseDeployDestinationInBrowser } = await import('../ui/deployScreens'); - const picked = await chooseDeployDestinationInBrowser({ - platforms: rows, - lastPlatform: defaultPlatform, - platform: badPlatformFlag ? undefined : flagPlatform, - mode: this.options.mode, - rejected: badPlatformFlag - ? { - argv: `--platform ${flagPlatform}`, - message: 'is not a platform Capy knows. Pick one below — the answer is remembered for this project.', - } - : undefined, - open: !process.env.CAPY_WEB_NO_OPEN, - }); - if (picked.cancelled) { - console.log('Cancelled.'); - process.exit(0); - } - platform = picked.platform; - webMode = picked.mode; - if (picked.mode) { - // CAP-679 follow-up: display text says "Target" now — 'connector' - // stays the internal value (untouched, see the `mode` doc above). - modeAnswer = picked.mode === 'connector' ? 'Target' : 'Deploy token'; - } - } else if (flagPlatform !== undefined) { - if (badPlatformFlag) { - console.error(` --platform must be one of: ${PLATFORMS.map(p => p.value).join(', ')}`); - process.exit(1); - } - platform = flagPlatform; - } else { - // Show "Other..." at the top as a ready-made escape hatch, with a - // non-selectable Separator between it and the alphabetical list so - // it doesn't read as "just another platform". - const choices = [ - ...decorateChoices(PLATFORMS.filter(p => p.value === 'other')), - new inquirer.Separator() as any, - ...decorateChoices(PLATFORMS.filter(p => p.value !== 'other')), - ]; - const answer = await inquirer.prompt([{ - type: 'list', - name: 'platform', - message: 'Where does this project deploy?', - choices, - default: defaultPlatform, - pageSize: 20, - }]); - platform = answer.platform; - } + const platform = await this.resolvePlatform(config.platform); if (platform !== config.platform) { - config.platform = platform; - writeConfig(projectRoot, config); + writeConfig(projectRoot, { ...config, platform }); } // Connector branch: when the picked platform has a real adapter, @@ -535,34 +536,7 @@ export class DeployCommand { // dispatch goes to a dedicated connector instead of through // DeployAdapter.deploy(). const isGhActions = connectorId === 'gh-actions'; - let mode: 'connector' | 'token'; - if (this.options.mode) { - mode = this.options.mode; - } else if (webMode) { - // Already answered on the second stop of the destination route. - mode = webMode; - } else { - const connectorChoice = isGhActions - ? 'Push SECRETS_BLOB + PROJECT_KEY to GitHub secrets via gh' - : 'Deploy now via direct target deploy (push secrets + ship code)'; - const r = await inquirer.prompt([{ - type: 'list', - name: 'mode', - message: `${PLATFORMS.find(p => p.value === platform)?.name} — what do you want to do?`, - choices: [ - // `value: 'connector'` is the internal identifier, untouched — - // only the displayed `name`/`short` moved to "target" wording. - { name: connectorChoice, value: 'connector', short: 'target' }, - { - name: 'Set up CI deploy token + docs (capy run in your CI)', - value: 'token', - short: 'token+docs', - }, - ], - default: 'connector', - }]); - mode = r.mode; - } + const mode = await this.resolveMode(platform, isGhActions); if (mode === 'connector') { if (isGhActions) { const { runGithubActionsConnector } = await import('./githubActionsConnector'); @@ -582,92 +556,23 @@ export class DeployCommand { yes: !!this.options.yes, force: !!this.options.force, devMode: this.devMode, - web: this.options.web, - // The rail on the picker's screens continues the route this one - // drew, rather than restarting it: these two stops were answered - // here, and a second command drawing them as unasked would say the - // user skipped a question they just answered. - platformAnswer: PLATFORMS.find(p => p.value === platform)?.name, - modeAnswer, }); process.exit(code); } // else fall through to existing token+docs flow } - const platformLabel = PLATFORMS.find(p => p.value === platform)?.name || platform!; + const platformLabel = PLATFORMS.find(p => p.value === platform)?.name || platform; // Step 2: Generate credentials - const spinner = ora('Generating deploy credentials...').start(); - let minted: MintedDeployToken; - try { - minted = await mintDeployToken({ serviceClient, fm, orgId, projectId, userId }); - } catch (err: any) { - spinner.fail(err?.message ?? 'Failed to generate deploy credentials'); - process.exit(1); - } - const { secretsBlob, projectKey, secretCount, blobBytes } = minted; - if (blobBytes > BLOB_SIZE_WARN_THRESHOLD) { - spinner.warn(`SECRETS_BLOB is ${Math.round(blobBytes / 1024)}KB — some platforms have 32-64KB env var limits. Consider splitting into multiple projects.`); - } else { - spinner.succeed(`Deploy credentials generated (${secretCount} secrets)`); - } + const { secretsBlob, projectKey } = await this.mintWithSpinner({ serviceClient, fm, orgId, projectId, userId }); // Step 3: Fetch instructions and serve HTML page - const { markdown } = await serviceClient.fetchDeployInstructions(platform!); - const html = generateDeployHtml(secretsBlob, projectKey, platformLabel, platform!, markdown); + const { markdown } = await serviceClient.fetchDeployInstructions(platform); + const html = generateDeployHtml(secretsBlob, projectKey, platformLabel, platform, markdown); // Try to serve via localhost (needed for clipboard API) - let serverStarted = false; - try { - const server = createServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(html); - }); - - await new Promise((resolve, reject) => { - server.listen(0, '127.0.0.1', () => resolve()); - server.on('error', reject); - }); - - const addr = server.address(); - if (addr && typeof addr === 'object') { - // Use 127.0.0.1 explicitly: `localhost` resolves to ::1 (IPv6) first - // on macOS/modern Linux, but the server above binds to 127.0.0.1 only, - // so default browsers opened via the printed terminal URL would hit - // a dead IPv6 port. The popup path happened to retry families and - // hid this from users who relied on the auto-opened window. - const url = `http://127.0.0.1:${addr.port}`; - console.log(`\n Temporary deploy instructions — if the browser doesn't open, visit:`); - console.log(` ${url}`); - console.log(' Press Ctrl+C to close.\n'); - - await openInBrowser(url); - serverStarted = true; - - // Auto-shutdown after 5 minutes - const shutdownTimer = setTimeout(() => { - server.close(); - process.exit(0); - }, 5 * 60 * 1000); - shutdownTimer.unref(); - - // Clean shutdown on Ctrl+C - process.on('SIGINT', () => { - server.close(); - process.exit(0); - }); - process.on('SIGTERM', () => { - server.close(); - process.exit(0); - }); - - // Keep process alive - await new Promise(() => {}); - } - } catch { - // Fall through to terminal output - } + const serverStarted = await serveDeployInstructions(html); if (!serverStarted) { // Fallback: print values to terminal @@ -690,79 +595,13 @@ export class DeployCommand { } } -/** One minted deploy token, as the listing and the confirm both read it. */ -export interface DeployTokenListRow { - deployId: string; - label: string | null; - createdAge: string; - createdOn: string; - createdBy?: string; - revokedAge: string | null; -} - -/** - * Turn the service's token records into the rows the browser draws. - * - * Age is humanised here, once, so the listing and the confirm agree; the - * absolute date is carried alongside because `toLocaleDateString()` renders - * `7/27/2026` on one machine and `27/07/2026` on another for the same token. - */ -function tokenRows( - tokens: Array<{ - deploy_id: string; - label: string | null; - created_by: string; - created_at: string; - revoked_at: string | null; - }>, -): DeployTokenListRow[] { - return tokens.map(t => ({ - deployId: t.deploy_id, - label: t.label, - createdAge: formatRelativeTime(t.created_at), - createdOn: new Date(t.created_at).toISOString().slice(0, 10), - createdBy: t.created_by || undefined, - revokedAge: t.revoked_at ? formatRelativeTime(t.revoked_at) : null, - })); -} - -/** What a typed prefix resolved to. A CODE, never a sentence to be parsed. */ -export type TokenPrefixMatch = - | { code: 'ok'; token: DeployTokenListRow } - | { code: 'none' } - | { code: 'ambiguous'; matches: DeployTokenListRow[] }; - -/** - * Resolve the prefix a user typed to the ONE token it names. - * - * The terminal hands the prefix to the service and lets it pick; under `--web` - * the whole list is already in hand, so an ambiguous prefix is a question that - * can be answered honestly instead of resolved to whichever row happens to sort - * first. Revoking is irreversible and cuts a live pipeline off, so "probably - * this one" is not an answer. - */ -export function resolveTokenPrefix( - rows: DeployTokenListRow[], - prefix: string, -): TokenPrefixMatch { - // An exact id is never ambiguous, whatever else it happens to prefix. - const exact = rows.find(t => t.deployId === prefix); - if (exact) return { code: 'ok', token: exact }; - const matches = rows.filter(t => t.deployId.startsWith(prefix)); - if (matches.length === 0) return { code: 'none' }; - if (matches.length > 1) return { code: 'ambiguous', matches }; - return { code: 'ok', token: matches[0] }; -} - export class DeployRevokeCommand { private apiUrl?: string; private devMode: boolean; - private web: boolean; - constructor(apiUrl?: string, devMode: boolean = false, options: { web?: boolean } = {}) { + constructor(apiUrl?: string, devMode: boolean = false) { this.apiUrl = apiUrl; this.devMode = devMode; - this.web = !!options.web; } async execute(deployIdPrefix: string): Promise { @@ -780,65 +619,12 @@ export class DeployRevokeCommand { const authService = new AuthService(this.apiUrl, this.devMode, projectState.userId); const serviceClient = new ServiceClient(this.apiUrl, this.devMode); serviceClient.setTokenProvider(() => authService.getValidToken()); - // org-scoped silent → unscoped silent → interactive, as one value - // rather than a reassigned local. - const authResult = await (async () => { - const scoped = await authService.authenticateSilent(orgId); - if (scoped.success) return scoped; - const unscoped = await authService.authenticateSilent(); - return unscoped.success ? unscoped : await authService.authenticate(orgId); - })(); + const authResult = await authenticateOrgFirst(authService, orgId); if (!authResult.success) { console.error('Authentication failed'); process.exit(1); } - if (this.web && projectState.projectId) { - // The terminal fires the DELETE the moment you press enter, with no - // summary of what is about to lose access — and a mistyped prefix and - // a permission failure come back looking the same. The browser draws - // the token being cut off and wants its id typed back first. - const { tokens } = await serviceClient.listDeployTokens(orgId, projectState.projectId); - const rows = tokenRows(tokens); - // Branch on the code, not on anything printed. Resolving an ambiguous - // prefix to whichever row sorts first is how the wrong pipeline loses - // access, and revoking cannot be undone. - const match = resolveTokenPrefix(rows, deployIdPrefix); - if (match.code === 'none') { - console.error(` No deploy token starting with ${deployIdPrefix.slice(0, 12)} in this project.`); - process.exit(1); - } - if (match.code === 'ambiguous') { - console.error( - ` ${match.matches.length} deploy tokens start with ${deployIdPrefix} — ` + - `pass more of the id. Run ${B('capy deploy list')} to see them in full.`, - ); - process.exit(1); - } - const subject = match.token; - const { showDeployTokensInBrowser } = await import('../ui/deployScreens'); - const picked = await showDeployTokensInBrowser({ - projectName: projectState.projectName ?? null, - tokens: rows, - view: 'confirm-revoke', - subjectToken: subject.deployId, - open: !process.env.CAPY_WEB_NO_OPEN, - }); - // A decline, a closed window and an unanswered page are one outcome and - // it is not a failure: the token is still active, which is what the - // user asked for. Said out loud, and exit 0. - if (!picked.deployId) { - console.log(` Nothing revoked — ${subject.deployId.slice(0, 12)} is still active.`); - return; - } - await serviceClient.revokeDeployToken(picked.deployId); - console.log(` Deploy token ${picked.deployId.slice(0, 12)}... revoked.`); - if (authResult.user_id) { - await stripRevokedTargets(pm, serviceClient, projectState, authResult.user_id, picked.deployId); - } - return; - } - await serviceClient.revokeDeployToken(deployIdPrefix); console.log(` Deploy token ${deployIdPrefix.slice(0, 12)}... revoked.`); @@ -860,12 +646,10 @@ export class DeployRevokeCommand { export class DeployListCommand { private apiUrl?: string; private devMode: boolean; - private web: boolean; - constructor(apiUrl?: string, devMode: boolean = false, options: { web?: boolean } = {}) { + constructor(apiUrl?: string, devMode: boolean = false) { this.apiUrl = apiUrl; this.devMode = devMode; - this.web = !!options.web; } async execute(): Promise { @@ -884,9 +668,7 @@ export class DeployListCommand { const authService = new AuthService(this.apiUrl, this.devMode, projectState.userId); const serviceClient = new ServiceClient(this.apiUrl, this.devMode); serviceClient.setTokenProvider(() => authService.getValidToken()); - let authResult = await authService.authenticateSilent(orgId); - if (!authResult.success) authResult = await authService.authenticateSilent(); - if (!authResult.success) authResult = await authService.authenticate(orgId); + const authResult = await authenticateOrgFirst(authService, orgId); if (!authResult.success) { console.error('Authentication failed'); process.exit(1); @@ -894,31 +676,6 @@ export class DeployListCommand { const { tokens } = await serviceClient.listDeployTokens(orgId, projectId); - if (this.web) { - // The one command you reach for in a hurry is also the one with no - // confirmation, so the browser listing carries the revoke rather than - // making you copy an id into a second command that fires immediately. - // - // No `.catch()` swallowing the outcome here: a refusal — closed window, - // nothing clicked — RESOLVES as `cancelled`, and a server that could - // not listen is a real failure that belongs on the error screen. - const { showDeployTokensInBrowser } = await import('../ui/deployScreens'); - const picked = await showDeployTokensInBrowser({ - projectName: projectState.projectName ?? null, - tokens: tokenRows(tokens), - open: !process.env.CAPY_WEB_NO_OPEN, - }); - if (picked.deployId) { - await serviceClient.revokeDeployToken(picked.deployId); - console.log(` Deploy token ${picked.deployId.slice(0, 12)}... revoked.`); - } else { - // A listing that ends is a listing. Saying so is the difference - // between "you read it and moved on" and "something went wrong". - console.log(' Nothing revoked.'); - } - return; - } - if (tokens.length === 0) { console.log(' No deploy tokens for this project.'); return; diff --git a/src/commands/editCommand.ts b/src/commands/editCommand.ts index 0d2acc7a..fa240a20 100644 --- a/src/commands/editCommand.ts +++ b/src/commands/editCommand.ts @@ -40,13 +40,13 @@ function classifyStatus( } /** - * Decrypts `.env`. A value this profile holds no key for is skipped (and named - * in `undecryptableKeys`); plaintext values pass through. + * Decrypts `.env`. A value this profile holds no key for is skipped; + * plaintext values pass through. */ function decryptLocalEnv( fileManager: FileManager, projectKey: string, -): { localPlaintext: Record; undecryptableKeys: string[] } { +): { localPlaintext: Record } { const decrypted = Object.entries(fileManager.readEnvFile()).map(([key, value]) => { if (!value.startsWith('capy:')) return { key, plain: value as string | undefined }; try { @@ -59,39 +59,16 @@ function decryptLocalEnv( localPlaintext: Object.fromEntries( decrypted.flatMap(({ key, plain }) => (plain === undefined ? [] : [[key, plain] as const])), ), - undecryptableKeys: decrypted.filter(({ plain }) => plain === undefined).map(({ key }) => key), }; } -/** - * Why there is no other copy to compare against, when there is none. The - * terminal renders all three the same way — `{n} ? / remote unavailable` — - * so an offline run, a project nobody has pushed and a cold local cache are - * indistinguishable. Minted where the condition is actually known. - */ -type RemoteGap = 'never_pushed' | 'fetch_failed' | 'local_mode'; - interface Baseline { remotePlaintext: Record; remoteAvailable: boolean; - remoteGap: RemoteGap | undefined; - /** - * Whether the comparison ran against the on-disk cache rather than the - * service. A warm cache computes the whole status column while offline with - * nothing on screen to say so. - */ - remoteFromCache: boolean; } -function baselineUnavailable(localMode: boolean, remoteFromCache: boolean): Baseline { - // Remote fetch failed (server mode) — fall back to pinned-only. - return { - remotePlaintext: {}, - remoteAvailable: false, - remoteGap: localMode ? 'local_mode' : 'fetch_failed', - remoteFromCache, - }; -} +/** Remote fetch failed (server mode), or nothing to compare against: fall back to pinned-only. */ +const BASELINE_UNAVAILABLE: Baseline = { remotePlaintext: {}, remoteAvailable: false }; async function loadBaseline(args: { localMode: boolean; @@ -105,20 +82,12 @@ async function loadBaseline(args: { }): Promise { const { localMode, serviceClient, fileManager, projectKey, keep, branch, orgId, projectId } = args; const keepHash = SyncEngine.computeKeepHash(keep, branch); - const remoteFromCache = probeCache(localMode, orgId, projectId, keepHash); - if (remoteFromCache === undefined) return baselineUnavailable(localMode, false); + if (cacheProbeThrew(localMode, orgId, projectId, keepHash)) return BASELINE_UNAVAILABLE; try { const blob = localMode ? readSecretsLocal(orgId, projectId, keepHash) : await fetchSecretsWithCache(serviceClient!, orgId, projectId, keepHash); - if (!blob?.env_file) { - return { - remotePlaintext: {}, - remoteAvailable: false, - remoteGap: localMode ? 'local_mode' : 'never_pushed', - remoteFromCache, - }; - } + if (!blob?.env_file) return BASELINE_UNAVAILABLE; const encrypted = fileManager.parseEnvContent(blob.env_file); const remotePlaintext = Object.fromEntries( Object.entries(encrypted).flatMap(([key, value]) => { @@ -131,33 +100,23 @@ async function loadBaseline(args: { ); // Remote column only applies to server mode; local mode uses the // committed baseline with local-mode wording instead. - return { remotePlaintext, remoteAvailable: !localMode, remoteGap: undefined, remoteFromCache }; + return { remotePlaintext, remoteAvailable: !localMode }; } catch { - return baselineUnavailable(localMode, remoteFromCache); + return BASELINE_UNAVAILABLE; } } -/** Whether the keep cache already holds this keep hash; `undefined` when the probe itself threw. */ -function probeCache(localMode: boolean, orgId: string, projectId: string, keepHash: string): boolean | undefined { +/** Whether probing the keep cache for this keep hash threw (the baseline is then unavailable). */ +function cacheProbeThrew(localMode: boolean, orgId: string, projectId: string, keepHash: string): boolean { try { - return localMode ? false : readKeepCache(orgId, projectId, keepHash) !== null; + if (!localMode) readKeepCache(orgId, projectId, keepHash); + return false; } catch { - return undefined; + return true; } } export interface EditOpts { - /** - * Render the variable table and the value editor as compiled screens in a - * local browser instead of the alternate-screen TUI. - * - * Agent-only, and the reason it exists: the TUI has no TTY guard. Run it - * headlessly and it writes an entire ANSI screen into the agent's captured - * stdout and then blocks forever on a stdin that never delivers a key. - */ - web?: boolean; - /** false when --no-open was passed: print the URL, do not open a browser. */ - open?: boolean; /** * The variable to work on (`capy edit NAME`). With a terminal: the TUI opens * with the cursor on it, and an unknown name opens its new-variable entry. @@ -238,7 +197,6 @@ export class EditCommand { // Decided first: before anything is drawn, and before any auth or network call. const mode = decideEditMode({ hasName: opts.name !== undefined, - web: opts.web === true, stdinIsTTY: process.stdin.isTTY === true, nonTty: opts.nonTty === true, }); @@ -297,18 +255,16 @@ export class EditCommand { const projectKey = await this.resolveKey({ localMode, orgId, projectId, userId, serviceClient, keep, branch }); if (projectKey === undefined) return; - // Decrypt local .env values. `undecryptableKeys`: local ciphertext this - // profile does not hold the key for. The TUI drops these on the floor and - // says nothing, and the next commit then deletes their pins — so the - // browser table names them. - const { localPlaintext, undecryptableKeys } = decryptLocalEnv(fileManager, projectKey); + // Decrypt local .env values. Local ciphertext this profile does not hold + // the key for is skipped. + const { localPlaintext } = decryptLocalEnv(fileManager, projectKey); // Baseline the working copy is compared against: // - remote mode: the latest committed blob fetched from the server. // - local mode: the committed blob from the local keep cache (no server). // In both cases it lands in `remotePlaintext` so the TUI's reclassify can // compare working-vs-baseline. - const { remotePlaintext, remoteAvailable, remoteGap, remoteFromCache } = await loadBaseline({ + const { remotePlaintext, remoteAvailable } = await loadBaseline({ localMode, serviceClient, fileManager, @@ -366,9 +322,8 @@ export class EditCommand { localMode, }; // `capy edit NAME` on a terminal: the cursor starts on NAME (a new name opens - // its value entry). `--web` opens the browser editor unfocused: focusing it - // needs a change to the compiled Keep screen, which is not part of this work. - const state = opts.name !== undefined && !opts.web ? focusedOn(baseState, opts.name) : baseState; + // its value entry). + const state = opts.name !== undefined ? focusedOn(baseState, opts.name) : baseState; const screen = new EditScreen(); const printExpiryAfter = async () => { @@ -475,35 +430,10 @@ export class EditCommand { }, }; - // `--web` changes only where the questions are ASKED. The commit callback - // above is the same object either way, so the crypto, the push, and the - // keep rewrite are one code path with one browser-shaped front end and - // one terminal-shaped one. - if (opts.web) { - const { runSecretEditorInBrowser } = await import('../ui/secretTableScreen'); - await runSecretEditorInBrowser( - { - projectName: keep.project_name, - branch, - mode: localMode ? 'local' : 'server', - rows, - remoteAvailable, - remoteGap, - remoteFromCache, - undecryptableKeys, - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI and - // headless runs drive the loopback without hijacking a real browser. - open: opts.open !== false && !process.env.CAPY_WEB_NO_OPEN, - }, - editContext, - ); - } else { - await screen.run(state, editContext); - } - // Same exit behavior in both modes: asked in the terminal, even after a - // --web session's browser tab has closed. The TUI has already released - // stdin (raw mode off, its key listener removed) by the time `run` - // resolves, so the prompts in keepLockPr.ts start from a clean terminal. + await screen.run(state, editContext); + // The TUI has already released stdin (raw mode off, its key listener + // removed) by the time `run` resolves, so the prompts in keepLockPr.ts start + // from a clean terminal. const outcome = await runKeepLockPrStep({ command: 'edit', cwd: process.cwd(), diff --git a/src/commands/editPiped.ts b/src/commands/editPiped.ts index b4a0d34b..0f2ced54 100644 --- a/src/commands/editPiped.ts +++ b/src/commands/editPiped.ts @@ -1,17 +1,17 @@ /** * `capy edit [NAME]` — which mode a given invocation is in, and piped mode. * - * The decision is a pure function of four facts (a name, `--web`, whether stdin - * is a terminal, `--non-tty`) and is made BEFORE anything is drawn and before - * any auth or network call, so `capy edit `\x1b[1m${s}\x1b[0m`; @@ -27,14 +22,7 @@ export const DECRYPTED_FILE_PATTERN = /^\.env\..*\.decrypted$/; * listing that peeked inside would undo it. */ export function listDecryptedFiles(cwd: string, now: Date = new Date()): DecryptedFile[] { - let names: string[]; - try { - names = readdirSync(cwd).filter(n => DECRYPTED_FILE_PATTERN.test(n)).sort(); - } catch { - return []; - } - - return names.map(name => { + return [...decryptedNames(cwd)].sort().map(name => { try { const st = statSync(`${cwd}/${name}`); return { @@ -50,66 +38,52 @@ export function listDecryptedFiles(cwd: string, now: Date = new Date()): Decrypt }); } +/** The matching names in `cwd`, in directory order; none when the directory cannot be read. */ +function decryptedNames(cwd: string): string[] { + try { + return readdirSync(cwd).filter(n => DECRYPTED_FILE_PATTERN.test(n)); + } catch { + return []; + } +} + function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${Math.round(bytes / (1024 * 1024))} MB`; } -export interface EndRecoverOptions { - /** - * Preview the sweep in a browser instead of performing it unannounced. - * - * The terminal form deletes `~/.capy/recover` and every matching file with - * no confirmation, no preview and no per-file result. That path is unchanged - * below. What `--web` adds is the chance to see the list first and untick - * something — and a report of what actually happened, which the terminal - * cannot give because it swallows unlink failures and prints ✓ regardless. - * - * What it does NOT add is a file. Both paths sweep in exactly one state — - * a recovery session is open — and both refuse in exactly the other. - */ - web?: boolean; +/** + * Removes the named files in order, announcing each. Best-effort: it stops at + * the first failure and returns the names removed before it. + */ +function removeInOrder(cwd: string, names: readonly string[]): string[] { + if (names.length === 0) return []; + const [name, ...rest] = names; + try { + unlinkSync(`${cwd}/${name}`); + } catch { + return []; + } + console.log(` Removed ${name}`); + return [name, ...removeInOrder(cwd, rest)]; } export class EndRecoverCommand { - async execute(options: EndRecoverOptions = {}): Promise { + async execute(): Promise { try { - // The one gate, read before either path branches. It used to sit inside - // the terminal path only, so `--web` offered the sweep in a state the - // terminal returns early on — with every row ticked, which made - // unlinking files `capy end-recover` never touches the DEFAULT answer to - // a page the flag alone had opened. if (!isRecoveryActive()) { this.reportNoSession(); return; } - if (options.web) { - await this.executeInBrowser(); - return; - } - // Delete recovery session deleteRecoverySession(); // Find and delete all .env.*.decrypted files in cwd const cwd = process.cwd(); - const pattern = DECRYPTED_FILE_PATTERN; - let deleted = 0; - - try { - const files = readdirSync(cwd); - for (const file of files) { - if (pattern.test(file)) { - unlinkSync(`${cwd}/${file}`); - console.log(` Removed ${file}`); - deleted++; - } - } - } catch { - // Best-effort cleanup of decrypted files - } + // Best-effort cleanup of decrypted files + const deleted = removeInOrder(cwd, decryptedNames(cwd)).length; console.log(`\n ✓ Recovery session ended.`); if (deleted > 0) { @@ -123,16 +97,14 @@ export class EndRecoverCommand { } /** - * There is nothing to close, said the same way on both paths. + * There is nothing to close. * - * The sentence is the terminal's, unchanged. What is new is the second half: - * this state is reached with plaintext still lying in the directory — a + * This state is reached with plaintext still lying in the directory — a * session cleared some other way leaves its `.env.*.decrypted` files behind * — and `capy end-recover` used to print "No recovery session active" over * the top of them and exit, so the files read as swept. * - * Naming them is a report, not a sweep. Neither path deletes anything here, - * because the difference between the two paths must never be a file. + * Naming them is a report, not a sweep: nothing is deleted here. */ private reportNoSession(): void { console.log('\n No recovery session active.\n'); @@ -147,122 +119,4 @@ export class EndRecoverCommand { for (const f of left) console.log(` ${f.name}`); console.log(`\n They are readable plaintext in ${bold(cwd)}. Delete them by hand.\n`); } - - /** - * The sweep, shown before it happens. - * - * One thing differs from the terminal form, and it is a consequence of there - * being a page at all rather than of `--web` deciding anything: files that - * could not be removed are reported. The terminal swallows the failure and - * still prints ✓, which is the one outcome where the user needs to know the - * plaintext is still there. - * - * What does NOT differ is which files are candidates. This runs only with a - * session open, exactly like the terminal path, and the page it serves - * arrives with every row ticked — so the default answer to it is the sweep - * the terminal would have performed, and unticking is the only way to get a - * smaller one. - */ - private async executeInBrowser(): Promise { - const cwd = process.cwd(); - const files = listDecryptedFiles(cwd); - - const { endRecoverInBrowser } = await import('../ui/recoveryScreens'); - let answer; - try { - answer = await endRecoverInBrowser({ - session: readSessionSummary(), - cwd, - files, - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI and - // headless verification drive the loopback without hijacking a real one. - open: !process.env.CAPY_WEB_NO_OPEN, - }); - } catch (err) { - // The page was never answered — the window was closed, or the wizard - // gave up waiting. Both are refusals, and the only thing this run can - // still say for itself is that it acted on none of them. - console.log('\n Nothing was removed, and the recovery session is still open.\n'); - throw err; - } - - if (answer.cancelled) { - console.log('\n Cancelled. Nothing was removed.\n'); - return; - } - - // A session is always open by the time this runs — it is what the command - // gated on — so this happens on every accepted submit. - let sessionCleared = false; - try { - deleteRecoverySession(); - sessionCleared = true; - } catch (err: any) { - // `deleteRecoverySession` sits outside the terminal path's best-effort - // try, so an EPERM there escapes as a raw error screen. Here the sweep - // has already been agreed to and the files still deserve their turn. - console.log(`\n Could not clear the recovery session: ${err?.message || err}`); - console.log(` It is at ${bold(getRecoverySessionPath())} — remove it by hand.`); - } - - const outcomes: RemovalOutcome[] = answer.remove.map(name => { - try { - unlinkSync(`${cwd}/${name}`); - console.log(` Removed ${name}`); - return { name, removed: true }; - } catch (err: any) { - return { name, removed: false, reason: err?.message || String(err) }; - } - }); - - const removed = outcomes.filter(o => o.removed).length; - const failed = outcomes.filter(o => !o.removed); - const kept = files.length - answer.remove.length; - - console.log(''); - if (sessionCleared) { - console.log(' ✓ Recovery session ended.'); - } - if (removed > 0) { - console.log(` ✓ Removed ${removed} decrypted file(s).`); - } - for (const f of failed) { - console.log(` ✗ Could not remove ${f.name} — it still holds readable secrets (${f.reason}).`); - } - if (kept > 0) { - console.log(` ${kept} file(s) kept on purpose. They stay in ${cwd} as readable plaintext.`); - } - console.log(''); - } -} - -/** - * What the session file can say about itself, offline. - * - * `end-recover` never authenticates — that is the point of it — so the - * organization's NAME is not knowable here. The id the session was written - * with is the honest thing to show; inventing a name would mean a network call - * in a command whose whole job is to work when nothing else does. - * - * Always a summary, never `undefined`. The caller has already established that - * a session exists, and a file that is present but unreadable is still a - * session to close — falling back to nothing there would have handed the - * browser path the one shape it refuses to serve. - * - * The session file also holds the cached master key. It is read here and only - * `org_id` and `created_at` are taken off it; the key never leaves this - * function and is never returned, printed or serialised. - */ -function readSessionSummary(): { orgName: string; startedAt: string } { - let session: { org_id?: string; created_at?: string } | null = null; - try { - session = readRecoverySession() as { org_id?: string; created_at?: string } | null; - } catch { - // An unreadable session file is still a session file. The sweep below is - // what removes it, so this must not become a reason to refuse the page. - } - return { - orgName: session?.org_id || 'unknown organization', - startedAt: session?.created_at ? formatRelativeTime(session.created_at) : 'unknown', - }; } diff --git a/src/commands/intakeSecurity.ts b/src/commands/intakeSecurity.ts deleted file mode 100644 index 4b86c57a..00000000 --- a/src/commands/intakeSecurity.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Loopback intake guards, isolated for unit testing. The intake server accepts a -// secret value from a local browser; these checks make that safe: a single-use -// constant-time nonce, plus Host/Origin pinning to the exact loopback address we -// bound (defends against DNS-rebinding from a malicious web page). -import { timingSafeEqual } from 'crypto'; - -export function nonceEqual(received: unknown, expected: string): boolean { - if (typeof received !== 'string' || received.length !== expected.length) return false; - try { - return timingSafeEqual(Buffer.from(received), Buffer.from(expected)); - } catch { - return false; - } -} - -export function isLoopbackHost(host: string | undefined, port: number): boolean { - return host === `127.0.0.1:${port}`; -} - -export function isAllowedOrigin(origin: string | undefined, port: number): boolean { - // Absent Origin (same-origin form submit/navigation) is allowed; if present it - // must be exactly our loopback origin. - return !origin || origin === `http://127.0.0.1:${port}`; -} diff --git a/src/commands/inviteCommand.ts b/src/commands/inviteCommand.ts index 171bb83c..a6d59a56 100644 --- a/src/commands/inviteCommand.ts +++ b/src/commands/inviteCommand.ts @@ -14,16 +14,12 @@ import { isInteractive, refuseNonInteractive } from '../ui/interactive'; import { excludeSystemProject } from '../system/reservedProjectName'; import { invitePlan, - unansweredInviteStops, - grantedProjects, parseTtl, formatTtl, - formatRelativeFuture, type InvitePlanInput, type SettledAnswer, } from '../core/invitePlan'; import type { InviteTeammateStop } from '../ui/screens/contract'; -import type { WebInviteParams } from '../ui/memberScreens'; const ROLES = [ { name: 'Member', value: 'member' }, @@ -44,22 +40,11 @@ export interface InviteOpts { json?: boolean; /** No prompts: resolve from flags or fail fast; also skips the clipboard prompt. */ nonTty?: boolean; - /** - * Render the questions as compiled screens in a local browser instead of - * inquirer, and hand the redeem code over in a page rather than on stdout. - * - * `--web` is a global option on the root program. `src/index.ts` does not - * read it for `invite` yet, so this path is live and tested but not reachable - * from argv until whoever owns that file threads `optsWithGlobals().web` - * through — the same seam `capy checkout` is waiting on. - */ - web?: boolean; } /** Parse "30s"/"10m"/"2h"/"12h" or bare seconds → ms. Exits on invalid input. */ function parseTtlMs(raw: string): number { - // The grammar lives in `invitePlan` so the flag and the browser's expiry step - // accept exactly the same lifetimes. Only the exit is this command's. + // The grammar lives in `invitePlan`. Only the exit is this command's. const ms = parseTtl(raw); if (ms === null) { console.error(`\n Invalid --ttl "${raw}". Use e.g. 30m, 2h, 12h, or a number of seconds (max 12h).\n`); @@ -71,13 +56,8 @@ function parseTtlMs(raw: string): number { /** * Resolve the invite's notAfter (ms epoch) from --expires / --ttl / env * default. Exits on invalid. - * - * `chosenTtl` is the lifetime the browser's expiry stop answered. It sits - * between the flags and the env default deliberately: an explicit `--expires` - * or `--ttl` on the command line still outranks a control the same run put on - * screen, which is §8.2's precedence and not a preference. */ -function resolveNotAfter(opts: InviteOpts, chosenTtl?: string): number { +function resolveNotAfter(opts: InviteOpts): number { if (opts.expires) { const t = Date.parse(opts.expires); if (Number.isNaN(t)) { @@ -91,7 +71,6 @@ function resolveNotAfter(opts: InviteOpts, chosenTtl?: string): number { return capAtCeiling(t, `--expires ${opts.expires}`); } if (opts.ttl) return capAtCeiling(Date.now() + parseTtlMs(opts.ttl), `--ttl ${opts.ttl}`); - if (chosenTtl) return capAtCeiling(Date.now() + parseTtlMs(chosenTtl), `an expiry of ${chosenTtl}`); return Date.now() + resolveInviteTtlMs(); } @@ -131,21 +110,13 @@ function settledExpiry(opts: InviteOpts): SettledAnswer | undefined { return undefined; } -/** - * Resolve `--project` tokens (id or name) to ids, cwd first. Exits on unknowns. - * - * Shared by the terminal path and the browser one, because `--project` settles - * the projects stop for both: the browser never serves a step a flag already - * answered, so the ids those tokens name have to come from somewhere other than - * an answer nobody was asked for. One resolution, one refusal. - */ +/** Resolve `--project` tokens (id or name) to ids, cwd first. Exits on unknowns. */ function resolveProjectTokens( tokens: string[], projects: Array<{ id: string; name: string }>, cwdProjectId: string | undefined, ): string[] { - const resolved: string[] = []; - for (const token of tokens) { + const ids = tokens.map((token) => { const match = projects.find((p) => p.id === token || p.name === token); if (!match) { console.error( @@ -153,9 +124,9 @@ function resolveProjectTokens( ); process.exit(1); } - if (!resolved.includes(match.id)) resolved.push(match.id); - } - return resolved.sort((a, b) => (a === cwdProjectId ? -1 : b === cwdProjectId ? 1 : 0)); + return match.id; + }); + return [...new Set(ids)].sort((a, b) => (a === cwdProjectId ? -1 : b === cwdProjectId ? 1 : 0)); } /** `CAPY_INVITE_TTL_SECONDS` in `--ttl`'s own vocabulary, when it is set. */ @@ -187,7 +158,7 @@ export class InviteCommand { async execute(email: string, opts: InviteOpts = {}): Promise { const interactive = isInteractive(opts.nonTty); try { - const { orgId, userId, userEmail, authService, serviceClient } = await resolveOrgContext(this.apiUrl, this.devMode); + const { orgId, userId, userEmail, serviceClient } = await resolveOrgContext(this.apiUrl, this.devMode); // Check if inviting yourself or an existing member if (userEmail && userEmail.toLowerCase() === email.toLowerCase()) { @@ -230,12 +201,10 @@ export class InviteCommand { ? (existingMember.projects || []).map((p) => p.id) : []; - // The whole route, declared before anything opens. Built from argv and + // The whole route, declared before anything is asked. Built from argv and // from the membership this address already has — the two things that can - // settle a question before it is asked — so the rail, the decision about - // whether to open a browser at all, and what `--json` prints all come off - // one call. `canAskExpiry` is `--web`: `resolveNotAfter` never prompts, so - // a terminal run's expiry is settled before the command starts. + // settle a question before it is asked. `resolveNotAfter` never prompts, so + // a run's expiry is settled before the command starts. const inheritedRole = existingMember && !opts.role ? existingMember.role : undefined; const inheritedProjectNames = existingMember && !opts.role ? (existingMember.projects || []).map((p) => p.name) : []; @@ -250,28 +219,12 @@ export class InviteCommand { expiry: settledExpiry(opts), envTtl: envTtl(), defaultTtl: envTtl() ?? '12h', - canAskExpiry: opts.web === true, }; - const plan = invitePlan(planInput); - /** Everything the browser needs, gathered once so both pages share it. */ - const webParams = opts.web - ? await this.gatherWebParams( - email, orgId, me, invitable, existingMember, planInput, authService, serviceClient, userEmail, - ) - : undefined; - - const resolution = await this.resolveInviteRoleAndProjects({ + const { role, projectId, extraProjectIds, roleSource, projectSource } = await this.resolveInviteRoleAndProjects({ email, opts, me, invitable, existingMember, reissuing, existingProjectIds, - webParams, plan, planInput, serviceClient, interactive, + planInput, serviceClient, interactive, }); - // Cancelling is a refusal: nothing was minted and nothing below may - // run, because everything below hands somebody a copy of the org key. - if (resolution.cancelled) { - console.log('\n No invite created.\n'); - return; - } - const { role, projectId, extraProjectIds, roleSource, projectSource, chosenTtl } = resolution; // 1. Generate invite token T const inviteToken = generateInviteToken(); @@ -282,7 +235,7 @@ export class InviteCommand { // 3. Service outer wraps (KMS layer), bound to (orgId, notAfter) so // the redeem code can't outlive its window even if forwarded. - const notAfter = resolveNotAfter(opts, chosenTtl); + const notAfter = resolveNotAfter(opts); const { ciphertext: outerBlob } = await serviceClient.wrapOuterLayer( orgId, Buffer.from(innerBlob, 'base64').toString('base64'), @@ -303,13 +256,10 @@ export class InviteCommand { const roleName = ROLES.find(r => r.value === role)?.name ?? role; const redeemCommand = `capy redeem ${redeemCode}`; const grantedProjectIds = [projectId, ...extraProjectIds].filter(Boolean) as string[]; - // Ids for the service, names for the page. The id is the fallback rather - // than a blank: a project this run granted and could not name is still a - // project this run granted, and the page has to say so. - const grantedProjectRefs = grantedProjectIds.map((id) => ({ - id, - name: webParams?.projects.find((x) => x.id === id)?.name ?? id, - })); + // Ids for the service; the id also stands in as the name, because a + // project this run granted and could not name is still a project this run + // granted. + const grantedProjectRefs = grantedProjectIds.map((id) => ({ id, name: id })); // What the fan-out actually landed. A stop is a claim about what this run // DID, so a project the service refused cannot be listed as one this // invite granted — that is the exact failure the markers exist to @@ -320,17 +270,9 @@ export class InviteCommand { ); // The route as it ended up: the same builder, fed what actually settled - // each stop. `--json` and the browser payload cannot describe different - // runs, because neither of them builds a rail of its own. - // - // `canAskExpiry` goes false here — this rail describes a FINISHED run and - // nothing on a finished run is still outstanding. Left true, a re-issue - // that never opened a browser (existing member, no `--role`) reports - // `expiry · current` on a run that already minted the code, which is a - // stop whose state does not describe what the run did. + // each stop. const finalStops: InviteTeammateStop[] = invitePlan({ ...planInput, - canAskExpiry: false, role: { value: role, flag: roleSource }, projects: assignedProjectRefs.length > 0 ? { @@ -345,7 +287,6 @@ export class InviteCommand { : {}), } : undefined, - expiry: planInput.expiry ?? (chosenTtl ? { value: chosenTtl } : undefined), }); // Machine-readable path for agents/CI: emit JSON to stdout and skip the @@ -373,55 +314,14 @@ export class InviteCommand { } console.log(''); - if (webParams) { - // The redeem code carries a double-wrapped copy of the organization - // key, unwrappable only by the invited email. `--web` is - // agent-only, and an agent shelling `capy` reads stdout, so under it the - // code goes to a page and NOWHERE else: not printed, not logged, not - // copied to the clipboard. The page it lands on is served with the - // display-only CSP (`connect-src 'none'`), so the one document in this - // flow holding key material is the one with no way to open a socket. - const { serveInviteCode } = await import('../ui/memberScreens'); - const page = await serveInviteCode( - webParams, - { - redeemCommand, - expiresAtIso: new Date(notAfter).toISOString(), - expiresRelative: formatRelativeFuture(notAfter), - role, - reissued: reissuing, - // What landed, not what was asked for. The failures travel in their - // own field right below, and a project that appears in both is a - // page contradicting itself about what this invite reaches. - grantedProjects: assignedProjectRefs, - assignmentFailures: failures.map((f) => ({ - project: { - id: f.projectId, - name: webParams!.projects.find((x) => x.id === f.projectId)?.name ?? f.projectId, - }, - error: f.error, - })), - }, - { role, projectIds: assignedProjectRefs.map((p) => p.id), ttl: chosenTtl }, - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI / - // headless verification drive the loopback without hijacking one. - { open: !process.env.CAPY_WEB_NO_OPEN }, - ); - console.log(' The redeem code is on this page — it is deliberately not printed here:'); - console.log(` ${page.url}`); - console.log(''); - console.log(` \x1b[90mExpires ${new Date(notAfter).toISOString()}.\x1b[0m`); - console.log(''); - } else { - console.log(' Send them this command:'); - console.log(''); - console.log(` ${B('capy')} redeem ${redeemCode}`); - console.log(''); - console.log(' \x1b[90mThis code is safe to share with your team member over email or your\x1b[0m'); - console.log(' \x1b[90mteam messaging app. It can only be used by them.\x1b[0m'); - console.log(` \x1b[90mExpires ${new Date(notAfter).toISOString()}.\x1b[0m`); - console.log(''); - } + console.log(' Send them this command:'); + console.log(''); + console.log(` ${B('capy')} redeem ${redeemCode}`); + console.log(''); + console.log(' \x1b[90mThis code is safe to share with your team member over email or your\x1b[0m'); + console.log(' \x1b[90mteam messaging app. It can only be used by them.\x1b[0m'); + console.log(` \x1b[90mExpires ${new Date(notAfter).toISOString()}.\x1b[0m`); + console.log(''); if (failures.length > 0) { console.log(` \x1b[33m${failures.length} additional project assignment${failures.length === 1 ? '' : 's'} failed:\x1b[0m`); @@ -431,10 +331,8 @@ export class InviteCommand { console.log(''); } - // The clipboard prompt is interactive — skip it under --non-tty/piped, - // and under `--web`, where the terminal never held the code to begin with - // and the page has its own copy control. - if (interactive && !webParams) { + // The clipboard prompt is interactive — skip it under --non-tty/piped. + if (interactive) { const { promptCopyToClipboard } = await import('../ui/clipboard'); await promptCopyToClipboard(redeemCommand); } @@ -489,26 +387,19 @@ export class InviteCommand { existingMember: { role: string; status: string; projects?: Array<{ id: string; name: string }> } | undefined; reissuing: boolean; existingProjectIds: string[]; - webParams: WebInviteParams | undefined; - plan: InviteTeammateStop[]; planInput: InvitePlanInput; serviceClient: { listProjects: () => Promise> }; interactive: boolean; - }): Promise< - | { cancelled: true } - | { - cancelled: false; - role: string; - projectId: string | undefined; - extraProjectIds: string[]; - roleSource: string | undefined; - projectSource: string | undefined; - chosenTtl: string | undefined; - } - > { + }): Promise<{ + role: string; + projectId: string | undefined; + extraProjectIds: string[]; + roleSource: string | undefined; + projectSource: string | undefined; + }> { const { email, opts, me, invitable, existingMember, reissuing, existingProjectIds, - webParams, plan, planInput, serviceClient, interactive, + planInput, serviceClient, interactive, } = ctx; // Pure re-issue (existing member, no explicit --role): reuse their current @@ -518,54 +409,11 @@ export class InviteCommand { // role instead of silently keeping the stale one. if (existingMember && !opts.role) { return { - cancelled: false, role: existingMember.role, projectId: existingProjectIds[0], extraProjectIds: existingProjectIds.slice(1), roleSource: 'existing membership', projectSource: 'existing membership', - chosenTtl: undefined, - }; - } - - if (webParams && unansweredInviteStops(plan).length > 0) { - // `--role` is validated first either way: a role this caller cannot - // grant is refused before a browser opens, not after somebody answers - // two more questions on top of it. - if (opts.role && !invitable.includes(opts.role as typeof ROLES[number]['value'])) { - console.error( - `\n Your role (${me.role}) can't grant "${opts.role}". Allowed: ${invitable.join(', ')}.\n`, - ); - process.exit(1); - } - // `--project` settles the projects stop, so the browser never serves - // it — and an unknown token is refused here, before a browser opens, - // rather than after somebody has answered two questions on top of it. - const flagProjectIds = - opts.projects && opts.projects.length > 0 - ? resolveProjectTokens( - opts.projects, - webParams.projects, - webParams.projects.find((p) => p.isCwd)?.id, - ) - : []; - - const { askInviteInBrowser } = await import('../ui/memberScreens'); - const answered = await askInviteInBrowser(webParams); - if (answered.cancelled) { - return { cancelled: true }; - } - const ids = grantedProjects(answered.role, answered.projectIds, flagProjectIds); - return { - cancelled: false, - role: answered.role, - projectId: ids[0], - extraProjectIds: ids.slice(1), - chosenTtl: answered.ttl, - // A stop a flag settled is never served, so anything the browser did - // NOT answer keeps the marker argv gave it. - roleSource: opts.role ? `--role ${opts.role}` : undefined, - projectSource: answered.projectIds.length > 0 ? undefined : planInput.projects?.flag, }; } @@ -574,14 +422,14 @@ export class InviteCommand { // Project scope is only required for project-admin and member. if (role !== 'project-admin' && role !== 'member') { return { - cancelled: false, role, projectId: undefined, extraProjectIds: [], roleSource, projectSource: undefined, chosenTtl: undefined, + role, projectId: undefined, extraProjectIds: [], roleSource, projectSource: undefined, }; } const { projectId, extraProjectIds, projectSource } = await this.resolveInviteeProjects( role, opts, serviceClient, interactive, reissuing, existingProjectIds, planInput, ); - return { cancelled: false, role, projectId, extraProjectIds, roleSource, projectSource, chosenTtl: undefined }; + return { role, projectId, extraProjectIds, roleSource, projectSource }; } /** Asks (flag, non-interactive default, or inquirer) which role to grant. Exits on an ungrantable --role. */ @@ -635,8 +483,6 @@ export class InviteCommand { // The cwd project sorts first (it's the most likely intent) and is // the non-interactive default when --project is omitted. const cwdProjectId = await this.resolveCwdProjectId(projects); - const cwdFirst = (a: T, b: T) => - a.id === cwdProjectId ? -1 : b.id === cwdProjectId ? 1 : 0; if (opts.projects && opts.projects.length > 0) { const resolved = resolveProjectTokens(opts.projects, projects, cwdProjectId); @@ -659,22 +505,20 @@ export class InviteCommand { ); } - const { CHECKBOX_INSTRUCTIONS, CHECKBOX_THEME } = await import('../ui/promptStyle'); - const ordered = [...projects].sort(cwdFirst); - const { chosenProjectIds } = await inquirer.prompt<{ chosenProjectIds: string[] }>({ - type: 'checkbox', - name: 'chosenProjectIds', + const { SEARCHABLE_CHECKBOX_INSTRUCTIONS, CHECKBOX_THEME } = await import('../ui/promptStyle'); + const { searchableCheckbox } = await import('../ui/searchableCheckbox'); + const ordered = [...projects.filter((p) => p.id === cwdProjectId), ...projects.filter((p) => p.id !== cwdProjectId)]; + const ids = await searchableCheckbox({ message: `Grant ${role === 'project-admin' ? 'Project Admin' : 'Member'} access to which projects?`, - instructions: CHECKBOX_INSTRUCTIONS, + instructions: SEARCHABLE_CHECKBOX_INSTRUCTIONS, theme: CHECKBOX_THEME, choices: ordered.map((p) => ({ name: p.name, value: p.id, checked: p.id === cwdProjectId, })), - validate: (v: ReadonlyArray) => v.length > 0 || 'Pick at least one project', - } as any); - const ids: string[] = chosenProjectIds; + validate: (v) => v.length > 0 || 'Pick at least one project', + }); return { projectId: ids[0], extraProjectIds: ids.slice(1), projectSource: undefined }; } @@ -704,93 +548,4 @@ export class InviteCommand { const restFailures = await this.fanOutExtraProjectInvites(orgId, email, role, rest, serviceClient); return firstFailure ? [firstFailure, ...restFailures] : restFailures; } - - /** The org's display name, re-asked from the cached session — falls back to `orgId` on any failure. */ - private async resolveOrgName( - authService: { authenticateSilent: (orgId?: string) => Promise<{ organization_name?: string }> }, - orgId: string, - ): Promise { - try { - const again = await authService.authenticateSilent(orgId); - return again.organization_name || orgId; - } catch { - // ignore — the id still identifies the organization unambiguously - return orgId; - } - } - - /** - * Everything the two browser pages need, gathered once. - * - * Both the question wizard and the code page render the same screen with the - * same payload shape, so building it twice would be two chances for the run a - * person answered and the run they are handed a key for to disagree. - * - * The project list is fetched here even when the role is already settled and - * needs no projects: the code page names the projects an invite granted, and - * `createInvite` takes ids while a person reads names. - */ - private async gatherWebParams( - email: string, - orgId: string, - me: { role: string }, - invitable: ReadonlyArray, - existingMember: { role: string; status: string; projects?: Array<{ id: string; name: string }> } | undefined, - planInput: InvitePlanInput, - authService: { authenticateSilent: (orgId?: string) => Promise<{ organization_name?: string }> }, - serviceClient: { listProjects: () => Promise> }, - callerEmail: string | undefined, - ): Promise { - const projects = excludeSystemProject(await serviceClient.listProjects()); - - // The cwd project sorts first and is ticked by default — the same order and - // the same default the terminal checkbox uses. The screen keeps both and - // drops the silence: the row says where the tick came from. - const cwdProjectId = await this.resolveCwdProjectId(projects); - const ordered = [...projects].sort((a, b) => - a.id === cwdProjectId ? -1 : b.id === cwdProjectId ? 1 : 0, - ); - - // `resolveOrgContext` drops the organization name on the way out, and a - // page headed by a UUID is a page that cannot tell you it opened on the - // wrong organization. Re-asking the cached session for it costs nothing — - // a live token short-circuits before any request — and the id is the - // fallback rather than a blank. - const orgName = await this.resolveOrgName(authService, orgId); - - return { - // What the CODE is bound to, not what argv typed. `innerWrap` derives the - // inner key from `${orgId}:${email.toLowerCase()}`, so the lowercased - // address is the one that decides whether this invite can ever be - // redeemed, and it is the one the page names. Argv travels beside it: the - // screen draws "The address was cleaned up" when the two differ, which is - // the only warning anybody gets that `capy invite Bob@X.com` mints a code - // bound to something they did not type. - // - // Lowercased and NOT trimmed, because the CLI lowercases and does not - // trim. A page that showed a trimmed address would be claiming a binding - // the code does not have — and ` bob@x.com` really does mint a code - // nobody can redeem. Fixing THAT is a change to what the command mints, - // for every path and not only this one, so it is reported rather than - // smuggled in behind a flag that is supposed to change rendering only. - email: email.toLowerCase(), - rawEmail: email, - orgName, - callerEmail: callerEmail ?? '', - callerRole: me.role, - grantableRoles: [...invitable], - projects: ordered.map((p) => ({ id: p.id, name: p.name, isCwd: p.id === cwdProjectId })), - ...(existingMember - ? { - existing: { - role: existingMember.role, - status: existingMember.status, - projects: (existingMember.projects || []).map((p) => ({ id: p.id, name: p.name })), - }, - } - : {}), - plan: planInput, - open: !process.env.CAPY_WEB_NO_OPEN, - }; - } } diff --git a/src/commands/keepLockPr.ts b/src/commands/keepLockPr.ts index 6f04c25f..d6de23b6 100644 --- a/src/commands/keepLockPr.ts +++ b/src/commands/keepLockPr.ts @@ -1,5 +1,5 @@ /** - * The keep.lock PR step: the one place `capy add`, `capy edit` (TUI, `--web` + * The keep.lock PR step: the one place `capy add`, `capy edit` (TUI * and piped) and `capy remove` offer to turn a keep.lock change into a PR. * * It is built on the GitHub API through the user's own `gh` login diff --git a/src/commands/kickCommand.ts b/src/commands/kickCommand.ts index 3fdb6866..c1916dc3 100644 --- a/src/commands/kickCommand.ts +++ b/src/commands/kickCommand.ts @@ -1,16 +1,22 @@ import { resolveOrgContext } from '../core/orgContext'; - -export interface KickOpts { - /** - * Render the confirmation as a compiled screen in a local browser instead of - * inquirer's one-line confirm. - * - * `--web` is a global option on the root program. `src/index.ts` does not - * read it for `kick` yet, so this path is live and tested but not reachable - * from argv until whoever owns that file threads `optsWithGlobals().web` - * through — the same seam `capy checkout` is waiting on. - */ - web?: boolean; +import type { ServiceClient } from '../service/serviceClient'; + +/** The membership id for `email`, or exits 1 when there is none or the list cannot be read. */ +async function findMembershipId(serviceClient: ServiceClient, orgId: string, email: string): Promise { + try { + const { members } = await serviceClient.listMemberDetails(orgId); + const match = members.find(m => + m.email.toLowerCase() === email.toLowerCase() + ); + if (!match) { + console.error(`No member found matching "${email}".`); + process.exit(1); + } + return match.membershipId; + } catch (err: any) { + console.error(`Failed to list members: ${err.message}`); + process.exit(1); + } } export class KickCommand { @@ -22,102 +28,19 @@ export class KickCommand { this.devMode = devMode; } - async execute(email: string, opts: KickOpts = {}): Promise { - const { orgId, authService, serviceClient } = await resolveOrgContext(this.apiUrl, this.devMode); + async execute(email: string): Promise { + const { orgId, serviceClient } = await resolveOrgContext(this.apiUrl, this.devMode); // Find the membership by email - let membershipId: string; - let member: Awaited>['members'][number]; - let callerRole = 'member'; - let currentUserId = ''; - let orgName = orgId; - try { - const { members } = await serviceClient.listMemberDetails(orgId); - const match = members.find(m => - m.email.toLowerCase() === email.toLowerCase() - ); - if (!match) { - console.error(`No member found matching "${email}".`); - process.exit(1); - } - membershipId = match.membershipId; - member = match; - - // Only the browser path needs to know who is asking — the screen marks - // the caller's own row, and removing yourself is the one removal that - // takes your access away rather than somebody else's. A failure here - // must not stop a removal the terminal would have performed, so it is - // best-effort and the fallbacks are the honest unknowns. - if (opts.web) { - try { - const me = await serviceClient.getOrgMe(orgId); - callerRole = me.role; - currentUserId = me.user_id; - } catch { - /* leave the defaults; the server remains the authority on both */ - } - // `resolveOrgContext` drops the organization name on the way out, and a - // removal confirm headed by a UUID cannot tell you it opened on the - // wrong organization. Re-asking the cached session costs nothing — a - // live token short-circuits before any request. - try { - const again = await authService.authenticateSilent(orgId); - if (again.organization_name) orgName = again.organization_name; - } catch { - /* the id still identifies the organization unambiguously */ - } - } - } catch (err: any) { - console.error(`Failed to list members: ${err.message}`); - process.exit(1); - } - - // Confirm. - // - // The terminal asks `Remove from this organization? They will lose - // access to all secrets.` — one line, defaulting to No, with the whole - // consequence folded into the question. Under `--web` it is the - // `org-members` screen's `confirm-remove` view, where what they lose and - // what removal does NOT reach (their laptop still holds the key) are two - // callouts ABOVE the button, and the button stays held down until the - // address is typed back. A destructive action's consequence never goes - // inside the control that performs it. - let confirm: boolean; - if (opts.web) { - const { confirmKickInBrowser } = await import('../ui/memberScreens'); - try { - confirm = await confirmKickInBrowser({ - orgName, - callerRole, - currentUserId, - member: { - membershipId: member.membershipId, - userId: member.userId, - email: member.email, - role: member.role, - status: member.status, - createdAt: member.createdAt, - projects: (member.projects || []).map(p => ({ id: p.id, name: p.name, role: p.role })), - }, - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI / - // headless verification drive the loopback without hijacking one. - open: !process.env.CAPY_WEB_NO_OPEN, - }); - } catch { - // Closed, timed out, or interrupted. None of those is a yes, and the - // one thing this flow must never do is read a window nobody answered as - // agreement to cut somebody off from every secret in the organization. - confirm = false; - } - } else { - const inquirer = (await import('inquirer')).default; - ({ confirm } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - message: `Remove ${email} from this organization? They will lose access to all secrets.`, - default: false, - }])); - } + const membershipId = await findMembershipId(serviceClient, orgId, email); + + const inquirer = (await import('inquirer')).default; + const { confirm } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: `Remove ${email} from this organization? They will lose access to all secrets.`, + default: false, + }]); if (!confirm) { console.log('Cancelled.'); diff --git a/src/commands/orgCommand.ts b/src/commands/orgCommand.ts index 730ee980..4d1e738a 100644 --- a/src/commands/orgCommand.ts +++ b/src/commands/orgCommand.ts @@ -10,6 +10,7 @@ import { createNewOrganization } from './orgCreation'; import { excludeSystemProject, assertProjectNameAllowed, isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../system/reservedProjectName'; import { execSync } from 'child_process'; import { ACCENT } from '../ui/colors'; +import { orgProjectQuestion } from '../ui/projectQuestions'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -29,47 +30,20 @@ async function resolveAuthResultWithFallback(authService: AuthService, currentOr return authService.authenticate(currentOrgId); } -/** - * The branch a first project is bootstrapped with. - * - * Named rather than inline so the browser can state it before the project is - * created. The terminal only mentions it as a spinner line that has already - * scrolled past by the time anyone looks. - */ +/** The branch a first project is bootstrapped with. */ const FIRST_BRANCH = 'development'; -export interface OrgCommandOptions { - /** - * Serve the three questions as browser screens instead of inquirer prompts. - * - * Agent-only, and NOT REACHABLE FROM ARGV YET. `src/index.ts` declares - * `--web` once, on the root program; a subcommand has to read the inherited - * global for itself, which `byoc` does in two lines: - * - * const web = command.optsWithGlobals().web === true; - * - * `org`'s own action takes no `command` argument and never calls - * `optsWithGlobals`, so `new OrgCommand()` is always built without this. The - * flow below is live and browser-tested; the missing hop is argv wiring, - * which the coordinator owns centrally along with the rest of the command - * registry — this parcel may not edit index.ts. - */ - web?: boolean; -} - export class OrgCommand { private projectManager: ProjectManager; private fileManager: FileManager; private authService: AuthService; private serviceClient: ServiceClient; - private web: boolean; - constructor(apiUrl?: string, devMode: boolean = false, options: OrgCommandOptions = {}) { + constructor(apiUrl?: string, devMode: boolean = false) { this.projectManager = new ProjectManager(); this.fileManager = new FileManager(); this.authService = new AuthService(apiUrl, devMode); this.serviceClient = new ServiceClient(apiUrl, devMode); - this.web = options.web === true; this.serviceClient.setTokenProvider(() => this.authService.getValidToken()); } @@ -104,14 +78,6 @@ export class OrgCommand { const currentOrg = currentOrgId ? orgs.find(o => o.id === currentOrgId) : undefined; const CREATE_NEW_ORG = '__create_new__'; - if (this.web) { - // No TTY under --web (this is driven through the MCP): an inquirer list - // here would hang forever with nothing on screen and no URL to hand - // anybody. Same three questions, same order, served as screens. - await this._executeWeb(authResult, orgs, currentOrgId, hasProject); - return; - } - console.log(''); const { orgId } = await inquirer.prompt([{ type: 'list', @@ -207,15 +173,7 @@ export class OrgCommand { } // Let user pick a project - const { projectId } = await inquirer.prompt([{ - type: 'list', - name: 'projectId', - message: 'Select project:', - choices: orgProjects.map(p => ({ - name: p.name, - value: p.id, - })), - }]); + const { projectId } = await inquirer.prompt([orgProjectQuestion(orgProjects)]); const selectedProject = orgProjects.find(p => p.id === projectId)!; this.bindToProject(selectedOrg, selectedProject, authResult.user_id, hasProject); @@ -255,140 +213,6 @@ export class OrgCommand { } } - /** - * The same three questions, served as screens. - * - * The order is the terminal's — organization, then project — and so is - * everything between them: the session is re-scoped, the device's key for the - * org is checked, the project list is fetched. Two things the terminal cannot - * do come for free from doing that work where the picker can hear the answer: - * an org this device holds no key for is refused in its own row rather than - * after a switch the CLI has already announced, and a failed re-scope leaves - * the list on screen with the reason attached instead of ending the run. - */ - private async _executeWeb( - authResult: AuthResult, - orgs: Organization[], - currentOrgId: string | undefined, - hasProject: boolean, - ): Promise { - const userId = authResult.user_id!; - const refreshToken = authResult._refresh_token || this.authService.getToken()?.refresh_token; - if (!refreshToken) { - console.error('No refresh token available. Run `capy` to re-authenticate.'); - process.exit(1); - } - - const { switchOrganizationInBrowser, nameFirstProjectInBrowser } = await import('../ui/selectWeb'); - - const facts = { - signedInAs: authResult.user_email, - currentOrgId, - // The one fact the terminal picker does not carry. `hasOrgKey` is what - // the CLI checks AFTER announcing the switch; shipping it with the list - // is what lets the screen refuse the row instead. - orgs: orgs.map(o => ({ id: o.id, name: o.name, hasLocalKey: hasOrgKey(o.id, userId) })), - hasKeepLock: hasProject, - defaultProjectName: this.projectManager.getDefaultProjectName(), - firstBranchName: FIRST_BRANCH, - }; - - const picked = await switchOrganizationInBrowser({ - ...facts, - onOrgChosen: async (orgId: string) => { - const org = orgs.find(o => o.id === orgId)!; - const scopedAuth = await this.authService.refreshWithCredentials( - refreshToken, - org.id, - userId, - ); - if (!scopedAuth.success) { - return { ok: false as const, reason: scopedAuth.error || 'Organization switch failed' }; - } - const projects = excludeSystemProject(await this.serviceClient.listProjects()); - const orgProjects = projects.filter(p => p.organization_id === org.id); - if (orgProjects.length === 0) { - const refusal = this.firstProjectRefusal(org, hasProject); - if (refusal) return { ok: false as const, reason: refusal }; - } - return { - ok: true as const, - projects: orgProjects.map(p => ({ id: p.id, name: p.name })), - }; - }, - open: !process.env.CAPY_WEB_NO_OPEN, - }); - - if (picked.action === 'cancel') { - console.log('\n Switch cancelled.\n'); - return; - } - - if (picked.action === 'create') { - const created = await createNewOrganization( - this.authService, - this.serviceClient, - refreshToken, - userId, - true, - ); - const scopedAuth = await this.authService.refreshWithCredentials( - refreshToken, - created.id, - userId, - ); - if (!scopedAuth.success) { - throw new CapyError( - scopedAuth.error || 'Organization switch failed', - ERROR_CODES.AUTH_FAILED, - ); - } - // A brand-new org has no projects, so the only route on is the first one. - console.log(`\n ${B(created.name)} has no projects yet.`); - const refusal = this.firstProjectRefusal(created, hasProject); - if (refusal) throw new CapyError(refusal, ERROR_CODES.INVALID_FORMAT); - const name = await nameFirstProjectInBrowser({ - ...facts, - orgs: [{ id: created.id, name: created.name, hasLocalKey: true }], - currentOrgId: undefined, - orgId: created.id, - open: !process.env.CAPY_WEB_NO_OPEN, - }); - if (name === null) { - console.log(`\n Switch cancelled. Run ${B('capy')} in a fresh directory to create a project in ${B(created.name)}.\n`); - return; - } - await this.bootstrapFirstProject(created, userId, name); - return; - } - - // `picked` here is 'select-project' or 'create-project' (the 'cancel' and - // 'create' actions already returned above), and both carry the id of - // whichever org `onOrgChosen` last switched into successfully. - const selectedOrg = orgs.find(o => o.id === picked.orgId)!; - if (!hasOrgKey(selectedOrg.id, userId)) { - // Unreachable through the screen, which disables a row with no key — - // and still checked, because the throw is what stops a switch this - // device cannot decrypt anything in. - throw new CapyError( - `You have access to "${selectedOrg.name}" but no encryption key on this device.\n\n` + - ' Ask your org owner for an invite code, then run:\n\n' + - ' capy redeem \n\n' + - ' This will securely transfer the shared encryption key to your device.', - ERROR_CODES.AUTH_FAILED, - ); - } - - if (picked.action === 'create-project') { - await this.bootstrapFirstProject(selectedOrg, userId, picked.projectName); - return; - } - - const projects = excludeSystemProject(await this.serviceClient.listProjects()); - const selectedProject = projects.find(p => p.id === picked.projectId)!; - this.bindToProject(selectedOrg, selectedProject, userId, hasProject); - } - private keyServiceOps(): KeyServiceOps { return { coDecrypt: (orgId, ciphertext) => @@ -445,12 +269,9 @@ export class OrgCommand { /** * Why this directory cannot take the first project of another org, or null. * - * A sentence rather than a throw so both surfaces can use it: the terminal - * raises it as a CapyError and ends the run, and the browser hands it back as - * a refusal on the organization list, where the user still has other rows to - * pick. The condition is the same one either way — a .env holding values - * encrypted for the project this directory is currently bound to, which - * rebinding keep.lock would orphan. + * A sentence rather than a throw; the caller raises it as a CapyError. The + * condition is a .env holding values encrypted for the project this + * directory is currently bound to, which rebinding keep.lock would orphan. */ private firstProjectRefusal(selectedOrg: Organization, hasProject: boolean): string | null { if (!hasProject) return null; @@ -471,9 +292,7 @@ export class OrgCommand { userId: string, projectName: string, ): Promise { - // Choke point for both callers (terminal prompt above and the browser - // wizard's create-project path, which has no synchronous validate hook of - // its own) — the reserved name must never reach the service either way. + // Choke point: the reserved name must never reach the service. assertProjectNameAllowed(projectName.trim()); const initSpinner = ora('Creating project...').start(); diff --git a/src/commands/orgCreation.ts b/src/commands/orgCreation.ts index ccd58a4a..f0d27f1d 100644 --- a/src/commands/orgCreation.ts +++ b/src/commands/orgCreation.ts @@ -10,15 +10,13 @@ import { } from '../crypto/keyManager'; import { wrapAndSaveMasterKey, KeyServiceOps } from '../crypto/keyResolver'; import { displayAndConfirmRecoveryPhrase } from '../ui/recoveryPhrase'; -// Type-only: erased at compile time, so the TTY path does not pull the browser -// wizard into its module graph just to name an organization. -import type { OrgNameVerdict } from '../ui/onboardingWeb'; - -/** The CLI's cap. One definition; the browser screen is handed this value. */ +/** The CLI's cap. */ export const MAX_ORG_NAME_LENGTH = 100; -// (recovery-phrase display + confirm lives in ../ui/recoveryPhrase; the browser -// half of both questions lives in ../ui/onboardingWeb) +/** Whether an organization name is free. A verdict, never a sentence to be parsed. */ +type OrgNameVerdict = 'available' | 'taken' | 'unreachable'; + +// (recovery-phrase display + confirm lives in ../ui/recoveryPhrase) function keyServiceOpsFromClient(serviceClient: ServiceClient): KeyServiceOps { return { @@ -74,16 +72,7 @@ export async function promptForAvailableOrgName( return orgName.trim(); } -/** - * The zero-trust warning the CLI prints beside the phrase. - * - * Split rather than one array so the browser can turn the last two lines into - * the anchor the screen renders ("Learn more about zero-trust") instead of - * leaving a bare URL at the foot of the most security-sensitive page in the - * product. The terminal still gets the block it always printed, byte for byte — - * built by concatenation here rather than recovered by matching prose, because - * a sentence is not a place to keep a structural fact. - */ +/** The zero-trust warning the CLI prints beside the phrase. */ export const ZERO_TRUST_URL = 'https://capy.sc/zero-trust'; export const ORG_PHRASE_NOTES = [ @@ -106,7 +95,6 @@ export async function createNewOrganization( serviceClient: ServiceClient, refreshToken: string, userId: string, - web = false, ): Promise { // ONE phrase for the whole run, generated before the first question. A 409 // sends the name step round again and the same words have to key whatever @@ -114,79 +102,46 @@ export async function createNewOrganization( // after they had already written the first one down. const seedPhrase = generateSeedPhrase(); - let orgName: string; - if (web) { - // SECURITY: under --web the phrase must render in the browser only. The TTY - // path prints all 24 words to stdout, which an MCP-driven run captures — so - // the agent would see a recovery-equivalent secret. Name and phrase are one - // wizard in the loopback page; the phrase stays in this process's memory. - orgName = await nameAndConfirmInBrowser(authService, seedPhrase); - } else { - orgName = await promptForAvailableOrgName(authService); - await displayAndConfirmRecoveryPhrase(seedPhrase, ORG_PHRASE_BOX); - } + const orgName = await promptForAvailableOrgName(authService); + await displayAndConfirmRecoveryPhrase(seedPhrase, ORG_PHRASE_BOX); + + return createOrganizationNamed({ authService, serviceClient, refreshToken, userId, seedPhrase }, orgName); +} - while (true) { - const orgSpinner = ora('Creating organization...').start(); - try { - const org = await authService.createOrganization(orgName, refreshToken, userId); - orgSpinner.succeed(`Organization "${org.name}" created`); +/** Creates the org; on a 409 asks for another name and goes round again with the same phrase. */ +async function createOrganizationNamed( + ctx: { + authService: AuthService; + serviceClient: ServiceClient; + refreshToken: string; + userId: string; + seedPhrase: string; + }, + orgName: string, +): Promise { + const { authService, serviceClient, refreshToken, userId, seedPhrase } = ctx; + const orgSpinner = ora('Creating organization...').start(); + try { + const org = await authService.createOrganization(orgName, refreshToken, userId); + orgSpinner.succeed(`Organization "${org.name}" created`); - // New orgs derive M under the current (strongest) KDF version. This is - // what binds the org to v2; legacy orgs created before this stay on v1 and - // are detected by trial decryption at the phrase→M boundaries. - const masterKey = seedPhraseToMasterKey(seedPhrase, CURRENT_KDF_VERSION); - await wrapAndSaveMasterKey(masterKey, org.id, userId, keyServiceOpsFromClient(serviceClient)); + // New orgs derive M under the current (strongest) KDF version. This is + // what binds the org to v2; legacy orgs created before this stay on v1 and + // are detected by trial decryption at the phrase→M boundaries. + const masterKey = seedPhraseToMasterKey(seedPhrase, CURRENT_KDF_VERSION); + await wrapAndSaveMasterKey(masterKey, org.id, userId, keyServiceOpsFromClient(serviceClient)); - return org; - } catch (err: any) { - orgSpinner.fail('Failed to create organization'); - if (err && err.status === 409) { - console.log(''); - orgName = web - ? await nameAndConfirmInBrowser(authService, seedPhrase, orgName) - : await promptForAvailableOrgName(authService, 'That name was claimed while you were setting up. Pick another:'); - continue; - } - throw err; + return org; + } catch (err: any) { + orgSpinner.fail('Failed to create organization'); + if (err && err.status === 409) { + console.log(''); + const nextName = await promptForAvailableOrgName( + authService, + 'That name was claimed while you were setting up. Pick another:', + ); + return createOrganizationNamed(ctx, nextName); } + throw err; } } - -/** - * The browser half: name the organization and write down its recovery phrase, - * in one wizard with one rail. - * - * `raced` is the name the server just refused with a 409, so the retry opens on - * the name step with that name in the field and the reason attached — the same - * phrase, already written down, carries over untouched. - */ -async function nameAndConfirmInBrowser( - authService: AuthService, - seedPhrase: string, - raced?: string, -): Promise { - const { createOrganizationInBrowser } = await import('../ui/onboardingWeb'); - const result = await createOrganizationInBrowser({ - phrase: seedPhrase, - bodyLines: ORG_PHRASE_NOTES, - learnMoreUrl: ZERO_TRUST_URL, - name: raced, - nameError: raced ? 'RACE_409' : undefined, - // A retry asks for the name and nothing else. The phrase is unchanged and - // already written down; showing it again would make "this is the only time - // it is shown" false the first time anybody read it. - nameOnly: raced !== undefined, - maxNameLength: MAX_ORG_NAME_LENGTH, - checkName: (name: string) => checkOrgNameAvailable(authService, name), - open: !process.env.CAPY_WEB_NO_OPEN, - }); - if (result.cancelled) { - throw new Error( - raced - ? 'Naming cancelled — organization not created' - : 'Recovery phrase not confirmed — organization not created', - ); - } - return result.name; -} diff --git a/src/commands/recoverCommand.ts b/src/commands/recoverCommand.ts index 0d317d37..0d7c0da1 100644 --- a/src/commands/recoverCommand.ts +++ b/src/commands/recoverCommand.ts @@ -53,6 +53,61 @@ async function tryListProjects(serviceClient: ServiceClient): Promise { + try { + return (await serviceClient.getDecryptData(projectId)).env_content || ''; + } catch { + return null; + } +} + +/** The first genuinely-encrypted value in `envContent`, as an oracle bound to `projectId`. */ +function firstOracleIn(envContent: string, projectId: string, fm: FileManager): CiphertextOracle | undefined { + // Only genuinely-encrypted values work as oracles: capy:{id}:{payload}. + // Tombstones (capy:deleted) and plaintext decrypt as no-ops under any key. + const [value] = envContent.split('\n').flatMap(line => { + const eq = line.indexOf('='); + if (eq < 0) return []; + const candidate = line.slice(eq + 1).trim(); + return candidate.startsWith('capy:') && candidate.split(':').length >= 3 ? [candidate] : []; + }); + if (value === undefined) return undefined; + return { + projectId, + verify: (projectKey: string) => { + try { + fm.decryptValue(value, projectKey); + return true; + } catch { + return false; + } + }, + }; +} + +/** + * Walks the projects in order. `anyRead` is whether at least one project's + * data was read, so the gap code can tell "nothing was looked at" from "it was + * looked at and held no encrypted default-branch value". + */ +async function scanProjects( + serviceClient: ServiceClient, + fm: FileManager, + projects: ReadonlyArray<{ id: string }>, + anyRead: boolean, +): Promise<{ oracle: CiphertextOracle } | { gap: OracleGapCode }> { + // The projects exist and were read, and none of their DEFAULT branches held + // an encrypted value. Reporting that rather than "no secrets" is the honest + // statement of what was looked at. + if (projects.length === 0) return { gap: anyRead ? 'other-branch' : 'fetch-failed' }; + const [proj, ...rest] = projects; + const envContent = await tryEnvContent(serviceClient, proj.id); + if (envContent === null) return scanProjects(serviceClient, fm, rest, anyRead); + const oracle = firstOracleIn(envContent, proj.id, fm); + return oracle ? { oracle } : scanProjects(serviceClient, fm, rest, true); +} + async function findOrgCiphertextOracle( serviceClient: ServiceClient, orgId: string, @@ -67,42 +122,7 @@ async function findOrgCiphertextOracle( const mine = projects.filter(p => p.organization_id === orgId); if (mine.length === 0) return { gap: 'no-secrets' }; - let anyRead = false; - for (const proj of mine) { - let envContent = ''; - try { - envContent = (await serviceClient.getDecryptData(proj.id)).env_content || ''; - anyRead = true; - } catch { - continue; - } - for (const line of envContent.split('\n')) { - const eq = line.indexOf('='); - if (eq < 0) continue; - const value = line.slice(eq + 1).trim(); - // Only genuinely-encrypted values work as oracles: capy:{id}:{payload}. - // Tombstones (capy:deleted) and plaintext decrypt as no-ops under any key. - if (!value.startsWith('capy:') || value.split(':').length < 3) continue; - return { - oracle: { - projectId: proj.id, - verify: (projectKey: string) => { - try { - fm.decryptValue(value, projectKey); - return true; - } catch { - return false; - } - }, - }, - }; - } - } - - // The projects exist and were read, and none of their DEFAULT branches held - // an encrypted value. Reporting that rather than "no secrets" is the honest - // statement of what was looked at. - return { gap: anyRead ? 'other-branch' : 'fetch-failed' }; + return scanProjects(serviceClient, fm, mine, false); } const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -110,25 +130,36 @@ const Y = (s: string) => `\x1b[33m${s}\x1b[0m`; const G = (s: string) => `\x1b[32m${s}\x1b[0m`; /** - * How many words a recovery phrase has. - * - * `validateSeedPhrase` is the authority and enforces 24. This states the same - * number where the browser path needs it BEFORE anything opens: the rail - * counts the words the run expects and the field builds that many boxes from - * it, so a screen that hardcoded 24 would be the one place in the product - * deciding how long a phrase is. + * The master key for the phrase. The org's KDF version isn't recorded, so it is + * detected by trial against a piece of the org's own ciphertext. This also + * validates the phrase up front — a phrase that matches nothing in the org is + * rejected before anything is written. With no ciphertext to verify against, + * the current KDF version is used (what a new org would use). */ -const PHRASE_WORD_COUNT = 24; - -export interface RecoverOptions { - /** - * Ask this run's questions in a browser instead of at the TTY. - * - * Changes only where a question is RENDERED. The same phrase is checked the - * same way, by the same trial decryption, and the same wrapped key lands in - * the same file — this flag moves no crypto and writes nothing new. - */ - web?: boolean; +function resolveMasterKey( + found: { oracle: CiphertextOracle } | { gap: OracleGapCode }, + phrase: string, + orgId: string, + orgName: string, +): Buffer { + if ('oracle' in found) { + const { oracle } = found; + const trial = resolveProjectKeyByTrial(phrase, orgId, oracle.projectId, oracle.verify); + if (!trial) { + console.error(`\n That recovery phrase does not match any secrets in ${B(orgName)}.`); + console.error(' Double-check the phrase, and that you selected the right organization.'); + console.error(' No changes were written.\n'); + process.exit(1); + } + return trial.masterKey; + } + // No stored secrets in this org — nothing to verify against. With no + // ciphertext there is nothing to mis-key; the first push defines the key tree. + console.log(''); + console.log(Y(' ⚠ This org has no stored secrets yet, so the recovery phrase could not')); + console.log(Y(' be verified. Writing a key under the current KDF version — run capy in')); + console.log(Y(' a project for this org to confirm it decrypts.')); + return seedPhraseToMasterKey(phrase, CURRENT_KDF_VERSION); } /** @@ -164,7 +195,15 @@ export class RecoverCommand { this.devMode = devMode; } - async execute(options: RecoverOptions = {}): Promise { + /** Silent first; interactive OAuth when there is no usable session. */ + private async signIn(authService: AuthService) { + const silent = await authService.authenticateSilent(); + if (silent.success) return silent; + console.log(`\n No active session. Launching browser to sign in...\n`); + return authService.authenticate(); + } + + async execute(): Promise { const inquirer = (await import('inquirer')).default; // 1. Authenticate. Try silent first; if there's no usable session (e.g. @@ -175,11 +214,7 @@ export class RecoverCommand { const projectState = await pm.detectProjectState(); const authService = new AuthService(this.apiUrl, this.devMode, projectState.userId); - let authResult = await authService.authenticateSilent(); - if (!authResult.success) { - console.log(`\n No active session. Launching browser to sign in...\n`); - authResult = await authService.authenticate(); - } + const authResult = await this.signIn(authService); if (!authResult.success) { console.error(`\n Sign-in failed: ${authResult.error || 'unknown error'}. Re-run ${B('capy recover')} after authenticating.\n`); process.exit(1); @@ -192,11 +227,6 @@ export class RecoverCommand { process.exit(1); } - if (options.web) { - await this.executeInBrowser(authService, userId, orgs, authResult.user_email); - return; - } - // 2. ALWAYS prompt for which org to recover. Never inherit keep.lock — // that's the bug that wrapped the wrong M for the wrong org. console.log(''); @@ -212,7 +242,7 @@ export class RecoverCommand { // 3. Re-scope the session to the chosen org so the KMS wrap-outer call // on this org's endpoint succeeds with the right token. - let scoped = await authService.authenticateSilent(orgId); + const scoped = await authService.authenticateSilent(orgId); if (!scoped.success) { // "select this org and retry" is the right advice for an ended session // and the wrong advice for an unreachable service, so the cause leads @@ -225,8 +255,6 @@ export class RecoverCommand { ); process.exit(1); } - authResult = scoped; - const serviceClient = new ServiceClient(this.apiUrl, this.devMode); serviceClient.setTokenProvider(() => authService.getValidToken()); @@ -275,35 +303,11 @@ export class RecoverCommand { process.exit(1); } - // Determine M. The KDF version that created this org isn't recorded, so we - // detect it by trial against a piece of the org's own ciphertext. This also - // validates the phrase up front — recover used to write a key.enc for a - // wrong phrase and only fail later (see class doc); now a phrase that - // matches nothing in the org is rejected before anything is written. + // Determine M (see `resolveMasterKey`). const fm = new FileManager(); const found = await findOrgCiphertextOracle(serviceClient, orgId, fm); - let masterKey: Buffer; - if ('oracle' in found) { - const { oracle } = found; - const trial = resolveProjectKeyByTrial(phrase, orgId, oracle.projectId, oracle.verify); - if (!trial) { - console.error(`\n That recovery phrase does not match any secrets in ${B(selectedOrg.name)}.`); - console.error(' Double-check the phrase, and that you selected the right organization.'); - console.error(' No changes were written.\n'); - process.exit(1); - } - masterKey = trial.masterKey; - } else { - // No stored secrets in this org — nothing to verify against. Use the - // current KDF version (what a new org would use). With no ciphertext there - // is nothing to mis-key; the first push defines the key tree. - console.log(''); - console.log(Y(' ⚠ This org has no stored secrets yet, so the recovery phrase could not')); - console.log(Y(' be verified. Writing a key under the current KDF version — run capy in')); - console.log(Y(' a project for this org to confirm it decrypts.')); - masterKey = seedPhraseToMasterKey(phrase, CURRENT_KDF_VERSION); - } + const masterKey = resolveMasterKey(found, phrase, orgId, selectedOrg.name); const keyOps = { coDecrypt: (oid: string, ct: string) => @@ -328,156 +332,4 @@ export class RecoverCommand { console.log(` phrase will surface as a decryption failure on the first encrypted variable.`); console.log(''); } - - /** - * The same three questions, drawn in a browser instead of at the TTY. - * - * Every decision below is still this command's. The screen module is handed - * three operations and never sees a master key: `scopeToOrg` re-scopes the - * session, `verifyPhrase` runs the identical trial decryption the terminal - * path runs, and `writeKey` derives and wraps. What moves is where the - * questions are asked — and, on one fork, whether they are asked at all. - * - * That fork is the unverified case. With nothing to trial-decrypt against, - * the terminal prints a warning and writes a key under the current KDF - * version anyway; for a legacy v1 organization caught by a transient outage - * that is a silently wrong key, discovered weeks later as a decryption - * failure. Here it is a stop, with the reason named, that the user answers. - * - * Nothing about the phrase is printed, logged or returned: this method sees - * the words only inside `verifyPhrase` and `writeKey`, which are the two - * places that have to. - */ - private async executeInBrowser( - authService: AuthService, - userId: string, - orgs: Array<{ id: string; name: string }>, - userEmail?: string, - ): Promise { - const serviceClient = new ServiceClient(this.apiUrl, this.devMode); - serviceClient.setTokenProvider(() => authService.getValidToken()); - const fm = new FileManager(); - - const keyOps = { - coDecrypt: (oid: string, ct: string) => - serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => - serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), - }; - - console.log(''); - console.log(` Signed in as ${B(userEmail || userId)}.`); - - const { recoverInBrowser } = await import('../ui/recoveryScreens'); - const pending = recoverInBrowser({ - userEmail, - // Which organizations already hold a key here decides whether the - // overwrite stop is a station or a struck-out one, and the rail says so - // before the user picks — the terminal springs it afterwards. - orgs: orgs.map(o => ({ - id: o.id, - name: o.name, - hasKeyOnThisDevice: hasOrgKey(o.id, userId), - })), - wordCount: PHRASE_WORD_COUNT, - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI and - // headless verification drive the loopback without hijacking a real one. - open: !process.env.CAPY_WEB_NO_OPEN, - ops: { - scopeToOrg: async (orgId: string) => { - const scoped = await authService.authenticateSilent(orgId); - return scoped.success === true; - }, - verifyPhrase: async (orgId: string, phrase: string) => { - // The terminal path's two checks, in the same order, so a phrase - // this command refuses at the TTY is refused in the browser too. - if (!phrase) return { code: 'EMPTY' as const }; - if (!validateSeedPhrase(phrase)) return { code: 'INVALID' as const }; - - const found = await findOrgCiphertextOracle(serviceClient, orgId, fm); - if ('gap' in found) return { code: 'NO_ORACLE' as const, gap: found.gap }; - - const trial = resolveProjectKeyByTrial( - phrase, - orgId, - found.oracle.projectId, - found.oracle.verify, - ); - if (!trial) return { code: 'NO_MATCH' as const }; - return { code: 'MATCH' as const, kdfVersion: trial.version }; - }, - writeKey: async (orgId: string, phrase: string, kdfVersion?: 1 | 2) => { - const masterKey = seedPhraseToMasterKey(phrase, kdfVersion ?? CURRENT_KDF_VERSION); - try { - await wrapAndSaveMasterKey(masterKey, orgId, userId, keyOps); - } catch (err: any) { - // The CLI's own sentence, carried whole so a failure reads the - // same wherever it is shown. - return { - ok: false as const, - message: `Failed to wrap and save the master key: ${err?.message || err}. No changes were written. Re-authenticate and try again.`, - }; - } - return { ok: true as const, keyPath: `~/.capy/orgs/${orgId}/users/${userId}/key.enc` }; - }, - }, - }); - const result = await this.orReportRefusal(pending); - - if (result.cancelled) { - console.log(' Aborted. No changes made.'); - return; - } - - if (result.kdfVersion === null) { - // Deliberately NOT the terminal path's sentence. That one says the org - // has no stored secrets, which is only one of four reasons the trial can - // fail to run — the page named the actual one, and repeating a guess - // here would be the CLI claiming something it did not establish. - console.log(''); - console.log(Y(' ⚠ Nothing here could check this recovery phrase, and you chose to write')); - console.log(Y(' the key anyway. It is under the current KDF version — run capy in a')); - console.log(Y(' project for this org to confirm it decrypts.')); - } - - console.log(''); - console.log(G(` ✓ Recovered master key for ${B(result.orgName)}.`)); - console.log(''); - if (result.keyPath) { - console.log(` Wrapped key written to ${B(result.keyPath)}.`); - } - console.log(` Verify by running ${B('capy')} in a project for this org — a wrong recovery`); - console.log(` phrase will surface as a decryption failure on the first encrypted variable.`); - console.log(''); - } - - /** - * A browser flow that never answered is a refusal, and it is said out loud. - * - * There are three ways out of `capy recover --web`: the key is written, the - * user cancels, or the window is closed. The third resolves nothing, so the - * wizard eventually gives up and rejects — and with no handler here that - * rejection reached index.ts's global `unhandledRejection`, which dumps the - * raw error object. A stack trace is not one of the endings, and it is a - * particularly bad one on the command whose subject is a master key: it - * says nothing about whether a key was written. - * - * Nothing derives or wraps a key outside `ops.writeKey`, and that returns - * its failures rather than throwing, so every rejection reaching here is a - * run that wrote nothing. That is what gets stated, before the CLI's own - * error screen renders the reason. - */ - private async orReportRefusal(pending: Promise): Promise { - try { - return await pending; - } catch (err) { - console.log(''); - console.log(' Nothing was written. No key on this device was created or replaced.'); - const { displayErrorAndExit } = await import('../ui/errorScreen'); - await displayErrorAndExit(err); - // Unreachable: the line above exits. Present so this function has no - // path that falls out of the catch returning nothing. - throw err; - } - } } diff --git a/src/commands/rotateCommand.ts b/src/commands/rotateCommand.ts index 7cdba768..56fb4d40 100644 --- a/src/commands/rotateCommand.ts +++ b/src/commands/rotateCommand.ts @@ -6,7 +6,7 @@ import { findManagedConnector, ResolvedContext, } from './connectors/shared'; -import { ConnectCommand, confirmLiveAction, rotateLiveGateStops } from './connectCommand'; +import { ConnectCommand, confirmLiveAction } from './connectCommand'; import { loadProvider, listProviders, RotateOpts } from './connectors/registry'; import { cap, rotationPlan, type RotationPlanInput } from './connectors/plans'; import { ProjectManager } from '../core/projectManager'; @@ -14,25 +14,13 @@ import { CapyError, ConnectorMetadata, ERROR_CODES, KeepFile } from '../types/in import { TargetConfig } from '../deploy/adapter'; import { staleTargets } from '../deploy/targetsGate'; import { isInteractive, refuseNonInteractive } from '../ui/interactive'; -import { confirmLiveActionInBrowser } from '../ui/connectScreens'; -import type { - RotateAdvisory, - RotateCandidate, - RotateKeyResult, - RotatePlanStop, - RotateRunOutcome, - RotateRunStep, - RotateRunStop, -} from '../ui/screens/contract'; +import type { RotatePlanStop } from '../ui/screens/contract'; import { writeSync } from 'fs'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; const DIM = (s: string) => `\x1b[90m${s}\x1b[0m`; const CYAN = (s: string) => `\x1b[36m${s}\x1b[0m`; -/** Browser paths honour this so a test never opens the developer's real browser. */ -const shouldOpen = (): boolean => !process.env.CAPY_WEB_NO_OPEN; - /** * Render the rotation plan as a vertical train-stop diagram: each stage is a * station (✓ answered, ● intermediate, ○ terminal, ◌ blank, · never visited) @@ -41,16 +29,12 @@ const shouldOpen = (): boolean => !process.env.CAPY_WEB_NO_OPEN; * confirmation aid — the route the rotation will travel — not a progress bar; * the ✓ lines printed during execution report what actually happened. * - * The stops are `rotationPlan`'s, not this function's. They used to be built - * inline here, printed and dropped, which is why the browser had no rail to - * draw and `--json` had no route to emit — and why the diagram began at the - * Auth stop, with the variable and the integration the user had just answered - * missing from the picture of what they were agreeing to. + * The stops are `rotationPlan`'s, not this function's, so the diagram begins + * with the variable and the integration the user had just answered. */ export function rotationPlanLines(stops: RotatePlanStop[]): string[] { const width = Math.max(...stops.map((s) => s.label.length)); - const lines: string[] = ['', ` ${B('Rotation plan')}`, '']; - stops.forEach((s, i) => { + const stopLines = stops.flatMap((s, i) => { const last = i === stops.length - 1; const faint = s.blank || s.state === 'skipped'; const node = s.blank @@ -67,14 +51,12 @@ export function rotationPlanLines(stops: RotatePlanStop[]): string[] { // with no marker is indistinguishable from a question still to come, and // the flag is the honest answer to "why was I never asked?". const settled = s.answer ? DIM(` · ${s.answer}${s.flag ? ` (${s.flag})` : ''}`) : ''; - lines.push(` ${node} ${label} ${DIM(s.detail ?? '')}${settled}`); - if (!last) { - const dotted = s.manual || stops[i + 1].manual; - lines.push(` ${DIM(dotted ? '┊' : '│')}`); - } + const line = ` ${node} ${label} ${DIM(s.detail ?? '')}${settled}`; + if (last) return [line]; + const dotted = s.manual || stops[i + 1].manual; + return [line, ` ${DIM(dotted ? '┊' : '│')}`]; }); - lines.push(''); - return lines; + return ['', ` ${B('Rotation plan')}`, '', ...stopLines, '']; } function renderRotationPlan(stops: RotatePlanStop[]): void { @@ -86,30 +68,15 @@ function renderRotationPlan(stops: RotatePlanStop[]): void { writeSync(1, rotationPlanLines(stops).join('\n') + '\n'); } -/** - * End the run on a refusal, wherever the caller is looking. - * - * Every early exit in this file used to be `console.error(…)` + - * `process.exit(1)`. That is right in a terminal and empty under `--web`: the - * flag exists because the caller is an agent, so the one fact that says what - * to do next — the variable does not exist, the branch has nothing managed — - * went to a stream with nobody on the other end. The exit code was correct and - * the sentence was correct, and neither of them reached a surface. - * - * `displayErrorAndExit` is the CLI's single answer to that, and it already - * does the three things this needs: the ANSI still goes to the terminal, a - * `command-error` page is served under `--web` and HELD until the browser has - * fetched it, and the process exits 1 either way. So these sites do not get a - * second implementation of an ending — they get a typed code, which is also - * the handle an agent branches on (cardinal Rule 4) instead of recognising a - * sentence. - * - * `await refuse(…); return;` at every call site, and the `return` is not - * decoration: `displayErrorAndExit` is `Promise` but TypeScript does - * not narrow across an awaited never, so the `return` is what tells the - * compiler `keep` is non-null below — and what keeps the flow honest under a - * test that stubs `process.exit` without throwing. - */ +/** The providers (in order) whose connector module requires auth. */ +async function providersRequiringAuth(providerNames: readonly string[]): Promise { + if (providerNames.length === 0) return []; + const [first, ...rest] = providerNames; + const mod = await loadProvider(first).catch(() => undefined); + const restAuth = await providersRequiringAuth(rest); + return mod?.requiresAuth ? [first, ...restAuth] : restAuth; +} + /** * The first provider (in order) whose connector module is import-kind — the * check `rotateMany` needs before it dares call `resolveContext()`. Prechecks @@ -126,6 +93,16 @@ async function findImportOnlyProvider(providerNames: readonly string[]): Promise return findImportOnlyProvider(rest); } +/** + * End the run on a refusal: prints it and exits 1 via `displayErrorAndExit`, + * with a typed code (the handle an agent branches on) rather than a sentence. + * + * `await refuse(…); return;` at every call site, and the `return` is not + * decoration: `displayErrorAndExit` is `Promise` but TypeScript does + * not narrow across an awaited never, so the `return` is what tells the + * compiler `keep` is non-null below — and what keeps the flow honest under a + * test that stubs `process.exit` without throwing. + */ async function refuse( error: CapyError, context: { projectName?: string; projectId?: string; branch?: string } = {}, @@ -208,17 +185,15 @@ export class RotateCommand { ); return; } - // `--all` ignores a positional variable and says nothing about it. The - // plan screen carries that as an advisory rather than letting the user - // approve a run they think is about one credential. - await this.planAndRotate(managed, branch, { ...opts, varIgnored: varName }); + // `--all` ignores a positional variable. + await this.planAndRotate(managed, branch, opts); return; } // Resolve which (varName, connector|unmanaged) we're operating on, as one // value rather than a reassigned local — `resolution.stop` is set on - // every path that already printed its own refusal/cancellation and needs - // this method to return without doing anything else. + // every path that already printed its own refusal and needs this method to + // return without doing anything else. type Target = { varName: string; connector: ConnectorMetadata } | { varName: string; unmanaged: true }; const resolution: { target: Target } | { stop: true } = await (async (): Promise<{ target: Target } | { stop: true }> => { if (varName) { @@ -246,47 +221,24 @@ export class RotateCommand { ); return { stop: true }; } - const picked: string | null = await (async (): Promise => { - if (opts.web) { - const candidates = buildRotateCandidates(allVars, keep, branch); - const { askRotateVariableInBrowser } = await import('../ui/rotateScreens'); - const answer = await askRotateVariableInBrowser({ - step: 'variable', - projectName: keep.project_name, - branch, - devMode: this.devMode, - all: false, - noPush: opts.noPush === true, - stops: await this.planStops(keep, branch, opts, { standing: 'variable' }), - candidates, - open: shouldOpen(), - }); - if (answer.cancelled) { - console.log('\n Cancelled.\n'); - return null; - } - return answer.variable; - } - if (!isInteractive(opts.nonTty)) { - refuseNonInteractive( - 'no variable specified and the picker needs a prompt', - `Pass the variable name: capy rotate (available: ${allVars.join(', ')}).`, - ); - } - const inquirer = (await import('inquirer')).default; - const answer = await inquirer.prompt([ - { - type: 'list', - name: 'picked', - message: 'Which variable to rotate:', - choices: buildRotatePickerChoices(allVars, keep, branch).map( - ({ name, value }) => ({ name, value }), - ), - }, - ]); - return answer.picked; - })(); - if (picked === null) return { stop: true }; + if (!isInteractive(opts.nonTty)) { + refuseNonInteractive( + 'no variable specified and the picker needs a prompt', + `Pass the variable name: capy rotate (available: ${allVars.join(', ')}).`, + ); + } + const inquirer = (await import('inquirer')).default; + const answer = await inquirer.prompt([ + { + type: 'list', + name: 'picked', + message: 'Which variable to rotate:', + choices: buildRotatePickerChoices(allVars, keep, branch).map( + ({ name, value }) => ({ name, value }), + ), + }, + ]); + const picked: string = answer.picked; const connector = findManagedConnector(keep, picked, branch); return { target: connector ? { varName: picked, connector } : { varName: picked, unmanaged: true } }; })(); @@ -323,41 +275,10 @@ export class RotateCommand { */ private async choosePromoteProvider( varName: string, - branch: string, opts: RotateOpts & { provider?: string }, providers: { name: string; description: string }[], linkableProviders: { name: string; description: string }[], ): Promise { - if (opts.web) { - // Never pre-selected, however few are registered. Off a TTY the CLI - // auto-picks the single provider with no output at all — for a variable - // the user never associated with it — and the run that follows replaces - // whatever is in that variable with a key the provider issues. The - // screen says that before the list, not after the write. - const pm = new ProjectManager(); - const keep = pm.readKeepFile(); - const { askRotateIntegrationInBrowser } = await import('../ui/rotateScreens'); - const answer = await askRotateIntegrationInBrowser({ - step: 'integration', - projectName: keep?.project_name ?? 'project', - branch, - devMode: this.devMode, - all: false, - noPush: opts.noPush === true, - stops: keep - ? await this.planStops(keep, branch, opts, { - standing: 'integration', - varName, - needsIntegration: true, - }) - : [], - integrations: linkableProviders, - varName, - open: shouldOpen(), - }); - return answer.cancelled ? null : answer.provider; - } - if (!isInteractive(opts.nonTty)) { // Non-interactive: resolve the integration from --provider, or auto-pick // it only when there's exactly one registered (unambiguous). Otherwise @@ -430,8 +351,7 @@ export class RotateCommand { } } - // The link-first picker (below, in every one of `--web`/non-interactive/ - // interactive) only ever offers a provider that can actually link ONE + // The link-first picker (below, non-interactive or interactive) only ever offers a provider that can actually link ONE // variable — an import-kind connector pulls many at once and has no // rotate() to promote into, so it never belongs in this list. const linkableProviders = ( @@ -442,7 +362,7 @@ export class RotateCommand { return; } - const chosenProvider = await this.choosePromoteProvider(varName, branch, opts, providers, linkableProviders); + const chosenProvider = await this.choosePromoteProvider(varName, opts, providers, linkableProviders); if (chosenProvider === null) { console.log('\n Cancelled.\n'); return; @@ -454,16 +374,10 @@ export class RotateCommand { var: varName, noPush: opts.noPush, nonTty: opts.nonTty, - // The connect flow takes over from here, and it has to keep serving - // screens: dropping `--web` at the hand-off is how a browser flow ends - // up at a TTY prompt nobody is watching. - web: opts.web, - // A step, not the run. Suppresses connect's success ENDING so the - // rotation that follows is not preceded by a page announcing the run is - // over — and stops it signing off with the command we are inside. + // A step, not the run: stops connect signing off with the command we + // are inside. subStep: true, }); - // A decline or a failed push already served its own ending and said why. if (!linked) return; // The connector `connect` just recorded, read back rather than assumed: @@ -494,13 +408,7 @@ export class RotateCommand { await this.planAndRotate([{ varName, connector }], branch, { ...opts, promotedVia: provider }); } - /** - * The route this run will travel, resolved before anything opens. - * - * One builder, so the diagram `renderRotationPlan` prints, the rail the - * browser draws and the array `--json` would emit cannot describe different - * journeys. - */ + /** The route this run will travel, resolved before anything runs. */ private async planStops( keep: KeepFile | null, branch: string, @@ -512,11 +420,7 @@ export class RotateCommand { (keep ? Array.from(new Set(listManagedKeys(keep, branch).map((m) => m.connector.provider))) : []); - const authProviders: string[] = []; - for (const p of providers) { - const mod = await loadProvider(p).catch(() => undefined); - if (mod?.requiresAuth) authProviders.push(p); - } + const authProviders = await providersRequiringAuth(providers); return rotationPlan({ branch, all: opts.all === true, @@ -530,19 +434,16 @@ export class RotateCommand { /** * Rotate one or more already-managed keys. Live-mode firewall in dev, - * provider preflight, per-rotation confirmation in prod live mode. + * provider preflight, per-rotation confirmation in prod live mode. Returns + * the names that rotated. */ private async rotateMany( targets: Array<{ varName: string; connector: ConnectorMetadata }>, opts: RotateOpts & { all?: boolean }, - ): Promise { - const web = opts.web === true; - // Every credential the run touched keeps a row, including the ones it - // never reached. `Rotated 2/3 key(s).` says nothing about which of the - // three never started, and that is the one the user still has to deal with. - const liveFilter = await this.filterLiveInDevMode(targets, [], opts); - if (liveFilter.kind === 'stop') return liveFilter.report; - const { toRotate, keys: keysAfterLiveFilter } = liveFilter; + ): Promise { + const liveFilter = await this.filterLiveInDevMode(targets, opts); + if (liveFilter.kind === 'stop') return []; + const { toRotate } = liveFilter; // Each unique provider (in first-appearance order), prechecked once and // refused early if it's import-kind — keyed off the connector module's @@ -560,82 +461,57 @@ export class RotateCommand { { provider: importOnlyProvider }, ), ); - return { succeeded: [], keys: [...keysAfterLiveFilter], stopped: true }; + return []; } const ctx = await resolveContext({ devMode: this.devMode }); - const result = await this.rotateSequentially(toRotate, ctx, opts, web, { - succeeded: [], - failed: [], - keys: keysAfterLiveFilter, - stopped: false, - }); + const result = await this.rotateSequentially(toRotate, ctx, opts, { succeeded: [], failed: [] }); if (opts.all && (result.succeeded.length > 0 || result.failed.length > 0)) { console.log(''); console.log(` Rotated ${result.succeeded.length}/${toRotate.length} key(s).`); if (result.failed.length > 0) { console.log(` Failed: ${result.failed.map((f) => f.name).join(', ')}`); - // Under `--web` the caller still has a page to serve, and `process.exit` - // would kill the loopback server before the browser could fetch it. - if (!web) process.exit(1); - return { succeeded: [...result.succeeded], keys: [...result.keys], stopped: true }; + process.exit(1); } else { console.log(''); } } - return { succeeded: [...result.succeeded], keys: [...result.keys], stopped: result.stopped }; + return [...result.succeeded]; } /** * The dev-mode live-key firewall, applied once before any rotation starts: * `capy-dev` refuses live keys outright — `--all` skips them and carries * on, without it the whole batch is refused. Returns the filtered list to - * rotate plus the `keys` rows the skip itself produced, or a `stop` result - * when nothing is left to rotate. + * rotate, or a `stop` when nothing is left to rotate. */ private async filterLiveInDevMode( targets: readonly { varName: string; connector: ConnectorMetadata }[], - keys: readonly RotateKeyResult[], opts: RotateOpts & { all?: boolean }, ): Promise< - | { - kind: 'ok'; - toRotate: readonly { varName: string; connector: ConnectorMetadata }[]; - keys: readonly RotateKeyResult[]; - } - | { kind: 'stop'; report: RotateRunReport } + | { kind: 'ok'; toRotate: readonly { varName: string; connector: ConnectorMetadata }[] } + | { kind: 'stop' } > { - if (!this.devMode) return { kind: 'ok', toRotate: targets, keys }; + if (!this.devMode) return { kind: 'ok', toRotate: targets }; const liveOnes = targets.filter((m) => m.connector.mode === 'live'); if (!opts.all && liveOnes.length > 0) { - // Reached AFTER the plan was approved in the browser under `--web`, so - // this is the one refusal the user has already said yes to something - // about. A terminal-only exit here reads as the run simply stopping. await refuse( new CapyError(`${liveOnes[0].varName} is configured for live mode.`, ERROR_CODES.DEV_LIVE_FIREWALL, { variables: liveOnes.map((m) => m.varName), nothingLeft: false, }), ); - return { kind: 'stop', report: { succeeded: [], keys: [...keys], stopped: true } }; + return { kind: 'stop' }; } if (opts.all && liveOnes.length > 0) { console.log(''); - const skippedKeys = liveOnes.map((m) => { + liveOnes.forEach((m) => { console.log(` \x1b[33m⚠ skipping ${m.varName} (live mode — not allowed in capy-dev)\x1b[0m`); - return { - name: m.varName, - provider: m.connector.provider, - outcome: 'skipped' as const, - skipReason: 'dev-live-firewall' as const, - mode: 'live' as const, - }; }); - const nextKeys = [...keys, ...skippedKeys]; const filtered = targets.filter((m) => m.connector.mode !== 'live'); if (filtered.length === 0) { await refuse( @@ -644,117 +520,46 @@ export class RotateCommand { nothingLeft: true, }), ); - return { kind: 'stop', report: { succeeded: [], keys: [...nextKeys], stopped: true } }; + return { kind: 'stop' }; } - return { kind: 'ok', toRotate: filtered, keys: nextKeys }; + return { kind: 'ok', toRotate: filtered }; } - return { kind: 'ok', toRotate: targets, keys }; + return { kind: 'ok', toRotate: targets }; } /** - * Rotates `items` one at a time, threading `succeeded`/`failed`/`keys`/ - * `stopped` through return values instead of mutating shared variables - * across the loop. Once `state.stopped` is true (a decline or a failure - * under `--web` without `--all`) every remaining key becomes `not-run` - * rather than attempted — the same rule the original `for` loop's `if - * (stopped) continue` enforced. + * Rotates `items` one at a time, threading `succeeded`/`failed` through + * return values instead of mutating shared variables across the loop. */ private async rotateSequentially( items: readonly { varName: string; connector: ConnectorMetadata }[], ctx: ResolvedContext, opts: RotateOpts & { all?: boolean }, - web: boolean, state: RotateLoopState, ): Promise { if (items.length === 0) return state; const [{ varName: name, connector }, ...rest] = items; - if (state.stopped) { - // The batch stopped at an earlier key, so this one never started — - // which is a different fact from "it failed", and the terminal draws - // neither. - const nextState: RotateLoopState = { - ...state, - keys: [ - ...state.keys, - { - name, - provider: connector.provider, - outcome: 'not-run', - skipReason: 'batch-stopped', - ...(connector.mode === 'test' || connector.mode === 'live' ? { mode: connector.mode } : {}), - }, - ], - }; - return this.rotateSequentially(rest, ctx, opts, web, nextState); - } - try { // Prod live rotation normally gates on a human typing the account ID. // In assisted non-interactive mode we skip that echo: the rotation // re-runs `stripe login`, and completing that browser pairing is itself // the human-presence proof (see docs/rotate-deploy-agent-flow.md). The - // typed confirmation only runs in an interactive terminal — or in a - // browser, which is the only way an agent-driven run gets asked at all. - if (!this.devMode && connector.mode === 'live' && (web || isInteractive(opts.nonTty))) { - const ok = web - ? await confirmLiveActionInBrowser({ - action: 'rotate', - provider: connector.provider, - projectName: ctx.keep.project_name, - branch: ctx.branch, - varName: name, - accountId: connector.account_id ?? null, - // NO `keyPrefix`. The gate's is `value.slice(0, 8)` — the - // literal `rk_live_` the terminal prints as "Key type" — and a - // rotation has no value to slice: the new key does not exist - // yet and the old one was never stored. What keep.lock holds - // is `fingerprint()`'s redacted `rk_…tst`, and passing its - // first eight characters rendered as `rk_…tst…`, a key type - // that does not exist. The screen omits the row when the field - // is absent, which is the honest reading. - // - // REPORTED, not patched: `ConnectLiveGateData` has no field - // for a fingerprint, so rotate's gate cannot say anything at - // all about which key is being replaced. It should. - push: !opts.noPush, - pushFromFlag: opts.noPush === true, - stops: rotateLiveGateStops({ - provider: connector.provider, - branch: ctx.branch, - varName: name, - ...(connector.account_id ? { accountId: connector.account_id } : {}), - push: !opts.noPush, - pushFromFlag: opts.noPush === true, - }), - open: shouldOpen(), - }) - : await confirmLiveAction({ - action: 'rotate', - varName: name, - accountId: connector.account_id ?? '(unknown)', - keyPrefix: connector.fingerprint?.slice(0, 8), - }); + // typed confirmation only runs in an interactive terminal. + if (!this.devMode && connector.mode === 'live' && isInteractive(opts.nonTty)) { + const ok = await confirmLiveAction({ + action: 'rotate', + varName: name, + accountId: connector.account_id ?? '(unknown)', + keyPrefix: connector.fingerprint?.slice(0, 8), + }); if (!ok) { console.log(` Cancelled ${name}.`); const declinedState: RotateLoopState = { ...state, failed: [...state.failed, { name, err: new Error('confirmation declined') }], - keys: [ - ...state.keys, - { - name, - provider: connector.provider, - outcome: 'failed', - mode: 'live', - failureCode: 'declined-live-confirm', - detail: 'the account ID was not confirmed, so nothing was fetched', - retry: `capy rotate ${name}`, - }, - ], }; - if (opts.all) return this.rotateSequentially(rest, ctx, opts, web, declinedState); - if (web) return this.rotateSequentially(rest, ctx, opts, web, { ...declinedState, stopped: true }); + if (opts.all) return this.rotateSequentially(rest, ctx, opts, declinedState); process.exit(1); } } @@ -770,19 +575,6 @@ export class RotateCommand { const succeededState: RotateLoopState = { ...state, succeeded: [...state.succeeded, name], - keys: [ - ...state.keys, - { - name, - provider: connector.provider, - outcome: 'rotated', - pushed: !opts.noPush, - ...(updated.mode === 'test' || updated.mode === 'live' ? { mode: updated.mode } : {}), - // A key Capy issued through the provider's CLI: every teammate's copy - // stopped working the moment this ran. - ...(connector.source === 'cli' ? { issuedByCapy: true } : {}), - }, - ], }; console.log(''); console.log(` ✓ ${B(name)} rotated${opts.noPush ? ' (local only)' : ' and pushed'}.`); @@ -792,31 +584,16 @@ export class RotateCommand { ); } console.log(''); - return this.rotateSequentially(rest, ctx, opts, web, succeededState); + return this.rotateSequentially(rest, ctx, opts, succeededState); } catch (err) { const failState: RotateLoopState = { ...state, failed: [...state.failed, { name, err }], - keys: [ - ...state.keys, - { - name, - provider: connector.provider, - outcome: 'failed', - ...(connector.mode === 'test' || connector.mode === 'live' ? { mode: connector.mode } : {}), - // No stable code to mint here: this is whatever the provider threw, - // and the screen branches on `failureCode`, never on the sentence. - failureCode: 'other', - detail: (err as Error).message, - retry: `capy rotate ${name}`, - }, - ], }; console.error(''); console.error(` ✗ Failed to rotate ${B(name)}: ${(err as Error).message}`); console.error(''); - if (opts.all) return this.rotateSequentially(rest, ctx, opts, web, failState); - if (web) return this.rotateSequentially(rest, ctx, opts, web, { ...failState, stopped: true }); + if (opts.all) return this.rotateSequentially(rest, ctx, opts, failState); process.exit(1); } } @@ -832,10 +609,8 @@ export class RotateCommand { * Apply — rotate → push → deploy. Deploy is the automatic terminal step, * never a separate, optional action. * - * --no-push is local-only with nothing to ship, so in the terminal it skips - * the diagram and rotates directly. Under `--web` it does not: it still - * invalidates the old key at the provider, so the plan is drawn for that run - * too, with the stops it will not travel struck through. + * --no-push is local-only with nothing to ship, so it skips the diagram and + * rotates directly. */ private async planAndRotate( targets: Array<{ varName: string; connector: ConnectorMetadata }>, @@ -844,19 +619,15 @@ export class RotateCommand { all?: boolean; skipPrompts?: boolean; provider?: string; - /** A positional variable `--all` dropped, so the plan can say so. */ - varIgnored?: string; /** * This run reached here by PROMOTING an unmanaged variable through the - * named integration, which is an answer the rail has to show rather than + * named integration, which is an answer the plan has to show rather than * a stop it can strike through. */ promotedVia?: string; }, ): Promise { - const web = opts.web === true; - - if (opts.noPush && !web) { + if (opts.noPush) { await this.rotateMany(targets, opts); return; } @@ -878,38 +649,12 @@ export class RotateCommand { // one path (a declined picker) that already printed its own // cancellation and needs this method to return without doing anything else. const targetResolution: { target: TargetConfig | null; stop?: true } = await (async () => { - if (opts.noPush) { - // `--no-push` ships nothing, so there is no target to resolve. The plan - // is still drawn for that run — the destructive half is unchanged — with - // the stops it will not travel struck through. - return { target: null }; - } - if (web || isTTY) { + if (isTTY) { // Ensure a target exists, setting one up inline if needed. - // - // `--web` HAS TO REACH THIS CALL, and for two reasons that are easy to - // miss because everything downstream is already built and already - // tested. `ensureDeployTarget` takes a `WebContext` and branches on it - // twice — `pickTargetInBrowser` when several targets are saved, and - // `runPicker(…, web)` when none is, which serves the adapter/branch/ - // settings/variables/delivery/name route through - // `setUpDeployTargetInBrowser`. Called with no second argument the - // context defaults to `{}`, every one of those branches is skipped, and - // a run whose whole point is that nobody is watching the terminal stops - // on `Where are you deploying?` at an inquirer prompt. The screens are - // not missing; this call site was not asking for them. - // - // And the gate cannot stay `isTTY` alone. `--web` exists because the - // caller is an agent, which is precisely the case with no TTY — so the - // old condition sent exactly the intended caller down the branch that - // silently resolves nothing. const { ensureDeployTarget } = await import('./deployCommand'); - const resolved = await ensureDeployTarget(process.cwd(), web ? { web: true } : {}); + const resolved = await ensureDeployTarget(process.cwd()); if (!resolved) { - // A declined picker wrote nothing and that was the point, so this is a - // 0 either way. Under `--web` the wizard has already closed on the - // user's own cancel, so the line below is a terminal echo of a - // decision they watched themselves make — not the only report of it. + // A declined picker wrote nothing and that was the point, so this is a 0. console.log('\n Cancelled.\n'); return { target: null, stop: true as const }; } @@ -948,34 +693,9 @@ export class RotateCommand { renderRotationPlan(stops); // ── Confirm: single Y/N gate ───────────────────────────────────────────── - // The one approval the whole rotate → push → deploy chain has, and in the - // terminal `!opts.skipPrompts && isTTY` drops it the moment stdin is piped - // — which is every agent-driven run. Under `--web` it is asked of every - // caller, because a gate that disappears when nobody is watching is not a - // gate. - if (!opts.skipPrompts && web) { - const { confirmRotatePlanInBrowser } = await import('../ui/rotateScreens'); - const proceed = await confirmRotatePlanInBrowser({ - step: 'plan', - projectName: keep?.project_name ?? 'project', - branch, - devMode: this.devMode, - all: opts.all === true, - noPush: opts.noPush === true, - stops, - ...(keep - ? { targets: buildRotateCandidates(targets.map((t) => t.varName), keep, branch) } - : {}), - ...(opts.all ? {} : { varName: targets[0]?.varName }), - deployTargetCount: configuredTargets.length, - advisories: this.advisories(targets, deployTarget, configuredTargets, opts), - open: shouldOpen(), - }); - if (!proceed) { - console.log('\n Cancelled.\n'); - return; - } - } else if (!opts.skipPrompts && isTTY) { + // The one approval the whole rotate → push → deploy chain has; it is + // dropped the moment stdin is piped (`!opts.skipPrompts && isTTY`). + if (!opts.skipPrompts && isTTY) { const inquirer = (await import('inquirer')).default; const { proceed } = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: 'Proceed?', default: true }, @@ -987,22 +707,8 @@ export class RotateCommand { } // ── Apply ──────────────────────────────────────────────────────────────── - // - // EVERY `--web` ending below sets `process.exitCode` and returns; none of - // them calls `process.exit`. The page each one serves comes off a loopback - // server in THIS process, and exiting closes that socket microseconds - // after it started listening — so the exit code arrived and the page that - // explained it never did. `rotateMany` already carried this rule inside - // itself (`stopped` instead of an inline exit); these are the call sites - // that dropped it. - const report = await this.rotateMany(targets, opts); - if (report.succeeded.length === 0) { - if (web) { - await this.reportRun(keep?.project_name ?? 'project', branch, opts, report, stops, null, configuredTargets.length); - if (report.keys.some((k) => k.outcome === 'failed')) process.exitCode = 1; - } - return; - } + const succeeded = await this.rotateMany(targets, opts); + if (succeeded.length === 0) return; // ── Stale targets (CAP-679) ─────────────────────────────────────────── // The rotate above already pushed — re-read to see which `targets` @@ -1011,51 +717,26 @@ export class RotateCommand { // above/below: THIS is "does any configured target still hold the OLD // value", regardless of whether a single unambiguous target happened to // be resolved for the redeploy-after-rotate flow. - await this.reportStaleTargets(branch, report.succeeded, opts, isTTY && !web); - - const deployOutcome: { deployed: { name: string; ok: boolean } | null; stop?: true } = await (async () => { - if (!deployTarget) { - if (!opts.noPush) { - // No target resolved (none configured, several to disambiguate, or - // a dev direct-mode target we skipped). The key is already rotated - // + pushed; kick the user into the deploy flow to open the rollout PR. - this.deployHint(configuredTargets.length); - } - return { deployed: null }; - } - const { deployCommand } = await import('./deployCommand'); - const code = await deployCommand(deployTarget.name, { yes: true, devMode: this.devMode }); - const deployed = { name: deployTarget.name, ok: code === 0 }; - if (code !== 0) { - // The keys are already live in Capy and every running system still - // holds the old ones. Under `--web` that state gets its own page - // before the exit code, because re-running rotate here makes it worse - // — and the page is the whole reason this branch exists, so the exit - // waits for it rather than racing it. - if (web) { - await this.reportRun(keep?.project_name ?? 'project', branch, opts, report, stops, deployed, configuredTargets.length); - process.exitCode = code; - return { deployed, stop: true as const }; - } - process.exit(code); - } - return { deployed }; - })(); - if (deployOutcome.stop) return; - const { deployed } = deployOutcome; + await this.reportStaleTargets(branch, succeeded, opts, isTTY); - if (web) { - await this.reportRun(keep?.project_name ?? 'project', branch, opts, report, stops, deployed, configuredTargets.length); - if (report.stopped) process.exitCode = 1; + if (!deployTarget) { + // No target resolved (none configured, several to disambiguate, or + // a dev direct-mode target we skipped). The key is already rotated + // + pushed; kick the user into the deploy flow to open the rollout PR. + this.deployHint(configuredTargets.length); + return; } + const { deployCommand } = await import('./deployCommand'); + const code = await deployCommand(deployTarget.name, { yes: true, devMode: this.devMode }); + if (code !== 0) process.exit(code); } /** * CAP-679: after a successful rotate + push, list every configured target * whose keep.lock record now disagrees with the fresh value — i.e. a - * platform that still holds the OLD value. Report-only outside a plain - * terminal (`offerRedeploy=false`): under `--web`, or with `--skip-prompts` - * /`--yes`, this never prompts — it reuses the same rotate→deploy plumbing + * platform that still holds the OLD value. Report-only without a plain + * terminal (`offerRedeploy=false`) or with `--skip-prompts`/`--yes`: this + * never prompts — it reuses the same rotate→deploy plumbing * (`deployCommand`) as the single-target auto-redeploy above, one call per * stale target, only when a human at a real TTY says yes. */ @@ -1110,121 +791,6 @@ export class RotateCommand { } } - /** - * The yellow lines the terminal prints above the plan — and the two it does - * not print at all. - * - * `--provider` at a TTY and a positional variable under `--all` are both - * accepted and then silently dropped, and a user who typed one is entitled to - * know it did nothing before they approve a plan built as though they had not. - */ - private advisories( - targets: Array<{ varName: string; connector: ConnectorMetadata }>, - deployTarget: TargetConfig | null, - configuredTargets: TargetConfig[], - opts: RotateOpts & { all?: boolean; provider?: string; varIgnored?: string }, - ): RotateAdvisory[] { - const out: RotateAdvisory[] = []; - if (this.devMode && targets.some((t) => t.connector.mode === 'live')) { - out.push({ - code: 'dev-skips-live-key', - detail: 'capy-dev refuses live keys, so they are left out of this run.', - }); - } - if (this.devMode && !deployTarget && configuredTargets.some((t) => (t.mode ?? 'direct') !== 'ci')) { - out.push({ - code: 'dev-skips-direct-deploy', - detail: 'capy-dev never runs a direct vendor ship, so the resolved target was dropped.', - }); - } - if (opts.provider && targets.length > 0) { - out.push({ - code: 'provider-flag-ignored', - detail: `--provider ${opts.provider} only applies to a variable with no integration yet. These are already managed.`, - }); - } - if (opts.all && opts.varIgnored) { - out.push({ - code: 'var-ignored-with-all', - detail: `--all rotates every managed credential on this branch, so ${opts.varIgnored} was not treated as the target.`, - }); - } - return out; - } - - /** What the run actually did, as a page. Reports only — nothing here decides. */ - private async reportRun( - projectName: string, - branch: string, - opts: RotateOpts & { all?: boolean }, - report: RotateRunReport, - stops: RotatePlanStop[], - deployed: { name: string; ok: boolean } | null, - targetCount: number, - ): Promise { - const rotated = report.keys.filter((k) => k.outcome === 'rotated'); - const failed = report.keys.filter((k) => k.outcome === 'failed'); - const outcome: RotateRunOutcome = - rotated.length === 0 - ? 'failed' - : failed.length > 0 || report.keys.some((k) => k.outcome === 'not-run') - ? 'partial' - : deployed && !deployed.ok - ? 'deploy-failed' - : deployed - ? 'deployed' - : opts.noPush - ? 'rotated-local' - : 'rotated'; - - const steps: RotateRunStep[] = [ - { - id: 'rotate', - label: 'Rotate', - state: rotated.length === 0 ? 'fail' : failed.length > 0 ? 'fail' : 'ok', - detail: `${rotated.length}/${report.keys.length}`, - }, - { - id: 'push', - label: 'Push', - // `skip` and `pending` are different facts: a `--no-push` run skips the - // push, while one queued behind a failed rotation never ran. - state: opts.noPush ? 'skip' : rotated.length > 0 ? 'ok' : 'pending', - detail: opts.noPush ? 'skipped by --no-push' : branch, - }, - { - id: 'deploy', - label: 'Deploy', - state: deployed ? (deployed.ok ? 'ok' : 'fail') : opts.noPush ? 'skip' : 'pending', - ...(deployed - ? { detail: deployed.name } - : { detail: 'no target resolved', prose: true, fix: 'capy deploy' }), - }, - ]; - - const { showRotateProgressInBrowser } = await import('../ui/rotateScreens'); - await showRotateProgressInBrowser({ - outcome, - projectName, - branch, - all: opts.all === true, - noPush: opts.noPush === true, - devMode: this.devMode, - // The route TRAVELLED, which is what this screen's payload asks for — - // not the route declared. Handing the plan over untouched drew Rotate, - // Push and Deploy as stops still ahead of a run that had already been - // through all three, on the one page whose subject is how far it got. - stops: travelledStops(stops, steps), - steps, - keys: report.keys, - deploy: { - ...(deployed ? { targetName: deployed.name } : {}), - targetCount, - }, - open: shouldOpen(), - }); - } - /** * Rotated + pushed, but we didn't ship — point the user into the deploy flow * to open the rollout PR. The key is already live in Capy; this is the @@ -1272,115 +838,12 @@ export function buildRotatePickerChoices( }); } -/** - * The same rows, for the browser. - * - * The terminal's are pre-formatted strings with ANSI in them — `STRIPE_KEY - * (stripe, rk_…tst, expires in 30d)` — which a payload cannot use: the escapes - * would render as literal `[90m`, and the expiry is glued into the sentence so - * the screen could not pluralise it. Same facts, structured, from the same - * keep.lock lookup — which is why this sits beside `buildRotatePickerChoices` - * rather than in the screen module. - * - * NEVER key material. `fingerprint` is the redacted `abc…xyz` form keep.lock - * already stores; no value has ever been in a connector entry. - */ -export function buildRotateCandidates( - allVars: string[], - keep: KeepFile, - branch: string, -): RotateCandidate[] { - return allVars.map((v) => { - const c = findManagedConnector(keep, v, branch); - if (!c) return { name: v, managed: false }; - return { - name: v, - managed: true, - provider: c.provider, - ...(c.fingerprint ? { fingerprint: c.fingerprint } : {}), - ...(typeof c.expires_at === 'number' - ? { expiresInDays: Math.floor((c.expires_at - Date.now() / 1000) / 86400) } - : {}), - ...(c.mode === 'test' || c.mode === 'live' ? { mode: c.mode } : {}), - ...(c.account_id ? { accountId: c.account_id } : {}), - // Issued by Capy through the provider's CLI, so rotating it invalidates - // the copy every teammate is holding. - ...(c.source === 'cli' ? { issuedByCapy: true } : {}), - }; - }); -} - -/** - * The declared route, redrawn as the route the run actually travelled. - * - * The rail on the progress page is documented as "the declared route, with the - * stops travelled marked done", and the CLI was handing over the plan it built - * BEFORE anything ran: on the `deploy-failed` page — the state this screen - * exists for — Rotate, Push and Deploy all still read as stops ahead of the - * traveller, next to a step log saying two of them were done and the third had - * failed. A rail that contradicts the page it sits on is worse than no rail, - * because it is the half that looks authoritative. - * - * The mapping is off `RotateRunStep.state`, which is a four-value enum, and - * never off any of the prose beside it: - * - * ok → done - * skip → skipped, the same struck-through station `--no-push` draws - * fail → current: where the run stopped, and `blank` because the plan - * still has a hole there. `StopState` has no `failed`, and the - * other three would each say something untrue. REPORTED. - * pending → upcoming, which is exactly what it is: queued behind a failure. - * - * Stops with no step — Variable, Integration — are the questions, already - * settled by the time the run began, and are passed through untouched. - */ -export function travelledStops( - stops: RotatePlanStop[], - steps: RotateRunStep[], -): RotateRunStop[] { - const state = new Map(steps.map((s) => [s.id as string, s.state])); - const rotateOk = state.get('rotate') === 'ok'; - return stops.map((stop) => { - // The manual hand-off has no step of its own: `mod.rotate` runs `stripe - // login` inside the Rotate step and only returns a key once the pairing - // came back. So a rotation that produced one went through it, and a rail - // still pointing at Auth would be telling the reader to go and do a thing - // they have already done. - if (stop.id === 'auth') { - return rotateOk ? { ...stop, state: 'done' as const, answer: 'paired' } : stop; - } - const ran = state.get(stop.id); - if (!ran) return stop; - if (ran === 'ok') return { ...stop, state: 'done' as const }; - if (ran === 'skip') return { ...stop, state: 'skipped' as const }; - if (ran === 'fail') return { ...stop, state: 'current' as const, blank: true }; - return { ...stop, state: 'upcoming' as const }; - }); -} - -/** What one pass of `rotateMany` did, so the caller can report it and exit. */ -interface RotateRunReport { - /** Names that rotated. The terminal's own tally counts these. */ - succeeded: string[]; - /** One row per credential the run touched, including ones it never reached. */ - keys: RotateKeyResult[]; - /** - * The run stopped rather than finishing. Under `--web` this replaces the - * inline `process.exit(1)`: exiting there would kill the loopback server - * before the browser could fetch the page that explains what happened. - */ - stopped: boolean; -} - /** * `rotateSequentially`'s accumulator, threaded through the recursion instead - * of the `succeeded`/`failed`/`keys`/`stopped` mutable locals the original - * `for` loop closed over. `failed` never leaves this file — the public - * report only exposes `succeeded`/`keys`/`stopped` (see `RotateRunReport`). + * of the `succeeded`/`failed` mutable locals the original `for` loop closed + * over. `failed` never leaves this file. */ interface RotateLoopState { readonly succeeded: readonly string[]; readonly failed: ReadonlyArray<{ name: string; err: any }>; - readonly keys: readonly RotateKeyResult[]; - readonly stopped: boolean; } diff --git a/src/commands/statusCommand.ts b/src/commands/statusCommand.ts index c2a2e693..ab3f4843 100644 --- a/src/commands/statusCommand.ts +++ b/src/commands/statusCommand.ts @@ -13,6 +13,12 @@ const B = (s: string) => `\x1b[1m${s}\x1b[0m`; type RemoteFailure = 'access_denied' | 'network_error' | 'no_data'; +/** Why the remote column is missing: the sentence a person reads, and the verdict the report branches on. */ +interface RemoteSkip { + reason: string; + kind: RemoteFailure; +} + /** * Why the remote column is missing — from the ERROR, not from its sentence. * @@ -155,7 +161,7 @@ export class StatusCommand { this.serviceClient.setTokenProvider(() => this.authService.getValidToken()); } - async execute(opts: { json?: boolean; web?: boolean } = {}): Promise { + async execute(opts: { json?: boolean } = {}): Promise { try { await this._execute(opts); } catch { @@ -165,7 +171,116 @@ export class StatusCommand { } } - private async _execute(opts: { json?: boolean; web?: boolean } = {}): Promise { + /** The project key, or why it could not be had. Never throws. */ + private async resolveEncryptionKey( + projectState: { userId?: string; organizationId?: string; projectId?: string }, + localMode: boolean, + ): Promise<{ encryptionKey: string } | { skip: RemoteSkip }> { + try { + if (localMode) { + return { encryptionKey: await resolveLocalProjectKey(projectState.projectId!) }; + } + if (projectState.userId) { + this.authService.setSessionUserId(projectState.userId); + } + const { resolveProjectKey } = await import('../crypto/keyResolver'); + const authResult = await this.authService.authenticateSilent(projectState.organizationId); + // The message becomes the skip reason, which the report shows. + // "auth failed" told the reader nothing about whether to sign in again or + // check the network. + if (!authResult.success) throw new Error(silentAuthFailureMessage(authResult)); + + const keyOps = { + coDecrypt: (oid: string, ct: string) => this.serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), + wrapOuterLayer: (oid: string, pt: string) => this.serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), + }; + return { + encryptionKey: await resolveProjectKey( + projectState.organizationId!, + projectState.projectId!, + authResult.user_id!, + keyOps, + ), + }; + } catch (err: any) { + // Auth or key resolution failed — compare pinned vs local only + return { skip: { reason: err?.message || 'auth or key resolution failed', kind: classifyRemoteFailure(err) } }; + } + } + + /** Hashes of the local .env values; a value this key cannot decrypt is left out. */ + private hashLocalEnv(encryptionKey: string): Record { + const rawLocal = this.fileManager.readEnvFile(); + return Object.fromEntries( + Object.entries(rawLocal).flatMap(([key, value]) => { + if (!(value.startsWith('capy:') && encryptionKey)) return [[key, hashValue(value)] as const]; + try { + return [[key, hashValue(this.fileManager.decryptValue(value, encryptionKey))] as const]; + } catch { + return []; + } + }), + ); + } + + /** The key (when there is one) and the hashes of the local .env, or the reason the comparison is local-only. */ + private async readLocalState( + projectState: { userId?: string; organizationId?: string; projectId?: string }, + localMode: boolean, + ): Promise<{ encryptionKey: string | undefined; localHashes: Record; skip: RemoteSkip | undefined }> { + const keyed = await this.resolveEncryptionKey(projectState, localMode); + if ('skip' in keyed) return { encryptionKey: undefined, localHashes: {}, skip: keyed.skip }; + try { + return { encryptionKey: keyed.encryptionKey, localHashes: this.hashLocalEnv(keyed.encryptionKey), skip: undefined }; + } catch (err: any) { + return { + encryptionKey: keyed.encryptionKey, + localHashes: {}, + skip: { reason: err?.message || 'auth or key resolution failed', kind: classifyRemoteFailure(err) }, + }; + } + } + + /** Hashes of the remote (Keep) values for the pinned keep, or why there are none to show. */ + private async fetchRemoteHashes(args: { + keep: KeepFile; + branch: string; + pinned: Record; + encryptionKey: string; + organizationId: string; + projectId: string; + localMode: boolean; + }): Promise<{ remoteHashes: Record; skip: RemoteSkip | undefined }> { + const { keep, branch, pinned, encryptionKey, organizationId, projectId, localMode } = args; + try { + const hasVariables = Object.keys(pinned).length > 0; + if (!hasVariables) return { remoteHashes: {}, skip: undefined }; + const keepHash = SyncEngine.computeKeepHash(keep, branch); + const blob = localMode + ? readSecretsLocal(organizationId, projectId, keepHash) + : await fetchSecretsWithCache(this.serviceClient, organizationId, projectId, keepHash); + if (!blob?.env_file) { + // Known here, so recorded here. Nothing downstream re-reads the + // sentence to work out what this line already knew. + return { remoteHashes: {}, skip: { reason: 'no data at this keep_hash', kind: 'no_data' } }; + } + const encrypted = this.fileManager.parseEnvContent(blob.env_file); + const remoteHashes = Object.fromEntries( + Object.entries(encrypted).flatMap(([key, value]) => { + try { + return [[key, hashValue(this.fileManager.decryptValue(value, encryptionKey))] as const]; + } catch { + return []; // Skip values we can't decrypt + } + }), + ); + return { remoteHashes, skip: undefined }; + } catch (err: any) { + return { remoteHashes: {}, skip: { reason: err?.message || 'network error', kind: classifyRemoteFailure(err) } }; + } + } + + private async _execute(opts: { json?: boolean } = {}): Promise { const projectState = await this.projectManager.detectProjectState(); if (!projectState.initialized) { if (this.terse) return; @@ -185,115 +300,46 @@ export class StatusCommand { const branchLabel = branch; // Build pinned hashes from keep.lock for active branch - const pinned: Record = {}; - for (const [varName, entries] of Object.entries(keep.variables)) { - const entry = entries.find(e => e.branch === branch); - if (entry) { - pinned[varName] = entry.value_hash; - } - } - - // Build local hashes from .env - const localHashes: Record = {}; - let encryptionKey: string | undefined; - // Two facts, deliberately separate: the sentence a person reads, and the - // verdict the report and the screen branch on. Deriving the second from - // the first is what this pair replaces. - let remoteSkipReason: string | undefined; - let remoteFailureKind: RemoteFailure | undefined; + const pinned: Record = Object.fromEntries( + Object.entries(keep.variables).flatMap(([varName, entries]) => { + const entry = entries.find(e => e.branch === branch); + return entry ? [[varName, entry.value_hash] as const] : []; + }), + ); + + // Build local hashes from .env. Two facts, deliberately separate: the + // sentence a person reads, and the verdict the report branches on. + // Deriving the second from the first is what this pair replaces. const localMode = isLocalOnly(); - try { - if (localMode) { - encryptionKey = await resolveLocalProjectKey(projectState.projectId!); - } else { - if (projectState.userId) { - this.authService.setSessionUserId(projectState.userId); - } - const { resolveProjectKey } = await import('../crypto/keyResolver'); - const authResult = await this.authService.authenticateSilent(projectState.organizationId); - // The message becomes `remoteSkipReason`, which the report and the - // screen both show. "auth failed" told the reader nothing about - // whether to sign in again or check the network. - if (!authResult.success) throw new Error(silentAuthFailureMessage(authResult)); - - const keyOps = { - coDecrypt: (oid: string, ct: string) => this.serviceClient.coDecrypt(oid, ct).then(r => r.plaintext), - wrapOuterLayer: (oid: string, pt: string) => this.serviceClient.wrapOuterLayer(oid, pt).then(r => r.ciphertext), - }; - encryptionKey = await resolveProjectKey( - projectState.organizationId!, - projectState.projectId!, - authResult.user_id!, - keyOps, - ); - } - - const rawLocal = this.fileManager.readEnvFile(); - for (const [key, value] of Object.entries(rawLocal)) { - let plaintext = value; - if (value.startsWith('capy:') && encryptionKey) { - try { - plaintext = this.fileManager.decryptValue(value, encryptionKey); - } catch { - continue; - } - } - localHashes[key] = hashValue(plaintext); - } - } catch (err: any) { - // Auth or key resolution failed — compare pinned vs local only - remoteSkipReason = err?.message || 'auth or key resolution failed'; - remoteFailureKind = classifyRemoteFailure(err); - } + const local = await this.readLocalState(projectState, localMode); + const { encryptionKey, localHashes } = local; // Build remote hashes (fetch from Keep) - const remoteHashes: Record = {}; - if (encryptionKey) { - try { - const hasVariables = Object.keys(pinned).length > 0; - if (hasVariables) { - const keepHash = SyncEngine.computeKeepHash(keep, branch); - const blob = localMode - ? readSecretsLocal(projectState.organizationId!, projectState.projectId!, keepHash) - : await fetchSecretsWithCache( - this.serviceClient, - projectState.organizationId!, - projectState.projectId!, - keepHash, - ); - if (blob?.env_file) { - const encrypted = this.fileManager.parseEnvContent(blob.env_file); - for (const [key, value] of Object.entries(encrypted)) { - try { - const plaintext = this.fileManager.decryptValue(value, encryptionKey); - remoteHashes[key] = hashValue(plaintext); - } catch { - // Skip values we can't decrypt - } - } - } else { - remoteSkipReason = 'no data at this keep_hash'; - // Known here, so recorded here. Nothing downstream re-reads the - // sentence to work out what this line already knew. - remoteFailureKind = 'no_data'; - } - } - } catch (err: any) { - remoteSkipReason = err?.message || 'network error'; - remoteFailureKind = classifyRemoteFailure(err); - } - } + const remote = encryptionKey + ? await this.fetchRemoteHashes({ + keep, + branch, + pinned, + encryptionKey, + organizationId: projectState.organizationId!, + projectId: projectState.projectId!, + localMode, + }) + : { remoteHashes: {} as Record, skip: undefined }; + const { remoteHashes } = remote; + // The remote step's skip, when it has one, is the later write and wins. + const skip = remote.skip ?? local.skip; + const remoteSkipReason = skip?.reason; const hasRemote = Object.keys(remoteHashes).length > 0; const remoteFailure: RemoteFailure | undefined = - !hasRemote && remoteSkipReason ? (remoteFailureKind ?? 'network_error') : undefined; + !hasRemote && remoteSkipReason ? (skip?.kind ?? 'network_error') : undefined; const { diffs, showLocal, showRemote } = compareSecrets(pinned, localHashes, remoteHashes); - // ONE report object, rendered three ways. `--json` prints it, `--web` - // carries it into the page verbatim, and the TTY draws the same numbers - // below — so what a person reads and what a script parses cannot describe - // different states. diffs carry value HASHES only (sha256 prefix), never - // plaintext. + // ONE report object, rendered two ways. `--json` prints it, and the TTY + // draws the same numbers below — so what a person reads and what a script + // parses cannot describe different states. diffs carry value HASHES only + // (sha256 prefix), never plaintext. const totalSecrets = new Set([...Object.keys(pinned), ...Object.keys(localHashes)]).size; const report = { projectName: keep.project_name, @@ -311,33 +357,6 @@ export class StatusCommand { return; } - if (opts.web) { - // No TTY under --web (this is the agent-driven path), so the report goes - // to the browser instead of to a stream nobody is reading. The page is - // display-only: it posts nothing, carries no nonce, and is served under - // the strict policy that cannot open a socket at all. - const { showSyncStatusInBrowser } = await import('../ui/syncScreens'); - const { checkExpiringKeys } = await import('./connectors/shared'); - await showSyncStatusInBrowser({ - projectName: keep.project_name ?? '', - branch, - totalSecrets, - localMatchesPinned: !showLocal, - remoteMatchesPinned: !showRemote, - hasRemote, - remoteFailure, - diffs, - // The warnings `printExpiryWarnings` puts on stderr after every run, - // where they can still be acted on. - expiring: checkExpiringKeys().map(k => ({ variable: k.varName, expiresInDays: k.expiresIn })), - json: JSON.stringify(report, null, 2), - // Open the user's browser by default; CAPY_WEB_NO_OPEN lets CI / - // headless verification drive the loopback without hijacking one. - open: !process.env.CAPY_WEB_NO_OPEN, - }); - return; - } - if (this.terse) { // Terse mode for git hooks if (diffs.length === 0) return; // Silent when synced @@ -402,29 +421,7 @@ export class StatusCommand { : remoteFailure === 'no_data' ? '(no data)' : undefined; for (const diff of diffs) { - let prefix: string; - let desc: string; - - if (failureLabel) { - prefix = '?'; - desc = failureLabel; - } else if (diff.type === 'new') { - prefix = '+'; - if (diff.remote && !diff.pinned) desc = '(new on remote)'; - else if (diff.local && !diff.pinned) desc = '(new locally)'; - else desc = '(new)'; - } else if (diff.type === 'deleted') { - prefix = '-'; - if (!diff.remote && diff.pinned) desc = '(missing from remote)'; - else if (!diff.local && diff.pinned) desc = '(missing locally)'; - else desc = '(missing)'; - } else { - prefix = '~'; - if (diff.local !== diff.pinned && diff.remote === diff.pinned) desc = '(changed locally)'; - else if (diff.remote !== diff.pinned && diff.local === diff.pinned) desc = '(changed on remote)'; - else desc = '(changed)'; - } - + const { prefix, desc } = describeDiff(diff, failureLabel); console.log(` ${prefix} ${diff.variable.padEnd(20)} ${desc}`); } @@ -442,3 +439,21 @@ export class StatusCommand { process.exit(0); } } + +/** The marker and the wording for one diff row. `failureLabel` replaces both when the remote could not be read. */ +function describeDiff(diff: DiffResult, failureLabel: string | undefined): { prefix: string; desc: string } { + if (failureLabel) return { prefix: '?', desc: failureLabel }; + if (diff.type === 'new') { + if (diff.remote && !diff.pinned) return { prefix: '+', desc: '(new on remote)' }; + if (diff.local && !diff.pinned) return { prefix: '+', desc: '(new locally)' }; + return { prefix: '+', desc: '(new)' }; + } + if (diff.type === 'deleted') { + if (!diff.remote && diff.pinned) return { prefix: '-', desc: '(missing from remote)' }; + if (!diff.local && diff.pinned) return { prefix: '-', desc: '(missing locally)' }; + return { prefix: '-', desc: '(missing)' }; + } + if (diff.local !== diff.pinned && diff.remote === diff.pinned) return { prefix: '~', desc: '(changed locally)' }; + if (diff.remote !== diff.pinned && diff.local === diff.pinned) return { prefix: '~', desc: '(changed on remote)' }; + return { prefix: '~', desc: '(changed)' }; +} diff --git a/src/core/branchCreatePlan.ts b/src/core/branchCreatePlan.ts index 9e7258ef..a95b7a4f 100644 --- a/src/core/branchCreatePlan.ts +++ b/src/core/branchCreatePlan.ts @@ -1,21 +1,15 @@ /** * The route `capy checkout -b ` will travel, computed before anything - * opens. + * runs, and printed by `capy checkout -b --json`. * - * ONE function, because the whole claim behind the browser screens is that the - * rail a person reads and the array a headless caller parses are the same - * object. Two builders — one for the page, one for `--json` — would make that - * a promise nobody could keep past the first divergence. So the plan is built - * here, and both surfaces render what this returns. + * ONE function, so the array a headless caller parses is built in one place. * * The precedence is §8.2's, and it is the reason a stop can arrive already * answered: an explicit flag settles a stop before the run starts, existing - * state settles the next ones, the browser is asked about whatever is left, - * and a headless run with a blank remaining refuses rather than guessing. A - * flag-answered stop is `done` carrying the flag that supplied it — never - * `skipped`, because the plan resolved it rather than dropping it, and - * "Protection · protected" with no marker is indistinguishable from a question - * the user answered two seconds ago. + * state settles the next ones, and a headless run with a blank remaining + * refuses rather than guessing. A flag-answered stop is `done` carrying the + * flag that supplied it — never `skipped`, because the plan resolved it rather + * than dropping it. */ import type { BranchCreateStop } from '../ui/screens/contract'; diff --git a/src/core/decryptPlan.ts b/src/core/decryptPlan.ts deleted file mode 100644 index 95585abe..00000000 --- a/src/core/decryptPlan.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * The route `capy decrypt` travels, computed before the phrase box opens. - * - * Same rule as `branchCreatePlan`: one builder, so the rail a person reads and - * the array a headless caller parses cannot describe different runs. - * - * Two of the three details are per-run and could never be a constant — the - * phrase stop counts the words this run expects, and the write stop names the - * file the plaintext lands in. A run with no active branch has no path to - * name, and that stop then carries no detail rather than an empty one. - * - * `phrase` is `skipped`, not dropped, when a recovery session already holds the - * master key. The terminal skips it silently — it reuses a cached key with no - * prompt and no mention — and a route that simply omitted the station would - * make the browser silent in the same way. - */ -import type { SeedPhraseDecryptStop } from '../ui/screens/contract'; - -export interface DecryptPlanInput { - /** How many words the phrase step expects. 24 in the CLI; never assumed. */ - wordCount: number; - /** `.env.{branch}.decrypted`, or null when there is no active branch to name. */ - outputFile: string | null; - /** An unexpired recovery session already holds the key, so nothing is typed. */ - usingSession: boolean; - /** The run has written (or refused to write) the file. */ - finished?: boolean; -} - -export function decryptPlan(input: DecryptPlanInput): SeedPhraseDecryptStop[] { - const phrase: SeedPhraseDecryptStop = { - id: 'phrase', - label: 'Phrase', - state: input.finished - ? input.usingSession - ? 'skipped' - : 'done' - : input.usingSession - ? 'skipped' - : 'current', - detail: `${input.wordCount} words, typed here`, - }; - - const decrypt: SeedPhraseDecryptStop = { - id: 'decrypt', - label: 'Decrypt', - state: input.finished ? 'done' : input.usingSession ? 'current' : 'upcoming', - detail: 'try each key version against this .env', - }; - - const write: SeedPhraseDecryptStop = { - id: 'write', - label: 'Write', - state: input.finished ? 'done' : 'upcoming', - // A stop with nothing to say says nothing rather than saying "". - ...(input.outputFile ? { detail: input.outputFile } : {}), - }; - - return [phrase, decrypt, write]; -} diff --git a/src/core/deployPlan.ts b/src/core/deployPlan.ts index d41a4313..360a45fa 100644 --- a/src/core/deployPlan.ts +++ b/src/core/deployPlan.ts @@ -1,17 +1,9 @@ /** - * The route `capy deploy` travels, computed before anything opens. + * The route `capy deploy` travels, computed before anything runs, and emitted + * by `capy deploy --json`. * - * ONE function, because three screens draw this rail — `deploy-destination`, - * `deploy-target-setup`, `deploy-plan-confirm` — and until now each of them - * carried its own copy of the station list in a lookup table inside its - * `Screen.svelte`. Three copies of one route is three chances for the rail to - * say something different depending on which page you happen to be standing - * on, and none of the three was the CLI's: the command that decides how many - * questions there are had no say in the diagram claiming to describe it. - * - * So the plan is built here, and both the browser payload and `--json` render - * what this returns. The screens draw it and may say where the traveller is - * STANDING; they may not add a station, drop one, or reword a detail. + * ONE function: the array an agent parses is built here, from argv and + * `.capy/deploy.json` alone. * * Why the whole route, including the parts a run will not travel: the terminal * asks its deploy questions as a run of `inquirer` prompts with nothing @@ -134,30 +126,10 @@ export function deployPlan(input: DeployPlanInput = {}): DeployPlanConfirmStop[] * about what is outstanding. * * Called by `describeDeployRoute` in deployCommand.ts, which is what - * `capy deploy --json` emits — the same array the three browser screens draw. - * That is the whole claim: the rail a person reads and the array an agent - * parses come out of one builder. (argv wiring for the flag lives in - * src/index.ts, which the coordinator owns, exactly as `--web` does.) + * `capy deploy --json` emits. */ export function unansweredDeployStops(stops: DeployPlanConfirmStop[]): string[] { return stops .filter((s) => s.state !== 'done' && s.state !== 'skipped' && s.id !== 'deploy') .map((s) => s.id); } - -/** - * What the manual sign-in stop was, per adapter. - * - * `signin` is the one station Capy does not perform: the vendor CLI holds its - * own session and the user establishes it in their own terminal. Preflight is - * where Capy finds out whether that happened, so the stop stays `upcoming` - * until preflight passes and then carries the command that made it true. - */ -export const SIGNIN_COMMAND: Readonly> = { - 'cf-worker': 'wrangler login', - 'cf-pages': 'wrangler login', - vercel: 'vercel link', - 'aws-ssm': 'aws configure', - 'gh-actions': 'gh auth login', - dokploy: 'export DOKPLOY_API_KEY=', -}; diff --git a/src/core/initWizardPlan.ts b/src/core/initWizardPlan.ts deleted file mode 100644 index 8b1bae4e..00000000 --- a/src/core/initWizardPlan.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * The route `capy` travels on its first run in a directory, computed before - * anything opens. - * - * ONE function, for the reason `branchCreatePlan` gives: the rail a person - * reads and the array a headless caller would parse have to be the same - * object, and two builders stop being the same object at the first divergence. - * - * The route is TEN stops and the CLI asks at most six questions on it. That - * gap is the point. `initializeProject` is six `inquirer` prompts in a row with - * nothing saying how many are left, which side of a fork this run is on, or - * that the recovery phrase about to scroll past is the only copy that will - * ever exist. A stop this run will not visit is drawn `skipped` rather than - * dropped, and a stop whose fork is not settled yet is `blank` — the CLI's own - * ◌ — because "we do not know yet" and "this will not happen" are different - * answers and a rail that renders them the same is lying about one of them. - * - * Four stops are not questions the CLI asks: - * - * auth settled before the wizard opens — `capy` authenticates first and - * the browser only opens once there is a session - * recovery the 24 words are shown and confirmed by `orgCreation.ts`, on its - * own surface, so the stop is `manual`: it happens, and it does - * not happen here - * redeem a key this device does not hold. The CLI refuses with `capy - * redeem `; the stop exists so the refusal lands on a station - * that was drawn from the start - * encrypt skipped when there is no .env to encrypt, which is a fact the - * directory already knows before a browser opens - */ -import type { InitWizardStop } from '../ui/screens/contract'; - -/** Which side of the organization fork this run took. */ -export type InitChoice = { kind: 'existing'; name: string } | { kind: 'new'; name?: string }; - -/** - * What the run has answered or discovered so far. - * - * Every field is optional and `undefined` means "not known yet" rather than - * "no" — the distinction the `blank` state exists to draw. The plan is - * recomputed from this after every answer, so no caller ever decides a stop's - * state itself. - */ -export interface InitWizardInput { - /** Who the run authenticated as. Shown on the `auth` stop, which is done. */ - signedInAs?: string; - /** Organizations this session can reach. 0 means the CLI never asks. */ - orgCount?: number; - organization?: InitChoice; - /** The 24 words have been printed in the terminal. */ - recoveryShown?: boolean; - /** Whether this device holds the organization's key. Checked after the org stop. */ - hasOrgKey?: boolean; - /** Projects listed in the chosen org. Undefined until the lookup runs. */ - projectCount?: number; - /** - * The project lookup failed. The CLI swallows that error and proceeds as if - * the org had none, so the stop is skipped either way — but for a different - * reason, and the payload says which. - */ - projectsUnavailable?: boolean; - project?: InitChoice; - branchChoice?: 'development' | 'other'; - branchName?: string; - /** Variables in the .env sitting in this directory. Undefined until read. */ - localEnvCount?: number; - /** The answer to the encrypt-and-push consent gate. */ - encrypt?: boolean; -} - -/** A stop before the cursor pass: `state` is decided in one place, below. */ -type Draft = Omit & { state?: InitWizardStop['state'] }; - -/** Creating a new project — either chosen, or the only thing this org offers. */ -function creatingProject(i: InitWizardInput): boolean | undefined { - if (i.project) return i.project.kind === 'new'; - if (i.projectsUnavailable) return true; - if (i.projectCount === 0) return true; - if (i.projectCount === undefined) return undefined; - return undefined; -} - -export function initWizardPlan(i: InitWizardInput): InitWizardStop[] { - const newOrg = i.organization?.kind === 'new'; - const existingOrg = i.organization?.kind === 'existing'; - const newProject = creatingProject(i); - const existingProject = i.project?.kind === 'existing'; - - const drafts: Draft[] = [ - { - id: 'auth', - label: 'Sign in', - // Already true by the time anything is served: `capy` authenticates - // before it asks its first question, and the browser opens after that. - state: 'done', - detail: 'capy.sc, in your browser', - answer: i.signedInAs, - }, - { - id: 'organization', - label: 'Organization', - // An account with no organization is never asked which one to use; the - // CLI says "No organization found. Let's create one." and creates it. - state: i.orgCount === 0 ? 'skipped' : i.organization ? 'done' : undefined, - detail: 'which organization this project belongs to', - answer: i.organization?.kind === 'existing' ? i.organization.name : i.organization ? 'a new organization' : undefined, - }, - { - id: 'organization-name', - label: 'Organization name', - state: existingOrg ? 'skipped' : newOrg ? (i.organization?.name ? 'done' : undefined) : undefined, - blank: i.organization === undefined, - detail: 'name the organization this project will belong to', - answer: newOrg ? i.organization?.name : undefined, - }, - { - id: 'recovery', - label: 'Recovery phrase', - state: existingOrg ? 'skipped' : newOrg ? (i.recoveryShown ? 'done' : undefined) : undefined, - blank: i.organization === undefined, - // The words are shown and confirmed somewhere else entirely — their own - // page under `--web`, the terminal without it — and never reach this - // payload. The dotted track is the honest drawing of that: the stop - // happens, and it does not happen here. - manual: true, - detail: '24 words, shown once and never again', - }, - { - id: 'redeem', - label: 'Redeem a code', - // Most sign-ins hold the key already and never see this. It is on the - // route so a run that does NOT hold it is refused at a station that was - // drawn from the start, rather than mid-flow at a stop nobody declared. - state: i.hasOrgKey === true ? 'skipped' : undefined, - blank: i.hasOrgKey === undefined, - manual: true, - detail: 'receive this organization\'s key on this device', - }, - { - id: 'project', - label: 'Project', - // No projects to choose between (or the lookup failed): the CLI goes - // straight to naming a new one. - state: - i.project ? 'done' - : i.projectsUnavailable || i.projectCount === 0 ? 'skipped' - : undefined, - blank: i.projectCount === undefined && i.project === undefined, - detail: 'a project this organization already has, or a new one', - answer: i.project?.kind === 'existing' ? i.project.name : i.project ? 'New project' : undefined, - }, - { - id: 'project-name', - label: 'Project name', - state: newProject === false ? 'skipped' : newProject ? (i.project?.name ? 'done' : undefined) : undefined, - blank: newProject === undefined, - detail: 'name the project this directory becomes', - answer: i.project?.kind === 'new' ? i.project.name : undefined, - }, - { - id: 'branch', - label: 'First branch', - // Bootstrapping an existing project pulls its development branch and - // returns; there is no first branch to choose. - state: existingProject ? 'skipped' : i.branchChoice ? 'done' : undefined, - blank: newProject === undefined, - detail: 'the branch this project starts on', - answer: i.branchChoice === 'development' ? 'development' : i.branchChoice ? 'another branch' : undefined, - }, - { - id: 'branch-name', - label: 'Branch name', - state: - existingProject ? 'skipped' - : i.branchChoice === 'development' ? 'skipped' - : i.branchChoice === 'other' ? (i.branchName ? 'done' : undefined) - : undefined, - blank: i.branchChoice === undefined, - detail: 'name the branch this project starts on', - answer: i.branchName, - }, - { - id: 'encrypt', - label: 'Encrypt and push', - state: - existingProject ? 'skipped' - : i.localEnvCount === 0 ? 'skipped' - : i.encrypt !== undefined ? 'done' - : undefined, - blank: i.localEnvCount === undefined, - detail: 'rewrite .env as ciphertext and store it', - answer: i.encrypt === undefined ? undefined : i.encrypt ? 'yes' : 'no', - }, - ]; - - // One cursor pass, in one place: the first stop that is neither settled nor - // skipped is where the traveller stands, and everything after it is ahead. - let standing = false; - return drafts.map((d) => { - const { blank, ...rest } = d; - let state = d.state; - if (!state) { - state = standing ? 'upcoming' : 'current'; - standing = true; - } - const stop: InitWizardStop = { ...rest, state }; - // A settled stop is not blank, whatever the fork looked like on the way in. - if (blank && state !== 'done' && state !== 'skipped') stop.blank = true; - return stop; - }); -} - -// There was an `unansweredInitStops(stops)` here, the way `branchCreatePlan` -// has one. It is gone: the root `capy` command has no `--json` and no flags -// for any of these stops, so nothing headless ever consumed it and nothing -// but its own test ever called it. The moment a caller needs it, it is four -// lines — and it will then be written against a consumer that exists. diff --git a/src/core/invitePlan.ts b/src/core/invitePlan.ts index bb7a1c89..fda26aff 100644 --- a/src/core/invitePlan.ts +++ b/src/core/invitePlan.ts @@ -1,32 +1,23 @@ /** - * The route `capy invite ` will travel, computed before anything opens. + * The route `capy invite ` will travel, computed before anything is + * asked, and described again at the end of the run in `--json`. * - * ONE function, for the reason `branchCreatePlan` gives: the rail a person - * reads and the array a headless caller parses have to be the same object, and - * two builders — one for the page, one for `--json` — would make that a promise - * nobody could keep past the first divergence. + * ONE function, for the reason `branchCreatePlan` gives: the array a headless + * caller parses has to be the same object whatever built it. * - * The precedence is §8.2's, and it is why a stop can arrive already answered: - * an explicit flag settles a stop before the run starts, an existing membership - * settles the next ones, the browser is asked about whatever is left, and a - * headless run with a blank remaining falls back or refuses rather than - * guessing. Every settled stop is `done` carrying WHAT settled it — never - * `skipped`, because the plan resolved it rather than dropping it, and `Role · - * admin` with no marker is indistinguishable from a question the user answered - * two seconds ago. An answer the browser gave carries no marker precisely - * because there is nothing to explain: the user was there. + * An explicit flag settles a stop before the run starts, an existing + * membership settles the next ones, and a headless run with a blank remaining + * falls back or refuses rather than guessing. Every settled stop is `done` + * carrying WHAT settled it — never `skipped`, because the plan resolved it + * rather than dropping it. * * THE EXPIRY STOP IS AN ADDITION, NOT A PORT. `capy invite` has exactly two * prompts, role and projects. `resolveNotAfter` reads `--expires`, then * `--ttl`, then `CAPY_INVITE_TTL_SECONDS`, then a 7-day default, and it NEVER - * asks — so on a terminal run the stop is settled before the command starts and - * the rail says which of those four settled it. That is the point of drawing - * it: the env override and the service's 30-day cap both change how long a code - * stays redeemable without saying so anywhere, and an invite that outlives its - * purpose is a key left in a door. Under `--web`, where there IS somewhere to - * ask, it becomes a question. `contract.ts`'s `InviteStop` sanctions both: "A - * CLI implementing this contract may serve it or skip it; skipping it is not a - * regression." + * asks — so the stop is settled before the command starts and the plan says + * which of those four settled it. The env override and the service's 30-day cap + * both change how long a code stays redeemable without saying so anywhere, and + * an invite that outlives its purpose is a key left in a door. */ import type { InviteTeammateStop } from '../ui/screens/contract'; @@ -42,32 +33,11 @@ const ORG_WIDE_ROLES: ReadonlySet = new Set(['admin', 'owner', 'org-admi /** Whether an invite with this role has to name projects. */ export const roleNeedsProjects = (role: string): boolean => !ORG_WIDE_ROLES.has(role); -/** - * Which projects an invite ends up granting. - * - * A function rather than three lines at the call site, because two of the three - * cases are quiet failures. `--project` SETTLES the projects stop, so a `--web` - * run never serves that step and the browser's answer comes back empty — - * reading that emptiness as "no projects" is how `capy invite bob --project - * storefront --web` grants nothing at all and says it succeeded. And an - * org-wide role takes none whatever a flag said, which the terminal path - * achieves by never entering its project block; here it has to be said. - */ -export function grantedProjects( - role: string, - answeredInBrowser: string[], - fromProjectFlag: string[], -): string[] { - if (!roleNeedsProjects(role)) return []; - return answeredInBrowser.length > 0 ? answeredInBrowser : fromProjectFlag; -} - /** * An answer the run already holds, and where it came from. * - * `flag` absent means nobody has to be told why they were not asked — the - * browser asked, and they answered. Present, it is the literal text the caller - * could find in their own shell history, or the state that stood in for one. + * `flag`, when present, is the literal text the caller could find in their own + * shell history, or the state that stood in for one. */ export interface SettledAnswer { value: string; @@ -75,7 +45,7 @@ export interface SettledAnswer { } export interface InvitePlanInput { - /** `--role`, an inherited membership role, or a role the browser answered. */ + /** `--role`, or an inherited membership role. */ role?: SettledAnswer; /** * Project NAMES this invite will grant, once something settled them. @@ -87,20 +57,12 @@ export interface InvitePlanInput { * hiding the failure rather than reporting it. */ projects?: { names: string[]; flag?: string; note?: string }; - /** `--expires` / `--ttl`, or a lifetime the browser answered. */ + /** `--expires` / `--ttl`. */ expiry?: SettledAnswer; /** `CAPY_INVITE_TTL_SECONDS` rendered in `--ttl`'s own vocabulary, when set. */ envTtl?: string; /** What `resolveNotAfter` would use with no flag at all: `12h`, or the env's. */ defaultTtl: string; - /** - * Whether this run has anywhere to ask about expiry. - * - * False for every terminal run — `resolveNotAfter` does not prompt — so the - * stop arrives `done`, marked with whichever source settled it. True only - * under `--web`, where a browser can hold the question. - */ - canAskExpiry: boolean; } const LABEL: Record = { @@ -141,56 +103,37 @@ function unpromptedExpiry(input: InvitePlanInput): SettledAnswer { return { value: input.defaultTtl, flag: 'default' }; } -export function invitePlan(input: InvitePlanInput): InviteTeammateStop[] { - const stops: InviteTeammateStop[] = []; - - stops.push(input.role ? settled('role', input.role) : stop('role', 'upcoming')); +function roleStop(input: InvitePlanInput): InviteTeammateStop { + return input.role ? settled('role', input.role) : stop('role', 'upcoming'); +} +function projectsStop(input: InvitePlanInput): InviteTeammateStop { // An org-wide role never visits the project stop, and saying so up front is // the point of declaring the whole route rather than dropping the station. // Until a role is settled there is no way to know, so the stop stands. if (input.role && !roleNeedsProjects(input.role.value)) { - stops.push(stop('projects', 'skipped', { detail: 'not asked: this role reaches every project' })); - } else if (input.projects && input.projects.names.length > 0) { + return stop('projects', 'skipped', { detail: 'not asked: this role reaches every project' }); + } + if (input.projects && input.projects.names.length > 0) { const p = settled('projects', { value: input.projects.names.join(', '), flag: input.projects.flag }); - stops.push(input.projects.note ? { ...p, detail: input.projects.note } : p); - } else { - stops.push(stop('projects', 'upcoming')); + return input.projects.note ? { ...p, detail: input.projects.note } : p; } + return stop('projects', 'upcoming'); +} - stops.push( - input.expiry - ? settled('expiry', input.expiry) - : input.canAskExpiry - ? stop('expiry', 'upcoming') - : settled('expiry', unpromptedExpiry(input)), - ); - - stops.push(stop('code', 'upcoming')); +export function invitePlan(input: InvitePlanInput): InviteTeammateStop[] { + const stops = [ + roleStop(input), + projectsStop(input), + input.expiry ? settled('expiry', input.expiry) : settled('expiry', unpromptedExpiry(input)), + stop('code', 'upcoming'), + ]; // Only the first unanswered station is where the traveller stands. Derived // here rather than set by each branch above, so a stop cannot be marked // `current` while an earlier one is still outstanding. - const first = stops.find((s) => s.state === 'upcoming'); - if (first) first.state = 'current'; - - return stops; -} - -/** - * The stops this run still has to ask about. - * - * `code` is excluded because it is the terminus rather than a question — the - * same way `create` is excluded from `unansweredStops`. A `skipped` stop is - * excluded too: the plan decided this run never visits it, which is settled, - * not outstanding. Anything left here is something no flag and no existing - * membership answered, so a run with nowhere to ask has to fall back or refuse - * rather than pick for the user. - */ -export function unansweredInviteStops(stops: InviteTeammateStop[]): string[] { - return stops - .filter((s) => s.id !== 'code' && s.state !== 'done' && s.state !== 'skipped') - .map((s) => s.id); + const firstUpcoming = stops.findIndex((s) => s.state === 'upcoming'); + return stops.map((s, i) => (i === firstUpcoming ? { ...s, state: 'current' as const } : s)); } // --------------------------------------------------------------------------- @@ -201,9 +144,8 @@ export function unansweredInviteStops(stops: InviteTeammateStop[]): string[] { * Parse `--ttl`'s grammar — `30s` / `10m` / `2h` / `12h`, or bare seconds — to * milliseconds. * - * Pure, and returns null rather than exiting, because the browser has to be - * able to reject an answer without taking the process down with it. The - * command's `--ttl` validation calls this and keeps its exit. + * Pure, and returns null rather than exiting. The command's `--ttl` + * validation calls this and keeps its exit. */ export function parseTtl(raw: string): number | null { const m = raw.trim().match(/^(\d+)\s*(s|m|h|d)?$/i); @@ -226,26 +168,3 @@ export function formatTtl(ms: number): string { if (ms % 60000 === 0) return `${ms / 60000}m`; return `${Math.round(ms / 1000)}s`; } - -/** - * A future instant in words: "in 7 days". - * - * `formatRelativeTime` only looks backwards ("3 days ago"); an expiry is always - * ahead, and rendering it through that function is how a screen ends up telling - * somebody their freshly minted code lapsed just now. - */ -export function formatRelativeFuture(atMs: number, now: number = Date.now()): string { - const seconds = Math.floor((atMs - now) / 1000); - if (seconds <= 0) return 'in the past'; - if (seconds < 60) return 'in under a minute'; - - const unit = (n: number, word: string) => `in ${n} ${word}${n === 1 ? '' : 's'}`; - - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return unit(minutes, 'minute'); - - const hours = Math.floor(minutes / 60); - if (hours < 24) return unit(hours, 'hour'); - - return unit(Math.floor(hours / 24), 'day'); -} diff --git a/src/core/onboardingPlan.ts b/src/core/onboardingPlan.ts deleted file mode 100644 index ee1a6654..00000000 --- a/src/core/onboardingPlan.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * The routes the org / onboarding commands travel, computed before anything - * opens a browser. - * - * FOUR pure functions, for the reason `branchCreatePlan` is one: the rail a - * person reads and the array a headless caller parses have to be the same - * object, and two builders — one for the page, one for `--json` — make that a - * promise nobody can keep past the first divergence. Nothing in here touches - * the network, the filesystem or a screen; it takes what a run has answered so - * far and says what the whole journey is, including the stops this run will - * not visit. - * - * A stop this run skips is declared `skipped` rather than dropped. Dropping it - * would make the rail describe the run instead of the route, and "why was I - * never asked about the certificate?" is a question the route answers and a - * filtered list does not. - * - * `detail` is only on the byoc rail. The generated contract's base `Stop` - * carries id/label/state/answer and nothing else, while the kit's own `Stop` - * renders `detail`, `manual`, `blank` and `flag`; only `ByocStop` re-declares - * them on the CLI side. So the byoc route says what happens at each station and - * the other three draw bare labels — which is the honest result of a contract - * that does not carry the field, not a decision made here. - */ -import type { ByocStop, Stop } from '../ui/screens/contract'; - -// --------------------------------------------------------------------------- -// capy org — pick an organization, then a project -// --------------------------------------------------------------------------- - -/** - * The stops that exist only on the create-a-new-organization branch. - * - * Shipped to the screen as `createStopIds` so it can strike them through while - * an existing organization is selected and light them up when the create row - * is. The knowledge of which route a stop belongs to stays with the code that - * computed the route, rather than being hardcoded in the screen that draws it. - */ -export const ORG_CREATE_STOP_IDS = ['name', 'phrase', 'create']; - -export interface OrgSwitchInput { - /** The organization chosen so far, by name. Undefined means unanswered. */ - orgName?: string; - /** - * The create-a-new-organization row is the one selected, and its three stops - * are ahead of this run. - * - * Applied client-side rather than here on the live picker: the CLI serves the - * list once and the screen re-labels `createStopIds` as the selection moves, - * because that transition happens between two renders the command never sees. - * The command's own use of this input is the moment the row is submitted. - */ - creating?: boolean; - /** - * The create route has been WALKED: the organization was named, its recovery - * phrase was shown and confirmed, and it exists. - * - * Distinct from `creating`, which puts those stops ahead of the traveller. - * `nameFirstProjectInBrowser` is served immediately after all three happened, - * and a rail that struck them through there would tell a user who had just - * written down 24 words that the step was "not needed". - */ - created?: boolean; - /** The project chosen (or named) so far. Undefined means unanswered. */ - projectName?: string; -} - -/** - * `capy org`'s whole route, create branch included. - * - * The create stops are on the map from the start, per §8.1: choosing "create a - * new organization" is not a discovery. Without them the rail promised - * "Organization → Project" to an account with no organizations, whose only - * reachable path is four stops long and includes a recovery-phrase reveal that - * cannot be undone. - * - * The `project` stop is declared on every run because this CLI asks for a - * project on every run — including one where the directory already has a - * keep.lock. CAP-316 will change that; until it does, a route that omitted the - * stop would be describing a command that does not exist yet. - */ -export function orgSwitchPlan(input: OrgSwitchInput): Stop[] { - const created = input.created === true; - const creating = input.creating === true && !created; - const orgAnswered = creating || created || (input.orgName ?? '') !== ''; - const projectAnswered = (input.projectName ?? '') !== ''; - - const org: Stop = orgAnswered - ? { - id: 'org', - label: 'Organization', - state: 'done', - // The create row's own wording, verbatim from the screen's option list, - // so the rail names what was picked rather than paraphrasing it. Once - // the organization exists it has a name, and the name is the answer. - answer: creating ? 'Create a new organization' : input.orgName!, - } - : { id: 'org', label: 'Organization', state: 'current' }; - - /** - * A create-only stop, in one of its three lives: struck through on the switch - * branch, ahead of the traveller while the create row is merely selected, and - * behind them once the route has been walked. - */ - const createStop = (id: string, label: string, answer?: string): Stop => ({ - id, - label, - state: created ? 'done' : creating ? 'upcoming' : 'skipped', - ...(created && answer ? { answer } : {}), - }); - - return [ - org, - createStop('name', 'Name', input.orgName), - // No `answer` beyond the consent, ever: see `createOrgPlan`. - createStop('phrase', 'Recovery phrase', 'written down'), - createStop('create', 'Create', input.orgName), - projectAnswered - ? { id: 'project', label: 'Project', state: 'done', answer: input.projectName! } - : { - id: 'project', - label: 'Project', - // Only the first unanswered stop is where the traveller stands, and - // while the create branch is still ahead, three stations sit in front - // of this one. Once they are behind, this is where the run is. - state: orgAnswered && !creating ? 'current' : 'upcoming', - }, - ]; -} - -// --------------------------------------------------------------------------- -// Creating an organization — name, phrase, create -// --------------------------------------------------------------------------- - -export interface CreateOrgInput { - /** The name settled so far. Undefined or blank means unanswered. */ - name?: string; - /** The recovery phrase has been shown and written down. */ - confirmed?: boolean; -} - -/** - * The three stations of `capy org` → "Create new organization +". - * - * Declared before the phrase is generated so a user knows a write-it-down step - * is coming while there is still somewhere to put the pen. - */ -export function createOrgPlan(input: CreateOrgInput): Stop[] { - const named = (input.name ?? '').trim() !== ''; - const confirmed = input.confirmed === true; - - return [ - named - ? { id: 'name', label: 'Name', state: 'done', answer: input.name!.trim() } - : { id: 'name', label: 'Name', state: 'current' }, - { - id: 'phrase', - label: 'Recovery phrase', - state: confirmed ? 'done' : named ? 'current' : 'upcoming', - // No `answer`. The answer to this stop is 24 words of master key, and a - // rail that echoed any of it would defeat the page it is drawn on. The - // consent is the only thing that happened here that can be written down. - ...(confirmed ? { answer: 'written down' } : {}), - }, - { id: 'create', label: 'Create', state: 'upcoming' }, - ]; -} - -// --------------------------------------------------------------------------- -// capy byoc — local mode -// --------------------------------------------------------------------------- - -/** The CLI's own menu wording, carried to the rail so both surfaces agree. */ -export const PHRASE_SOURCE_LABELS: Record<'generate' | 'enter', string> = { - generate: 'Generate a new recovery phrase', - enter: 'Enter an existing recovery phrase', -}; - -export interface LocalOnboardingInput { - /** Which source this run took, once chosen. */ - source?: 'generate' | 'enter'; - /** The phrase step is behind us: written down, or typed and accepted. */ - phraseSettled?: boolean; - /** The passphrase was accepted and the key written. */ - passphraseSettled?: boolean; -} - -/** - * `capy byoc --web`'s route: where the phrase comes from, the phrase itself, a - * passphrase, and the write. - * - * The `replace-key` station the screen can draw is deliberately NOT here. - * Nothing in this CLI checks whether a key already exists — `saveLocalKeyRecord` - * overwrites unconditionally and `hasLocalKey` has no callers — so a run never - * reaches that decision, and declaring a stop the command has never considered - * would be the rail claiming knowledge the command does not have. - */ -export function localOnboardingPlan(input: LocalOnboardingInput): Stop[] { - const sourced = input.source !== undefined; - const phraseDone = input.phraseSettled === true; - const passDone = input.passphraseSettled === true; - - return [ - sourced - ? { - id: 'source', - label: 'Phrase source', - state: 'done', - answer: PHRASE_SOURCE_LABELS[input.source!], - } - : { id: 'source', label: 'Phrase source', state: 'current' }, - { - id: 'phrase', - label: 'Recovery phrase', - // No `answer` on this stop, ever: see `createOrgPlan`. - state: phraseDone ? 'done' : sourced ? 'current' : 'upcoming', - }, - { - id: 'passphrase', - label: 'Passphrase', - state: passDone ? 'done' : phraseDone ? 'current' : 'upcoming', - }, - { id: 'finish', label: 'Finish setup', state: 'upcoming' }, - ]; -} - -// --------------------------------------------------------------------------- -// capy byoc — connect to a self-hosted instance -// --------------------------------------------------------------------------- - -export interface ByocConnectInput { - /** - * The URL SETTLED so far, normalized. - * - * Settled, not attempted. A probe that failed puts the page back on the - * address question, and an address the run is standing on again is not an - * answer — the rail said `Server URL https://capy.internal` with a tick - * beside it while the field underneath was asking for the address a third - * time. The field keeps the text (it is edited, not retyped); the rail keeps - * only what was agreed. - */ - url?: string; - /** It came from the argv positional rather than from a question. */ - urlFromArgv?: boolean; - /** A probe has accepted this URL as a Capy service. */ - verified?: boolean; - /** - * Whether this run has a certificate to answer for. - * - * Three states on purpose, and the third is the load-bearing one. - * `undefined` is "nothing has learned anything about a certificate yet", - * which is not the same as "the certificate is fine": the stop is declared - * `blank` so the rail shows a station the plan still needs an answer for, - * rather than quietly promising it will be skipped. Only a completed - * handshake may pass `false` — a name that did not resolve, a refused - * connection or a /health that was not Capy never reached a certificate and - * so cannot strike that station through. - */ - certUntrusted?: boolean; - /** The CA bundle path settled so far. */ - caBundle?: string; - /** The profile name settled so far. */ - profileName?: string; -} - -/** - * The route `capy byoc ` travels. - * - * The terminal has no route at all: it is a `while (true)` around a probe and a - * re-prompt whose only exit other than success is Ctrl-C, so a user three URLs - * deep cannot tell how many questions are left or that a certificate question - * exists until one appears. - */ -export function byocConnectPlan(input: ByocConnectInput): ByocStop[] { - const urlAnswered = (input.url ?? '') !== ''; - const verified = input.verified === true; - const named = (input.profileName ?? '') !== ''; - - const url: ByocStop = urlAnswered - ? { - id: 'url', - label: 'Server URL', - state: 'done', - detail: 'where your self-hosted Capy lives', - answer: input.url, - // Positional rather than a flag, so there is no `--x` to quote. Naming - // the argument is still the honest answer to "why was I not asked?". - ...(input.urlFromArgv ? { flag: 'argument' } : {}), - } - : { - id: 'url', - label: 'Server URL', - state: 'current', - detail: 'where your self-hosted Capy lives', - }; - - const trust: ByocStop = { - id: 'trust', - label: 'Certificate', - detail: 'name the authority that signed it', - // Finding a root certificate on disk is work the user does outside Capy, - // which is what a dotted track means. - state: input.caBundle - ? // A bundle that made the instance verify is an ANSWER to this stop, so - // it is read before the verdict that produced the question. The other - // order struck the station through and then hung the bundle path off - // it as the answer to a question it claimed was never asked. - 'done' - : input.certUntrusted === false - ? 'skipped' - : input.certUntrusted === true - ? 'current' - : 'upcoming', - manual: true, - ...(input.caBundle ? { answer: input.caBundle } : {}), - // Nothing has looked yet: the plan needs an answer here and does not have - // one. A failed probe that never reached a certificate lands here too. - ...(input.certUntrusted === undefined ? { blank: true } : {}), - }; - - return [ - url, - { - id: 'verify', - label: 'Verify', - detail: 'ask /health whether it is Capy', - // One stop is `current`, and it is the one the page is standing on. An - // untrusted certificate moves the run to the station that answers it — - // the handshake will be tried again from there — so `verify` waits its - // turn rather than lighting up beside the stop that overtook it. - state: verified - ? 'done' - : urlAnswered && input.certUntrusted !== true - ? 'current' - : 'upcoming', - }, - trust, - named - ? { - id: 'name', - label: 'Profile name', - state: 'done', - detail: 'what you type after capy use', - answer: input.profileName, - } - : { - id: 'name', - label: 'Profile name', - state: verified ? 'current' : 'upcoming', - detail: 'what you type after capy use', - }, - { - id: 'save', - label: 'Save', - state: 'upcoming', - detail: 'write the profile and switch to it', - }, - ]; -} diff --git a/src/core/recoverPlan.ts b/src/core/recoverPlan.ts deleted file mode 100644 index 05b54974..00000000 --- a/src/core/recoverPlan.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * The route `capy recover` will travel, computed before anything opens. - * - * ONE function, for the reason `branchCreatePlan` gives: the rail a person - * reads and the array a headless caller would parse have to be the same object, - * and two builders make that a promise nobody can keep past the first - * divergence. `capy recover` has no `--json` today; when it grows one, this is - * what it prints. - * - * The route matters more here than on most commands because `recover` is five - * questions deep and the terminal announces none of them. You sign in, you are - * asked which organization, and then — only if this device already holds a key - * for the one you picked — a destructive confirm appears that nothing warned - * you about. Drawing the whole route first is what turns that ambush into a - * station you could see coming. - * - * Two of the stops carry a modifier the base `Stop` does not: - * - * manual the sign-in is a browser tab the user finishes themselves, so its - * track is dotted — but only while it is still theirs to finish. A - * sign-in already `done` is history, and dotted track over history - * says "over to you" about something that is not. - * blank the overwrite gate exists only if the organization you pick - * already holds a key on this device. Until the organization stop - * settles that, it is a blank the plan still needs an answer for — - * drawn ◌ rather than left out, because a stop that appears - * mid-flow is exactly what this rail exists to prevent. - */ -import type { RecoverMasterKeyStop } from '../ui/screens/contract'; - -export interface RecoverPlanInput { - /** - * The session this run signed in with. `recover` authenticates before it - * asks anything — silently if it can, with a browser tab if it cannot — so - * by the time a screen is served this is true. - */ - signedIn: boolean; - /** Who it signed in as, shown on the finished sign-in stop. */ - userEmail?: string; - /** The organization chosen at the org stop. Empty until one is. */ - orgName?: string; - /** - * Whether the CHOSEN organization already holds a wrapped key on this - * device. `undefined` while no organization has been chosen: the overwrite - * gate is a blank the org stop fills in, not a stop the plan can commit to. - */ - hasKeyOnThisDevice?: boolean; - /** The overwrite gate has been agreed to. */ - overwriteAgreed?: boolean; - /** - * A phrase has been entered and accepted as well-formed. Set on the fork - * where the trial decryption could not run, which is the only state in which - * the run stands at the write stop with a screen still open. - */ - phraseEntered?: boolean; - /** - * How many words this run expects. The rail counts them, and the field - * builds that many boxes from the same number — so it is the command's, and - * never hardcoded in either surface. - */ - wordCount: number; -} - -export function recoverPlan(input: RecoverPlanInput): RecoverMasterKeyStop[] { - const orgAnswered = typeof input.orgName === 'string' && input.orgName.trim() !== ''; - // The gate is only real for an organization this device already holds a key - // for. `false` strikes the stop out; `undefined` leaves it blank. - const gateOpen = input.hasKeyOnThisDevice === true && input.overwriteAgreed !== true; - const pastGate = orgAnswered && !gateOpen; - - const auth: RecoverMasterKeyStop = input.signedIn - ? { - id: 'auth', - label: 'Sign in', - state: 'done', - detail: 'so Capy knows which organizations you can reach', - answer: input.userEmail, - } - : { - id: 'auth', - label: 'Sign in', - state: 'current', - detail: 'so Capy knows which organizations you can reach', - manual: true, - }; - - const organization: RecoverMasterKeyStop = orgAnswered - ? { - id: 'organization', - label: 'Organization', - state: 'done', - // The CLI's own question, shortened to a station label. Never - // inherited from keep.lock: that is the bug `recover` exists to undo. - detail: 'which organization this recovery phrase is for', - answer: input.orgName!.trim(), - } - : { - id: 'organization', - label: 'Organization', - state: input.signedIn ? 'current' : 'upcoming', - detail: 'which organization this recovery phrase is for', - }; - - const overwriteDetail = 'replace the wrapped key this device already holds'; - let overwrite: RecoverMasterKeyStop; - if (input.hasKeyOnThisDevice === undefined) { - overwrite = { - id: 'overwrite', - label: 'Overwrite', - state: 'upcoming', - detail: overwriteDetail, - blank: true, - }; - } else if (!input.hasKeyOnThisDevice) { - // Nothing on this device to destroy, so this run never visits the stop — - // and the rail says so rather than quietly dropping a station. - overwrite = { id: 'overwrite', label: 'Overwrite', state: 'skipped', detail: overwriteDetail }; - } else if (input.overwriteAgreed) { - overwrite = { - id: 'overwrite', - label: 'Overwrite', - state: 'done', - detail: overwriteDetail, - answer: 'replace it', - }; - } else { - overwrite = { - id: 'overwrite', - label: 'Overwrite', - state: orgAnswered ? 'current' : 'upcoming', - detail: overwriteDetail, - }; - } - - const phrase: RecoverMasterKeyStop = { - id: 'phrase', - label: 'Recovery phrase', - state: input.phraseEntered ? 'done' : pastGate ? 'current' : 'upcoming', - detail: `all ${input.wordCount} words`, - }; - - return [ - auth, - organization, - overwrite, - phrase, - { - id: 'write', - label: 'Write the key', - // Reached with a screen still open only on the fork where the trial - // decryption could not run and the user is asked to write anyway. On - // every other run the phrase stop verifies and writes in one move. - state: input.phraseEntered ? 'current' : 'upcoming', - // Which is why the detail cannot be one sentence. Standing here means - // the verification this stop describes is the thing that DID NOT - // happen, and a rail that still promised it would be describing a step - // that failed as though it were ahead of you. - detail: input.phraseEntered - ? 'nothing here could verify it, so saving it is a decision' - : 'verify it against this organization’s own ciphertext, then save it here', - }, - ]; -} diff --git a/src/core/secretEditPlan.ts b/src/core/secretEditPlan.ts deleted file mode 100644 index 29f301cf..00000000 --- a/src/core/secretEditPlan.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * The route `capy edit` travels, computed before anything opens. - * - * ONE function, for `branchCreatePlan`'s reason: the rail a person reads and - * the array a headless caller parses have to be the same object, and two - * builders — one for the page, one for `--json` — make that a promise nobody - * can keep past the first divergence. - * - * There is a second reason here. This route is drawn by TWO screens: - * `secret-table` shows it on the variable list and again on the review stop, - * and `secret-commit-review` shows it from the other half of the same journey. - * A station cannot be called two things, and until now it was — both screens - * carried their own lookup table of labels. They get their stations from here. - * - * The terminal has no route at all. `c` commits and pushes on one keystroke, - * `q` prompts, and neither says a review exists until you are standing in it. - */ -import type { SecretTableStop } from '../ui/screens/contract'; - -/** Where the run is standing. `result` covers both endings — done and discarded. */ -export type SecretEditStopId = 'edit' | 'review' | 'write' | 'result'; - -export interface SecretEditInput { - /** Variables on the table this run. Named on the first stop. */ - variableCount: number; - /** Uncommitted edits so far. Becomes the first stop's answer once it is behind. */ - changeCount: number; - /** - * A local-only profile has no server, so the write stop is a local commit - * and names no destination it could push to. - */ - localMode: boolean; - /** Where a commit lands, as `project/branch`. Unused in local mode. */ - destination: string; - /** Which stop the run is standing on. */ - at: SecretEditStopId; - /** - * The run reached `result` without writing anything — the edits were - * discarded. The write stop is then `skipped` rather than `done`: it is a - * station this run decided not to visit, not one it passed through. - */ - discarded?: boolean; -} - -const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`; - -/** - * How a stop reads relative to where the traveller is standing. - * - * Only the first unanswered station is `current`; everything before it is - * behind and everything after is ahead. Derived from one cursor rather than - * set per stop, so a route cannot be built with two "you are here" markers. - */ -function stateFor(id: SecretEditStopId, at: SecretEditStopId): 'done' | 'current' | 'upcoming' { - const order: SecretEditStopId[] = ['edit', 'review', 'write', 'result']; - const here = order.indexOf(at); - const mine = order.indexOf(id); - if (mine < here) return 'done'; - if (mine === here) return 'current'; - return 'upcoming'; -} - -export function secretEditPlan(input: SecretEditInput): SecretTableStop[] { - const editState = stateFor('edit', input.at); - const writeState = stateFor('write', input.at); - const resultState = stateFor('result', input.at); - - const edit: SecretTableStop = - editState === 'done' - ? { - id: 'edit', - label: 'Edit values', - state: 'done', - // What was decided here, not what was on offer. A finished stop that - // still says "9 variables" describes the page rather than the answer. - answer: plural(input.changeCount, 'change', 'changes'), - } - : { - id: 'edit', - label: 'Edit values', - state: editState, - detail: plural(input.variableCount, 'variable', 'variables'), - }; - - return [ - edit, - { - id: 'review', - label: 'Review changes', - state: stateFor('review', input.at), - detail: 'everything this write touches', - }, - { - id: 'write', - // Local mode pushes nothing, so naming Keep here would promise a - // destination that does not exist for this profile. - label: input.localMode ? 'Commit locally' : 'Encrypt and push to Keep', - // A discarded run never visits this station. Saying so is the point of - // drawing the whole route rather than only the part that happened. - state: input.discarded && writeState !== 'upcoming' ? 'skipped' : writeState, - detail: input.localMode ? 'to this machine only' : input.destination, - // The detail is a branch path — machine state, so it is set in mono. Local - // mode's detail is a sentence and must not be. - ...(input.localMode ? {} : { detailMono: true }), - }, - { - id: 'result', - label: 'Result', - state: resultState, - // The endings ARE the result, so a stop standing on one has nothing left - // to describe: "what this run changed" is on the page underneath it. - ...(resultState === 'current' ? {} : { detail: 'what this run changed' }), - }, - ]; -} diff --git a/src/core/webModeRemoved.ts b/src/core/webModeRemoved.ts new file mode 100644 index 00000000..6a277b62 --- /dev/null +++ b/src/core/webModeRemoved.ts @@ -0,0 +1,28 @@ +/** + * `--web` is gone. An agent that still passes it must be told so, with a coded + * refusal, rather than have the command run in a terminal nobody is watching. + * + * The flag stays declared (hidden from help and from `capy help --json`) so it + * parses wherever it used to, and the program's `preAction` hook calls + * {@link refuseWebMode} before any handler runs. The command is never executed. + */ +import { ERROR_CODES } from '../types/index'; + +/** COPY-FLAG: minimal-neutral. Callers branch on the code, never on this text. */ +export const WEB_MODE_REMOVED_MESSAGE = + '--web was removed. Run the command in a terminal, or pipe values (see capy help).'; + +/** + * Refuses and exits 1. `--json`: `{ ok:false, code, error }` as pure JSON on + * stdout. Otherwise the sentence on stderr. + */ +export function refuseWebMode(json: boolean): never { + if (json) { + console.log( + JSON.stringify({ ok: false, code: ERROR_CODES.WEB_MODE_REMOVED, error: WEB_MODE_REMOVED_MESSAGE }, null, 2), + ); + } else { + console.error(WEB_MODE_REMOVED_MESSAGE); + } + process.exit(1); +} diff --git a/src/deploy/adapters/vercel.ts b/src/deploy/adapters/vercel.ts index 270e0f8f..1989d14e 100644 --- a/src/deploy/adapters/vercel.ts +++ b/src/deploy/adapters/vercel.ts @@ -34,7 +34,8 @@ import { spawnSync, spawn } from 'child_process'; import { homedir } from 'os'; import { basename, join } from 'path'; import inquirer from 'inquirer'; -import { LIST_THEME } from '../../ui/promptStyle'; +import { searchableSelectQuestion } from '../../ui/searchPrompts'; +import { sortedCopy } from '../dokployApi'; import { DeployAdapter, DeployContext, @@ -199,7 +200,7 @@ async function vercelApi(token: string, path: string): Promise { return res.json(); } -interface PickableProject { +export interface PickableProject { projectId: string; projectName: string; /** Personal account uid or team id — what project.json calls orgId. */ @@ -280,44 +281,56 @@ type PickerOutcome = /** The user asked for the wizard. */ | { kind: 'declined' }; -async function linkByProjectPicker(projectDir: string): Promise { - const token = readVercelCliToken(); - if (!token) return { kind: 'no-token' }; - - let projects: PickableProject[]; +/** The project list, or the picker outcome that explains why there isn't one. */ +async function tryListVercelProjects( + token: string, +): Promise<{ readonly projects: PickableProject[] } | { readonly outcome: PickerOutcome }> { try { - projects = await listAllVercelProjects(token); + return { projects: await listAllVercelProjects(token) }; } catch (e) { - return { kind: 'api-error', detail: e instanceof Error ? e.message : String(e) }; + return { outcome: { kind: 'api-error', detail: e instanceof Error ? e.message : String(e) } }; } - if (projects.length === 0) return { kind: 'no-projects' }; +} - // Best guess first: a project named like the directory it lives in. - const dirName = basename(projectDir); - projects.sort((a, b) => { +/** Best guess first (a project named like the directory it lives in), then by name. The input is not changed. */ +export function orderProjectsForPicker( + projects: readonly T[], + dirName: string, +): T[] { + return sortedCopy(projects, (a, b) => { const aMatch = a.projectName === dirName; const bMatch = b.projectName === dirName; if (aMatch !== bMatch) return aMatch ? -1 : 1; return a.projectName.localeCompare(b.projectName); }); +} + +/** The Vercel project picker's question: type-to-filter over `scope/project` rows plus the "None of these" row. */ +export function vercelProjectQuestion(projects: readonly PickableProject[]): any { + return searchableSelectQuestion({ + name: 'picked', + message: 'Which Vercel project is this?', + choices: [ + ...projects.map((p) => ({ name: `${p.scopeLabel}/${p.projectName}`, value: p })), + { name: 'None of these — run `vercel link` instead', value: null }, + ], + }); +} + +async function linkByProjectPicker(projectDir: string): Promise { + const token = readVercelCliToken(); + if (!token) return { kind: 'no-token' }; + + const listed = await tryListVercelProjects(token); + if ('outcome' in listed) return listed.outcome; + const projects = orderProjectsForPicker(listed.projects, basename(projectDir)); + if (projects.length === 0) return { kind: 'no-projects' }; process.stdout.write( '\n\x1b[33m▸ This directory is not linked to a Vercel project yet.\x1b[0m\n', ); const ans: { picked: PickableProject | null } = (await inquirer.prompt([ - { - type: 'list', - name: 'picked', - message: 'Which Vercel project is this?', - theme: LIST_THEME, - choices: [ - ...projects.map((p) => ({ - name: `${p.scopeLabel}/${p.projectName}`, - value: p, - })), - { name: 'None of these — run `vercel link` instead', value: null }, - ], - } as any, + vercelProjectQuestion(projects), ])) as any; if (!ans.picked) return { kind: 'declined' }; diff --git a/src/deploy/dokployApi.ts b/src/deploy/dokployApi.ts index dbeb2452..2ae8e148 100644 --- a/src/deploy/dokployApi.ts +++ b/src/deploy/dokployApi.ts @@ -770,11 +770,9 @@ export async function resolveDokployApiKey( * Whether a run may let the org system store prompt a human for a missing * Dokploy key. `baseInteractive` is the caller's ordinary TTY check; * `suppressed` covers every reason that overrides a real TTY back to "never - * ask" — shared by `deployCommand.ts` (`--web`, `--yes`, `--dry-run`) and - * the import connector (`--web`, `--json`, `--dry-run`): + * ask" — shared by `deployCommand.ts` (`--yes`, `--dry-run`) and + * the import connector (`--json`, `--dry-run`): * - * - `--web`: the store's own prompt is a raw terminal `inquirer` prompt, - * not a browser screen — asking there could hang a browser-driven run. * - `--json` (connector only): machine output must never have a prompt * interleaved with it. * - `--yes` (deploy only): never ask, resolve from what's already there diff --git a/src/index-dev.ts b/src/index-dev.ts index bd5d1884..7ec033ed 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -6,11 +6,11 @@ */ import { config } from 'dotenv'; import { resolve } from 'path'; -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { CapyCommand } from './commands/capyCommand'; import { CliOptions } from './types/index'; import { version as CLI_VERSION } from '../package.json'; -import { setWebMode } from './ui/webMode'; +import { refuseWebMode } from './core/webModeRemoved'; import { ACCENT } from './ui/colors'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -81,19 +81,17 @@ program .option('-v, --verbose', 'enable detailed logging') .option('-f, --force', 're-encrypt existing variables') .option('-d, --dry-run', 'preview changes without applying') - .option('--web', 'render interactive steps (first-run setup / sync conflicts) in a local browser instead of TTY prompts') + // Hidden: `--web` was removed. It still parses so the hook below can refuse it (WEB_MODE_REMOVED). + .addOption(new Option('--web').hideHelp()) // Root help only (Commander scopes addHelpText to the command it's called // on) — points an agent at `capy help --json` for the full, drift-proof // command reference (CAP-681). .addHelpText('after', '\nAgents: run `capy help --json` for a machine-readable command reference.') - // Record `--web` once, before any handler runs, for the code that has no way - // to ask. `displayErrorAndExit` is reached from eighteen catch blocks — a key - // resolver, a service client, a crypto path — none of which is handed the - // flag, and threading a boolean through every signature between here and - // there would be forgotten on the nineteenth. Commands that decide their own - // flow still read `command.optsWithGlobals().web`. - .hook('preAction', (thisCommand) => { - setWebMode(thisCommand.opts().web === true); + // `--web` is gone: refuse it before any handler runs, so an agent that still + // passes it is told so instead of having the command run unattended. + .hook('preAction', (_thisCommand, actionCommand) => { + const opts = actionCommand.optsWithGlobals(); + if (opts.web === true) refuseWebMode(opts.json === true); }) .action(async (options, cmd) => { if (cmd.args.length > 0) { @@ -124,8 +122,7 @@ program envPath: options.envPath, verbose: options.verbose, force: options.force, - dryRun: options.dryRun, - web: options.web + dryRun: options.dryRun }; const command = new CapyCommand(cliOptions, true); @@ -252,7 +249,7 @@ program if (selected !== '__stay__') { const { CheckoutCommand } = await import('./commands/checkoutCommand'); const cmd = new CheckoutCommand(true); - await cmd.execute(selected, { web: command.optsWithGlobals().web === true }); + await cmd.execute(selected); } } @@ -277,7 +274,6 @@ program await cmd.execute(branch, { create: options.create, protected: options.protected, - web: command.optsWithGlobals().web === true, }); }); @@ -297,7 +293,7 @@ program .action(async (options, command) => { const { StatusCommand } = await import('./commands/statusCommand'); const cmd = new StatusCommand(false, true); - await cmd.execute({ json: options.json, web: command.optsWithGlobals().web === true }); + await cmd.execute({ json: options.json }); }); program @@ -322,7 +318,6 @@ program const { prFlagsFromCommand } = await import('./commands/keepLockPr'); const cmd = new EditCommand(process.env.CAPY_API_URL, true); await cmd.execute({ - web: command.optsWithGlobals().web === true, name, json: options.json, noPush: options.push === false, @@ -353,9 +348,6 @@ const deploy = program target: options.target, yes: options.yes ?? merged.yes, dryRun: options.dryRun ?? merged.dryRun, - // Same inherited-global rule as every other converted command: --web - // is declared once on the root program, so it arrives in merged opts. - web: merged.web === true, edit: options.edit, devMode: true, }); @@ -380,9 +372,7 @@ deploy .description('Revoke a deploy token') .action(async (deployId: string, _options: unknown, command: any) => { const { DeployRevokeCommand } = await import('./commands/deployTokenCommand'); - const cmd = new DeployRevokeCommand(process.env.CAPY_API_URL, true, { - web: command.optsWithGlobals().web === true, - }); + const cmd = new DeployRevokeCommand(process.env.CAPY_API_URL, true); await cmd.execute(deployId); }); @@ -391,9 +381,7 @@ deploy .description('List deploy tokens for this project') .action(async (_options, command) => { const { DeployListCommand } = await import('./commands/deployTokenCommand'); - const cmd = new DeployListCommand(process.env.CAPY_API_URL, true, { - web: command.optsWithGlobals().web === true, - }); + const cmd = new DeployListCommand(process.env.CAPY_API_URL, true); await cmd.execute(); }); @@ -408,7 +396,7 @@ deploy .description('List configured connector targets (connector mode)') .action(async (_options, command) => { const { deployList } = await import('./commands/deployCommand'); - process.exit(await deployList(process.cwd(), { web: command.optsWithGlobals().web === true })); + process.exit(await deployList(process.cwd())); }); deploy @@ -417,7 +405,7 @@ deploy .action(async (name: string, _options, command) => { const { deployRemove } = await import('./commands/deployCommand'); process.exit( - await deployRemove(name, process.cwd(), { web: command.optsWithGlobals().web === true, devMode: true }), + await deployRemove(name, process.cwd(), { devMode: true }), ); }); @@ -434,7 +422,6 @@ program const { InviteCommand } = await import('./commands/inviteCommand'); const cmd = new InviteCommand(process.env.CAPY_API_URL, true); await cmd.execute(email, { - web: command.optsWithGlobals().web === true, role: options.role, projects: options.project, ttl: options.ttl, @@ -480,7 +467,7 @@ program .action(async (email, _options, command) => { const { KickCommand } = await import('./commands/kickCommand'); const cmd = new KickCommand(process.env.CAPY_API_URL, true); - await cmd.execute(email, { web: command.optsWithGlobals().web === true }); + await cmd.execute(email); }); const systemCmd = program @@ -723,11 +710,7 @@ program .description('Switch organization') .action(async (_options, command) => { const { OrgCommand } = await import('./commands/orgCommand'); - // OrgCommand takes it at construction — see its own note on why a - // subcommand must read the inherited global rather than its own options. - const cmd = new OrgCommand(process.env.CAPY_API_URL, true, { - web: command.optsWithGlobals().web === true, - }); + const cmd = new OrgCommand(process.env.CAPY_API_URL, true); await cmd.execute(); }); @@ -806,7 +789,7 @@ program .description('Connect to a self-hosted Capy (BYOC) instance') .action(async (url: string | undefined, _options: unknown, command: any) => { const { byocCommand } = await import('./commands/byocCommand'); - process.exit(await byocCommand(url, { web: command.optsWithGlobals().web === true })); + process.exit(await byocCommand(url)); }); program @@ -901,7 +884,7 @@ program .action(async (_options, command) => { const { DecryptCommand } = await import('./commands/decryptCommand'); const cmd = new DecryptCommand(); - await cmd.execute({ web: command.optsWithGlobals().web === true }); + await cmd.execute(); }); program @@ -910,7 +893,7 @@ program .action(async (_options, command) => { const { EndRecoverCommand } = await import('./commands/endRecoverCommand'); const cmd = new EndRecoverCommand(); - await cmd.execute({ web: command.optsWithGlobals().web === true }); + await cmd.execute(); }); program @@ -919,7 +902,7 @@ program .action(async (_options, command) => { const { RecoverCommand } = await import('./commands/recoverCommand'); const cmd = new RecoverCommand(process.env.CAPY_API_URL, true); - await cmd.execute({ web: command.optsWithGlobals().web === true }); + await cmd.execute(); }); program @@ -938,14 +921,6 @@ program program .command('add ') .description('Add one or more secret values to the project (encrypts + syncs)') - .option('--reason ', 'short note shown on the intake page') - .option( - '--help-url ', - 'per-variable "where to find this" link, e.g. STRIPE_SECRET_KEY=https://dashboard.stripe.com/apikeys (repeatable)', - (val: string, acc: string[]) => [...acc, val], - [] as string[], - ) - .option('--no-open', 'do not auto-open the browser; print the URL only') .option('--no-push', 'write to .env only; do not push to Capy') .option('-f, --force', 'overwrite existing values without prompting') .option('--non-tty', 'never prompt; resolve from flags or fail fast (agents/CI)') @@ -966,10 +941,6 @@ program const cmd = new AddCommand(true); // devMode: dev backend + ~/.capy-dev const merged = command.optsWithGlobals(); await cmd.execute(varNames, { - web: command.optsWithGlobals().web === true, - reason: options.reason, - helpUrls: options.helpUrl, - open: options.open, noPush: options.push === false, force: merged.force, nonTty: options.nonTty, @@ -1036,20 +1007,12 @@ program .action(async (provider, options, command) => { const { ConnectCommand } = await import('./commands/connectCommand'); const cmd = new ConnectCommand(true); // devMode: hard-blocks live - // Globals, because `--web` is declared once on the root program. Dropping - // it here is not a no-op: `ConnectCommand` reads `opts.web` to decide - // between the browser route and the TTY prompts, so an unpassed flag makes - // `capy-dev connect stripe --web` silently answer in the terminal — or - // fail as "ambiguous without a prompt" when there isn't one. That is the - // dev binary, which is the only one the Stripe sandbox flow ever runs. const merged = command.optsWithGlobals(); - const web = merged.web === true; if (!provider) { - await cmd.list({ web }); + await cmd.list(); return; } await cmd.execute(provider, { - web, live: options.live, var: options.var, account: options.account, @@ -1082,7 +1045,6 @@ program const { RotateCommand } = await import('./commands/rotateCommand'); const cmd = new RotateCommand(true); // devMode: skips live entries await cmd.execute(varName, { - web: command.optsWithGlobals().web === true, all: options.all, noPush: options.push === false, skipPrompts: !!(options.yes || options.skipPrompts), diff --git a/src/index.ts b/src/index.ts index bf16d351..f9608e3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,12 +2,12 @@ // Imported first, and applied below before any other statement: the pin has to // land before a single Capy module reads configuration. import { applyProdPins, formatPinNotice } from './config/prodPins'; -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { CapyCommand } from './commands/capyCommand'; import { CliOptions } from './types/index'; import { assertNotLocalOnly } from './core/localGate'; import { version as CLI_VERSION } from '../package.json'; -import { setWebMode } from './ui/webMode'; +import { refuseWebMode } from './core/webModeRemoved'; import { ACCENT } from './ui/colors'; // Prod talks to api.capy.sc and ~/.capy, full stop. Strip the environment's @@ -66,19 +66,17 @@ program .option('-v, --verbose', 'enable detailed logging') .option('-f, --force', 're-encrypt existing variables') .option('-d, --dry-run', 'preview changes without applying') - .option('--web', 'render interactive steps (first-run setup / sync conflicts) in a local browser instead of TTY prompts') + // Hidden: `--web` was removed. It still parses so the hook below can refuse it (WEB_MODE_REMOVED). + .addOption(new Option('--web').hideHelp()) // Root help only (Commander scopes addHelpText to the command it's called // on) — points an agent at `capy help --json` for the full, drift-proof // command reference (CAP-681). .addHelpText('after', '\nAgents: run `capy help --json` for a machine-readable command reference.') - // Record `--web` once, before any handler runs, for the code that has no way - // to ask. `displayErrorAndExit` is reached from eighteen catch blocks — a key - // resolver, a service client, a crypto path — none of which is handed the - // flag, and threading a boolean through every signature between here and - // there would be forgotten on the nineteenth. Commands that decide their own - // flow still read `command.optsWithGlobals().web`. - .hook('preAction', (thisCommand) => { - setWebMode(thisCommand.opts().web === true); + // `--web` is gone: refuse it before any handler runs, so an agent that still + // passes it is told so instead of having the command run unattended. + .hook('preAction', (_thisCommand, actionCommand) => { + const opts = actionCommand.optsWithGlobals(); + if (opts.web === true) refuseWebMode(opts.json === true); }) .action(async (options, cmd) => { if (cmd.args.length > 0) { @@ -114,8 +112,7 @@ program envPath: options.envPath, verbose: options.verbose, force: options.force, - dryRun: options.dryRun, - web: options.web + dryRun: options.dryRun }; const command = new CapyCommand(cliOptions); @@ -142,7 +139,7 @@ program .action(async (options, command) => { const { StatusCommand } = await import('./commands/statusCommand'); const cmd = new StatusCommand(); - await cmd.execute({ json: options.json, web: command.optsWithGlobals().web === true }); + await cmd.execute({ json: options.json }); }); program @@ -167,7 +164,6 @@ program const { prFlagsFromCommand } = await import('./commands/keepLockPr'); const cmd = new EditCommand(); await cmd.execute({ - web: command.optsWithGlobals().web === true, name, json: options.json, noPush: options.push === false, @@ -297,7 +293,7 @@ program // `capy branch` hands its switch step to checkout, so the flag has to // travel with it — otherwise picking a branch here drops out of the // browser and into a TTY prompt halfway through the same run. - await cmd.execute(selected, { web: command.optsWithGlobals().web === true }); + await cmd.execute(selected); } } @@ -341,7 +337,6 @@ program await cmd.execute(branch, { create: options.create, protected: options.protected, - web: command.optsWithGlobals().web === true, }); }); @@ -396,9 +391,6 @@ const deploy = program target: options.target, yes: options.yes ?? merged.yes, dryRun: options.dryRun ?? merged.dryRun, - // Same inherited-global rule as every other converted command: --web - // is declared once on the root program, so it arrives in merged opts. - web: merged.web === true, edit: options.edit, // Deploy-level flag only — the global `-f/--force` means "re-encrypt", // a different thing, so it must NOT be merged in here. @@ -435,7 +427,7 @@ deploy .action(async (deployId: string, _options, command) => { assertNotLocalOnly('deploy revoke'); const { DeployRevokeCommand } = await import('./commands/deployTokenCommand'); - const cmd = new DeployRevokeCommand(undefined, false, { web: command.optsWithGlobals().web === true }); + const cmd = new DeployRevokeCommand(); await cmd.execute(deployId); }); @@ -445,7 +437,7 @@ deploy .action(async (_options, command) => { assertNotLocalOnly('deploy list'); const { DeployListCommand } = await import('./commands/deployTokenCommand'); - const cmd = new DeployListCommand(undefined, false, { web: command.optsWithGlobals().web === true }); + const cmd = new DeployListCommand(); await cmd.execute(); }); @@ -455,7 +447,7 @@ deploy .action(async (_options, command) => { assertNotLocalOnly('deploy targets'); const { deployList } = await import('./commands/deployCommand'); - process.exit(await deployList(process.cwd(), { web: command.optsWithGlobals().web === true })); + process.exit(await deployList(process.cwd())); }); deploy @@ -467,7 +459,6 @@ deploy const { deployRemove } = await import('./commands/deployCommand'); process.exit( await deployRemove(name, process.cwd(), { - web: command.optsWithGlobals().web === true, // commander negates `--no-deploy` onto the positive `deploy` property. noDeploy: options.deploy === false, }), @@ -501,9 +492,7 @@ program .description('Connect to a self-hosted Capy (BYOC) instance') .action(async (url: string | undefined, _options: unknown, command: Command) => { const { byocCommand } = await import('./commands/byocCommand'); - // `--web` is a global option on the root program, so read it via globals. - const web = command.optsWithGlobals().web === true; - process.exit(await byocCommand(url, { web })); + process.exit(await byocCommand(url)); }); program @@ -657,7 +646,6 @@ program const { InviteCommand } = await import('./commands/inviteCommand'); const cmd = new InviteCommand(); await cmd.execute(email, { - web: command.optsWithGlobals().web === true, role: options.role, projects: options.project, ttl: options.ttl, @@ -707,7 +695,7 @@ program assertNotLocalOnly('kick'); const { KickCommand } = await import('./commands/kickCommand'); const cmd = new KickCommand(); - await cmd.execute(email, { web: command.optsWithGlobals().web === true }); + await cmd.execute(email); }); const systemCmd = program @@ -756,7 +744,7 @@ program const { OrgCommand } = await import('./commands/orgCommand'); // OrgCommand takes it at construction — see its own note on why a // subcommand must read the inherited global rather than its own options. - const cmd = new OrgCommand(undefined, false, { web: command.optsWithGlobals().web === true }); + const cmd = new OrgCommand(); await cmd.execute(); }); @@ -847,7 +835,7 @@ program .action(async (_options, command) => { const { DecryptCommand } = await import('./commands/decryptCommand'); const cmd = new DecryptCommand(); - await cmd.execute({ web: command.optsWithGlobals().web === true }); + await cmd.execute(); }); program @@ -856,7 +844,7 @@ program .action(async (_options, command) => { const { EndRecoverCommand } = await import('./commands/endRecoverCommand'); const cmd = new EndRecoverCommand(); - await cmd.execute({ web: command.optsWithGlobals().web === true }); + await cmd.execute(); }); program @@ -866,25 +854,12 @@ program assertNotLocalOnly('recover'); const { RecoverCommand } = await import('./commands/recoverCommand'); const cmd = new RecoverCommand(); - await cmd.execute({ web: command.optsWithGlobals().web === true }); + await cmd.execute(); }); program .command('add ') .description('Add one or more secret values to the project (encrypts + syncs)') - // NOTE: `--web` is intentionally NOT declared here. The root program already - // defines a global `--web`, and Commander binds a doubly-declared flag to the - // parent scope — so a local copy would silently shadow to undefined (the bug - // that made `capy add --web` fall through to the dead TTY prompt). Like `byoc`, - // we read the inherited global via `merged.web` below. - .option('--reason ', 'short note shown on the intake page') - .option( - '--help-url ', - 'per-variable "where to find this" link, e.g. STRIPE_SECRET_KEY=https://dashboard.stripe.com/apikeys (repeatable)', - (val: string, acc: string[]) => [...acc, val], - [] as string[], - ) - .option('--no-open', 'do not auto-open the browser; print the URL only') .option('--no-push', 'write to .env only; do not push to Capy') .option('-f, --force', 'overwrite existing values without prompting') .option('--non-tty', 'never prompt; resolve from flags or fail fast (agents/CI)') @@ -906,12 +881,6 @@ program const cmd = new AddCommand(); const merged = command.optsWithGlobals(); await cmd.execute(varNames, { - // `--web` is defined on both the root program and this subcommand, so Commander - // binds it to the global scope — read it from merged opts, not the local `options`. - web: merged.web, - reason: options.reason, - helpUrls: options.helpUrl, - open: options.open, noPush: options.push === false, force: merged.force, nonTty: options.nonTty, @@ -981,19 +950,14 @@ program const { ConnectCommand } = await import('./commands/connectCommand'); const cmd = new ConnectCommand(); if (!provider) { - await cmd.list({ web: command.optsWithGlobals().web === true }); + await cmd.list(); return; } - // Globals, because `--web` is declared once on the root program. Dropping - // it here is not a no-op: `ConnectCommand` reads `opts.web` to choose - // between the browser route and the TTY prompts, so an unpassed flag makes - // `capy connect stripe --web` answer in a terminal nobody is watching. - // `--dry-run` is also declared once on the root program (like `--web`) — + // `--dry-run` is declared once on the root program — // merge globals so `capy --dry-run connect dokploy` and // `capy connect dokploy --dry-run` both work, same pattern as `deploy`. const merged = command.optsWithGlobals(); await cmd.execute(provider, { - web: merged.web === true, live: options.live, var: options.var, account: options.account, @@ -1027,7 +991,6 @@ program const { RotateCommand } = await import('./commands/rotateCommand'); const cmd = new RotateCommand(); await cmd.execute(varName, { - web: command.optsWithGlobals().web === true, all: options.all, noPush: options.push === false, skipPrompts: !!(options.yes || options.skipPrompts), diff --git a/src/types/index.ts b/src/types/index.ts index abc2633e..11d327f1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -381,10 +381,6 @@ export interface CliOptions { verbose?: boolean; force?: boolean; dryRun?: boolean; - /** Render bare `capy`'s interactive steps (init trainstops / sync conflict resolver) - * in a local browser instead of TTY prompts. Lazy: the browser only opens when an - * interactive decision is actually reached (a clean sync stays terminal-only). */ - web?: boolean; } export interface ProjectInitResult { @@ -450,9 +446,7 @@ export const ERROR_CODES = { LOCAL_KEY_BACKEND_ERROR: 'LOCAL_KEY_BACKEND_ERROR', // Local-state refusals: the command cannot start because this directory, // this branch or this build does not hold what it needs. Nothing has been - // asked of the service yet, so none of these is a SERVICE_ERROR — and each - // one used to be a bare `console.error` + `process.exit(1)`, which under - // `--web` is a decision reported to a stream nobody is reading. + // asked of the service yet, so none of these is a SERVICE_ERROR. /** The branch has no connector-managed credentials, so there is nothing to rotate. */ NO_MANAGED_KEYS: 'NO_MANAGED_KEYS', /** The branch has no variables at all yet. */ @@ -698,6 +692,8 @@ export const ERROR_CODES = { ADD_STDIN_ONE_NAME: 'ADD_STDIN_ONE_NAME', /** `capy add` piped mode: the variable already exists and `--force` was not passed. Exit 3. */ ADD_VAR_EXISTS: 'ADD_VAR_EXISTS', + /** `--web` was passed. The browser screens were removed and the command is not run. Exit 1. */ + WEB_MODE_REMOVED: 'WEB_MODE_REMOVED', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; diff --git a/src/ui/branchScreens.ts b/src/ui/branchScreens.ts deleted file mode 100644 index f928ee9c..00000000 --- a/src/ui/branchScreens.ts +++ /dev/null @@ -1,362 +0,0 @@ -// `capy checkout`, served as compiled screens. -// -// Two questions this command asks and had no browser answer for: -// -// 1. `capy checkout -b ` asks, in the terminal, `Make "" a -// protected branch? (invite-only)` — a confirm defaulting to No, with the -// whole consequence of "yes" compressed into two dim words. Under `--web` -// it is the `branch-create` screen's protection step, which states what -// invite-only costs beside the choice and lists what this directory is -// about to copy onto the new branch. -// 2. `capy checkout ` on a name that does not exist prints the branch -// list and exits 1. It is a listing the user cannot answer: the branch -// they meant is on the screen and the only way to reach it is to retype -// the command. Under `--web` that dead end becomes the `branch-list` -// screen, and picking a row continues the same checkout. -// -// The route is `branchCreatePlan`'s, not this file's, and not the screen's. -// One builder feeds both `--json` and the payload, which is the only way the -// rail a person reads and the array an agent parses can be claimed to be the -// same array. `tests/core/branchCreatePlan.test.ts` pins that claim. -// -// Renders no secret values. `checkout` decrypts .env in-process to seed a new -// branch, and that plaintext must never reach a payload: the seed preview is -// variable NAMES and a count, deliberately. -import { runBrowserWizard } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { formatRelativeTime } from './relativeTime'; -import { branchCreatePlan, unansweredStops } from '../core/branchCreatePlan'; -import type { - BranchCreateData, - BranchCreateExisting, - BranchListData, - BranchRow, -} from './screens/contract'; -import type { Branch } from '../types/index'; - -/** - * Strip terminal colour codes on the way into a payload. - * - * Applied to strings the CLI also PRINTS — the age line it formats, the - * project name it puts in headers — because a payload is not a terminal and - * an escape would render as a literal `[90m` in the browser. - * - * Deliberately NOT applied to a branch name. A name is the identifier the - * answer comes back as and the one this command then checks out, so it has to - * round-trip byte for byte; rewriting it here would mean the browser could - * only ever ask for a branch that does not exist. Names are compared against - * the list the server sent instead, which no rewriting can defeat. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -// --------------------------------------------------------------------------- -// branch-create -// --------------------------------------------------------------------------- - -export interface WebBranchCreateParams { - projectName: string; - /** Name from `capy checkout -b `. */ - branchName: string; - /** Settled by `--protected` / `--no-protected`; undefined means ask. */ - isProtected?: boolean; - /** Branches that already exist, so a collision is caught before the POST. */ - existingBranches: BranchCreateExisting[]; - /** - * The branch this directory is on now: where `-b` copies .env from. Null - * when Capy could not tell which branch this directory is on. - */ - seedFrom: string | null; - /** Variable NAMES that would be copied across. Never values. */ - seedVarNames: string[]; - /** The .env could not be decrypted, so the new branch starts empty. */ - seedUnreadable?: boolean; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** What the browser decided. `cancelled` means nothing may be created. */ -export interface WebBranchCreateResult { - name: string; - isProtected: boolean; - cancelled: boolean; -} - -/** - * How each question is answered without a browser. - * - * Written from the CLI's argv as it is today. `--no-protected` exists and - * commander binds it to `false`, so an unprotected branch IS reachable - * headlessly — the fixture under packages/fixtures still says it is not, - * which was true before that flag landed. - */ -function nonTtyEscapes(name: string): BranchCreateData['nonTty'] { - const shown = name || ''; - return { - name: { - command: `capy checkout -b ${shown}`, - why: 'The name is a positional argument. Without one there is nothing to create, so the step cannot fall back to a default.', - }, - protection: { - command: `capy checkout -b ${shown} --no-protected`, - why: 'Protection is not guessed: --protected makes the branch invite-only, --no-protected leaves it open to the project. An untouched flag is unanswered rather than false, so a run with nowhere to ask refuses instead of picking one.', - }, - }; -} - -/** - * Which question this run is standing on, or null when nothing is left to ask. - * - * Derived from the plan rather than from a view field the caller sets, so the - * screen that gets served and the rail drawn beside it cannot disagree about - * where the traveller is. - */ -function currentView(p: { branchName: string; isProtected?: boolean }): 'name' | 'protection' | null { - const [next] = unansweredStops(branchCreatePlan({ branchName: p.branchName, isProtected: p.isProtected })); - if (next === 'name') return 'name'; - if (next === 'protection') return 'protection'; - return null; -} - -export function buildBranchCreateData(p: WebBranchCreateParams, nonce: string): BranchCreateData { - const stops = branchCreatePlan({ branchName: p.branchName, isProtected: p.isProtected }); - const name = p.branchName.trim(); - - return { - nonce, - projectName: stripAnsi(p.projectName), - stops, - existingBranches: p.existingBranches, - // "Empty when the CLI has none" — and a name that is only whitespace is - // none, which is the same judgement `branchCreatePlan` makes one line up. - name, - isProtected: p.isProtected, - seedFrom: p.seedFrom, - seedVarNames: p.seedVarNames, - seedUnreadable: p.seedUnreadable, - // `currentView` returning null means there is nothing to serve; the caller - // never opens a browser in that case. Falling back to the name step keeps - // the type total without inventing a fourth view. - view: currentView({ branchName: p.branchName, isProtected: p.isProtected }) ?? 'name', - nonTty: nonTtyEscapes(name), - }; -} - -/** - * The four ways a name is not a name. - * - * Only the first has CLI copy (the init prompt's validator); the rest are the - * screen's, reproduced verbatim so a refusal the CLI makes and the error the - * field shows are one sentence rather than two about one condition. - * - * The screen holds its button on every one of these, so any of them arriving - * over the wire means the submit did not come from the screen — refuse it - * rather than post a name nobody typed to `POST /projects/{id}/branches`. - */ -export function branchNameProblem(name: string, existing: BranchCreateExisting[]): string | undefined { - const trimmed = name.trim(); - if (!trimmed) return 'Branch name cannot be empty'; - if (/\s/.test(trimmed)) return 'Branch names cannot contain spaces or line breaks'; - if (trimmed.startsWith('-')) return 'Branch names cannot start with a hyphen'; - const clash = existing.find((b) => b.name === trimmed); - if (clash) { - return clash.isProtected - ? `${clash.name} already exists in this project, as a protected branch` - : `${clash.name} already exists in this project`; - } - return undefined; -} - -export async function createBranchInBrowser(p: WebBranchCreateParams): Promise { - // The nonce is minted inside `runBrowserWizard` and reaches a caller only - // through `renderFirst`. A second standalone step has to be rendered with - // that same token, so it is captured on the way past rather than each flow - // minting its own — which would put the security token of every browser path - // in the hands of each path instead of in one place. - let nonce = ''; - - // What this run has answered so far. Seeded from argv, then folded forward - // as each step comes back, so the plan is recomputed from one place and the - // rail redraws itself without this file deciding what a stop's state is. - let answered = { branchName: p.branchName, isProtected: p.isProtected }; - const render = (): string => - renderScreen('branch-create', buildBranchCreateData({ ...p, ...answered }, nonce)); - - const out = await runBrowserWizard( - { - title: `New branch — ${p.projectName}`, - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Answered — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { - done: true, - result: { name: answered.branchName, isProtected: false, cancelled: true }, - }; - } - - const view = currentView(answered); - - if (view === 'name') { - const name = typeof payload.name === 'string' ? payload.name.trim() : ''; - const problem = branchNameProblem(name, p.existingBranches); - if (problem) return { error: problem }; - answered = { ...answered, branchName: name }; - } else if (view === 'protection') { - // The screen sends a boolean or nothing. Anything else is a malformed - // submit, and guessing would decide who can reach this branch — the - // one security setting this flow exists to set. - if (typeof payload.isProtected !== 'boolean') { - return { error: 'That is not an answer the protection step can produce.' }; - } - answered = { ...answered, isProtected: payload.isProtected }; - } else { - // Nothing was outstanding, so no screen asked this. Refusing keeps a - // stray submit from creating a branch nobody was asked about. - return { error: 'There is nothing left to answer on this run.' }; - } - - const next = currentView(answered); - if (next === null) { - return { - done: true, - result: { - name: answered.branchName, - isProtected: answered.isProtected === true, - cancelled: false, - }, - }; - } - // A whole document cannot be spliced into the open page, so it is handed - // back as `standalone` and the browser reloads to receive it. - return { screen: { html: render(), standalone: true } }; - }, - ); - return out as WebBranchCreateResult; -} - -// --------------------------------------------------------------------------- -// branch-list -// --------------------------------------------------------------------------- - -export interface WebBranchListParams { - projectName: string; - /** Null when the CLI could not derive one — then no row is marked current. */ - activeBranch: string | null; - /** The server's list, verbatim. Protection is read off `is_protected`. */ - branches: Branch[]; - /** Variables held per branch, where keep.lock has counted them. */ - variableCounts?: Record; - /** Whether this run may delete branches at all. `capy checkout` may not. */ - canDelete: boolean; - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; - /** Test hook only: fixes `now` so the age column is deterministic. */ - now?: Date; -} - -/** What the browser picked. `cancelled` means the run must not switch. */ -export interface WebBranchListResult { - branch: string; - cancelled: boolean; -} - -export function buildBranchListData(p: WebBranchListParams, nonce: string): BranchListData { - const branches: BranchRow[] = p.branches.map((b) => ({ - id: b.id, - name: b.name, - // Off `is_protected`, never off the name. `branchResolver` holds itself to - // that — a branch called `production` is not protected and a protected - // branch called `spike` is — and a screen that guessed from the name would - // be the one place the product disagreed with itself about who can read a - // secret. - isProtected: b.is_protected, - isCurrent: b.name === p.activeBranch, - variableCount: p.variableCounts?.[b.name], - age: b.created_at ? stripAnsi(`created ${formatRelativeTime(b.created_at, p.now)}`) : undefined, - // The server's branch list carries no per-caller grant, so for a protected - // branch this is genuinely unknown here. `true` is the honest unknown: it - // offers exactly what the terminal picker offers, and the 403 the checkout - // already handles says the accurate thing afterwards. `false` would read - // as knowledge the CLI does not have, and would lock a member who DOES - // hold a grant out of a branch the terminal would let them switch to. - canSwitch: true, - })); - - return { - nonce, - projectName: stripAnsi(p.projectName), - activeBranch: p.activeBranch, - branches, - canDelete: p.canDelete, - // Omitted rather than zeroed: `checkout` refuses to run at all with a dirty - // working tree, so by the time this screen is served there is no drift to - // count, and `0` would be a claim this code did not make. - nonTty: { - command: 'capy checkout ', - why: 'Switching branches changes which secrets land in .env, so it is never chosen for you.', - }, - }; -} - -/** - * Serve the branch list and wait for a row. - * - * The submitted name is resolved against the list the server sent, not - * trusted: `switch` is followed by a real checkout of whatever comes back, and - * a name the screen could not have offered did not come from the screen. - */ -export async function chooseBranchInBrowser(p: WebBranchListParams): Promise { - const out = await runBrowserWizard( - { - title: `Branches — ${p.projectName}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Picked — back to your terminal.', - renderFirst: (nonce) => renderScreen('branch-list', buildBranchListData(p, nonce)), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { branch: '', cancelled: true } }; - } - if (payload.__action === 'delete') { - // The screen only draws its delete control when `canDelete` is set, and - // a checkout never sets it. Deleting somebody's secrets off the back of - // a submit the screen could not have produced is the failure this - // refusal exists to prevent. - return { error: 'Deleting a branch is not part of capy checkout. Use capy branch -D .' }; - } - if (payload.__action !== 'switch') { - return { error: 'That is not an action this screen offers.' }; - } - - const name = typeof payload.branch === 'string' ? payload.branch : ''; - const row = p.branches.find((b) => b.name === name); - if (!row) return { error: 'That branch is not in this project.' }; - if (row.name === p.activeBranch) { - // The screen holds its button on the current row, so this can only - // arrive from something that is not the screen. - return { error: 'This directory is already on that branch.' }; - } - - return { done: true, result: { branch: row.name, cancelled: false } }; - }, - ); - return out as WebBranchListResult; -} diff --git a/src/ui/browserWizard.ts b/src/ui/browserWizard.ts deleted file mode 100644 index 69a59a75..00000000 --- a/src/ui/browserWizard.ts +++ /dev/null @@ -1,776 +0,0 @@ -// Multi-step loopback "wizard" — the browser-rendered counterpart to a sequence -// of TTY prompts. It serves a SEQUENCE of screens over ONE persistent local server -// so an agent-driven `capy --web` can render its interactive steps (init trainstops, -// the sync conflict resolver) in the user's browser instead of blocking on inquirer. -// -// Security mirrors the single-step intake (src/commands/addCommand.ts runWebIntake): -// a single-use 32-byte nonce, Host/Origin pinning to the exact loopback address -// (defends DNS-rebinding), a body-size cap, a per-question timeout, and full cleanup. The -// only new capability is statefulness: each POST advances to the next screen, and -// the caller's reducer decides the next screen / done / inline error. -// -// What flows where: the browser submits each screen's answer — serialized from its -// form fields, or built by the screen itself and handed to `window.capySubmit` — and -// the reducer turns that into the NEXT screen or a final result. Values the user types -// stay in the browser→CLI loopback; this transport never prints or logs them. -// -// THE CLOCK ONLY RUNS WHILE A QUESTION IS OUTSTANDING. It used to be a single -// wall clock, set once at `listen()` and never touched again — which was fine -// while every question stood up its own server for one question, and stopped -// being fine the moment a flow held ONE window across a whole run. The init -// wizard's budget then had to cover not just the answering but everything the -// CLI does between two stops: creating an organization, which opens two more -// browser windows of its own and asks somebody to write down 24 words. Five -// minutes is a generous time to answer a question and a short time to be alive. -// So the timer is armed while the browser owes us an answer and disarmed the -// moment one arrives, for exactly as long as the reducer is working. A CLI that -// hangs between two stops is a CLI bug and is not a thing this transport can -// fix by killing a window the user is still looking at. -// -// ENDINGS. A flow ends submitted or cancelled — and either way the page has to -// say which, so `{ done }` is not the only way out. A reducer can also end on a -// final SCREEN (`final: true`): the browser is told to reload, receives a whole -// document that asks nothing, and the wizard resolves once that document has -// been served. That is what a failed run uses. `{ done: true }` renders as the -// ending the page's own button implied — the compiled screens decide "submitted" -// vs "cancelled" from which control was pressed — so answering a submit with -// `done` on a run that just DIED would draw a green check over a failure. -import { createServer, type IncomingMessage, type ServerResponse } from 'http'; -import { randomBytes } from 'crypto'; -import type { Socket } from 'net'; -import { CapyError, ERROR_CODES } from '../types'; -import { nonceEqual, isLoopbackHost, isAllowedOrigin } from '../commands/intakeSecurity'; -import { DEPLOY_PAGE_CSS } from './deployPage/generatedAssets'; -import { screenHeaders } from './screens/serve'; - -const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; -const MAX_BODY = 5_000_000; -/** - * How long a final screen waits to be collected before the flow gives up on it. - * - * The browser is holding the POST that releases it, so the reload lands in - * milliseconds; this cap is for the window that has already gone, so a CLI that - * ends on a failure page cannot be held open by a tab nobody will ever load. - */ -const DEFAULT_FINAL_GRACE_MS = 10 * 1000; - -/** - * Where the page reports that it is going away, and how long the flow waits - * before believing it. - * - * The wait exists because a standalone step ADVANCES by reloading (see - * `WizardScreen.standalone`), and a reload is a page going away followed - * immediately by the same page coming back. - * - * THE WAIT IS NOT WHAT TELLS THEM APART. `sendBeacon` is fire-and-forget and - * the reload's GET is a fresh connection, so the two arrive in whichever order - * the kernel feels like — measured, the beacon lands about 4ms AFTER the GET it - * was supposed to be cancelled by, every time. A flow that believed the beacon - * unless something re-requested the document inside a grace therefore armed a - * self-destruct on every healthy advance, and a mistyped recovery phrase ended - * the run while the user was still retyping. What tells them apart is the - * GENERATION the beacon carries (see `servedGen`): a document that has already - * been replaced was reloaded, whichever order the requests land in. The grace - * below is now only for the other ordering, and it no longer has to be right. - */ -const CLOSED_PATH = '/__closed'; -const CLOSE_GRACE_MS = 1_200; - -/** - * The beacon's generation field: which served document is reporting that it is - * going away. - * - * A name, so the check that reads it and the script that writes it cannot - * drift apart silently. - */ -const BEACON_GEN = 'gen'; - -/** - * How a browser flow ended without an answer. - * - * An enum rather than a sentence, because every caller has to tell these three - * apart to say the true thing in the terminal, and two of them are not errors: - * - * declined a control on the page said no — the screen's own Cancel/Discard - * closed the window went away, which is a refusal and never consent - * timeout nobody answered before the step's deadline - */ -export type WebRefusal = 'declined' | 'closed' | 'timeout'; - -/** - * Nobody answered. - * - * A typed error rather than a message to match on. `runBrowserWizard` rejects - * for three different reasons — a socket that would not bind, a deadline that - * passed, a Ctrl+C — and only the first is a fault. A flow that wants to end a - * silent run as the refusal it is has to be able to tell them apart, and - * `SERVICE_ERROR` covers all three; `reason` is the signal that does not. - */ -export class BrowserRefusal extends CapyError { - constructor( - message: string, - public readonly reason: 'timeout' | 'interrupt', - ) { - super(message, ERROR_CODES.SERVICE_ERROR); - this.name = 'BrowserRefusal'; - } -} - -export const CAPY_LOGO_SVG = ``; - -// Escapes `<` so a value can never close the surrounding `; -} - -/** Put the beacon inside the document it belongs to, whatever shape it is. */ -function withCloseBeacon(html: string, nonce: string, gen: number): string { - const script = closeBeaconScript(nonce, gen); - const end = html.lastIndexOf(''); - return end === -1 ? html + script : html.slice(0, end) + script + html.slice(end); -} - -/** - * The reducer's verdict for a submitted screen. - * - * `{ screen }` with `final` set on it is the third ending: the flow stops ON - * that document instead of on the shell's "Done", and `result` is what the - * wizard resolves with once the browser has collected it. - */ -export type WizardDecision = - | { screen: WizardScreen; result?: unknown } - | { done: true; result: unknown } - | { error: string } - /** - * Answer this POST with `body` and leave the flow exactly where it was. - * - * A step is not the only thing a screen posts. `secret-table` asks for one - * variable's plaintext with `{ __action: 'reveal', key }` — a question, not - * an answer — and it needs the value in the response. Every other decision - * either advances, finishes or refuses, so without this the only way to hand - * a value back would be to finish the flow, which is not what happened. - */ - | { body: Record }; - -/** Called for each submitted screen. `step` is the 0-based index of the screen being - * submitted (0 = the first). Return the next screen, a final result, or an inline - * error (the browser stays on the current screen and shows it). May be async. */ -export type WizardSubmit = (step: number, payload: Record) => Promise; - -/** - * Run a multi-step browser wizard. Resolves with the reducer's `result` once it - * returns `{ done }` or once a `final` screen has been served; rejects on - * timeout, server error, or Ctrl+C. - */ -export function runBrowserWizard(params: WizardParams, onSubmit: WizardSubmit): Promise { - const nonce = randomBytes(32).toString('hex'); - const connections = new Set(); - let step = 0; - let busy = false; - let done = false; - /** - * The step the browser gets if it asks for the page right now. - * - * A shell flow never re-requests — it swaps `#screen` in place — so this only - * moves for standalone steps, which advance by reloading. Holding it here - * rather than always serving `firstScreen` is what makes a reload return the - * step the flow is actually on. - */ - let current: WizardScreen = params.renderFirst - ? { ...params.firstScreen, html: params.renderFirst(nonce) } - : params.firstScreen; - - return new Promise((resolve, reject) => { - let timer: NodeJS.Timeout | null = null; - /** Set once a `final` screen is waiting to be collected. Its `result`. */ - let ending: { result: unknown } | null = null; - /** - * How many documents this flow has served. - * - * This is the structural half of the close signal, and the half that does - * not depend on which of two racing requests wins. A reload comes back and - * asks for the page again, which bumps this; a closed window never does. So - * a beacon whose generation is no longer the one on screen is reporting a - * document that was REPLACED, not abandoned, and is ignored outright. - */ - let servedGen = 0; - /** - * Set while a page that said it was leaving might still come back. - * - * Only armed by a beacon for the CURRENT generation, and re-checked when it - * fires, so this is a courtesy delay rather than the thing the decision - * rests on. - */ - let goneTimer: NodeJS.Timeout | null = null; - - /** Ctrl+C at the terminal the browser was opened from. A refusal, not a fault. */ - const onSigint = (): void => { - cleanup(); - reject(new BrowserRefusal('Cancelled.', 'interrupt')); - }; - - const stillHere = (): void => { - if (goneTimer) clearTimeout(goneTimer); - goneTimer = null; - }; - - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - const addr = server.address(); - const port = addr && typeof addr === 'object' ? addr.port : 0; - - if (req.method === 'GET' && new URL(req.url ?? '/', `http://127.0.0.1:${port}`).pathname === '/') { - const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`); - if (url.searchParams.get('n') !== nonce) { - res.writeHead(403).end('forbidden'); - return; - } - // The document is being asked for, so whatever left a moment ago came - // back: this is a reload, not a closed window. - stillHere(); - // The wizard page went out with only a content-type. It is a page that - // renders credentials and can open sockets, so it gets the same policy - // as any other interactive screen: no remote origins, no eval, no - // framing, no native form posts, and `connect-src` limited to the - // loopback origin it was served from — which is the only place its - // answers are supposed to go. - res.writeHead(200, screenHeaders({ interactive: true })); - // A standalone step is its own document and is served as-is. Every - // other step is a fragment and gets the shell. `current` rather than - // `firstScreen`, because a standalone flow advances by reloading this - // same URL — the browser comes back for step 2 and must not be handed - // step 1 again. - // A new document is going out, so anything the old one said about - // leaving is now about a generation that no longer exists. - servedGen += 1; - const html = current.standalone - ? current.html - : wizardPage(params.title, current.html, nonce, params.doneMessage); - res.end(params.closeIsRefusal ? withCloseBeacon(html, nonce, servedGen) : html); - // The flow ended on this document. It has now been delivered, which is - // the whole reason the server was still up — so let the bytes flush and - // stop. A run that dies must not be kept alive by its own error page. - if (ending) { - const result = ending.result; - ending = null; - res.on('finish', () => - setTimeout(() => { - cleanup(); - resolve(result); - }, 250), - ); - } - return; - } - - // `/submit` advances the flow; a `routes` entry answers beside it without - // moving the step. Both go through the identical guards below, so a route - // cannot become a second, laxer door into the same server. - const path = new URL(req.url ?? '/', `http://127.0.0.1:${port}`).pathname; - const route = path === '/submit' ? onSubmit : params.routes?.[path]; - // The page reporting that it is going away. Not a route: it carries no - // answer and the reducer is never told about it — but it goes through - // every guard below, because a request that can end a run is a request - // that has to prove it came from the page we served. - const closing = params.closeIsRefusal !== undefined && path === CLOSED_PATH; - if (req.method === 'POST' && (route || closing)) { - const advances = path === '/submit'; - if (!isLoopbackHost(req.headers.host, port)) { - res.writeHead(403).end('bad host'); - return; - } - if (!isAllowedOrigin(req.headers.origin, port)) { - res.writeHead(403).end('bad origin'); - return; - } - let body = ''; - let aborted = false; - req.on('data', (c: Buffer) => { - body += c.toString(); - if (body.length > MAX_BODY) { - aborted = true; - res.writeHead(413).end('too large'); - req.destroy(); - } - }); - req.on('end', async () => { - if (aborted) return; - let parsed: { nonce?: unknown; payload?: unknown }; - try { - parsed = JSON.parse(body); - } catch { - res.writeHead(400).end('bad json'); - return; - } - if (!nonceEqual(parsed.nonce, nonce)) { - res.writeHead(403).end('bad nonce'); - return; - } - const payload = - parsed.payload && typeof parsed.payload === 'object' ? (parsed.payload as Record) : {}; - if (closing) { - // Answered before it is believed: a beacon is fire-and-forget and - // the tab is already gone. A flow that has finished, or is mid - // submit, is not waiting on this page any more. - res.writeHead(204).end(); - // WHICH document is leaving. A standalone step advances by - // RELOADING, so `pagehide` fires on a perfectly healthy advance and - // the beacon it sends usually arrives just AFTER the GET for the - // next document. That beacon names the generation that has already - // been replaced, and a replaced document was reloaded, not closed. - // Ordering cannot change that, which is the whole point: the old - // grace-window test was a race this lost every time it was measured. - const gen = payload[BEACON_GEN]; - if (typeof gen !== 'number' || gen !== servedGen) return; - if (done || busy || goneTimer) return; - goneTimer = setTimeout(() => { - goneTimer = null; - // Re-checked rather than assumed: if the page came back inside the - // grace, `servedGen` moved on and this beacon is stale after all. - if (done || gen !== servedGen) return; - done = true; - cleanup(); - resolve(params.closeIsRefusal!.result); - }, params.closeIsRefusal!.graceMs ?? CLOSE_GRACE_MS); - return; - } - // Something is still answering this step, so the page that said it - // was leaving was reloading. - stillHere(); - if (done) { - res.writeHead(409).end('already finished'); - return; - } - if (busy) { - res.writeHead(409).end('a submission is already in progress'); - return; - } - busy = true; - // The answer arrived; nothing is outstanding until the reducer says - // there is a next question. Whatever the CLI does in between is its - // own business and none of it is the browser keeping us waiting. - disarm(); - try { - const decision = await route!(step, payload); - if ('error' in decision) { - busy = false; - // Refused inline: the page keeps this step, so the same question - // is outstanding again and the clock goes back on. - arm(); - res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ error: decision.error })); - return; - } - if ('body' in decision) { - // A question, not an answer: the flow has not moved, so the step - // counter does not either and the server stays open for the step - // that is still on screen. - busy = false; - // And the clock goes back on. Every POST disarms it, because an - // answer means the browser owes us nothing while the reducer - // works — but this branch leaves the SAME question outstanding, - // so it is outstanding again the moment this response goes out. - // Without this a run that revealed one value had no deadline at - // all from then on: the page could be closed and the command - // would wait for a browser that was never coming back. - arm(); - res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(decision.body)); - return; - } - if (!advances) { - // A side route may only answer or refuse. Advancing from one - // would move the flow past a step whose page never asked to - // leave it, and that page would keep posting to a spent token. - busy = false; - res - .writeHead(500, { 'content-type': 'application/json' }) - .end(JSON.stringify({ error: 'That request cannot advance this flow.' })); - return; - } - if ('done' in decision) { - done = true; - const result = decision.result; - res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ done: true })); - setTimeout(() => { - cleanup(); - resolve(result); - }, 250); - return; - } - // advance to the next screen - step += 1; - current = decision.screen; - busy = false; - if (decision.screen.final) { - // The flow ends on this document. Nothing more may be submitted, - // and the wizard resolves when the browser has been served it — - // see the GET above. The grace timer is the window that has - // already been closed: without it a run that died would be held - // open by a page nobody is coming back for. - done = true; - ending = { result: decision.result }; - armFinalGrace(); - res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ next: true })); - return; - } - // A new question is outstanding from the moment this response goes - // out, so the clock the answer stopped starts again. - arm(); - if (decision.screen.standalone) { - // The next step is a whole document, so it cannot be handed back - // as a fragment for the current page to swap in. It is held in - // `current`, and the browser is told there IS a next step so it - // reloads and receives it from the GET above. - // - // `{ next: true }` is the contract the ui `Wizard` component - // already implements: it freezes its controls and says "Saved. - // Opening the next step…", precisely because this stop's token - // is spent and the page is about to be replaced. - res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ next: true })); - return; - } - res - .writeHead(200, { 'content-type': 'application/json' }) - .end(JSON.stringify({ screen: decision.screen.html, doneMessage: decision.screen.doneMessage })); - } catch (err) { - busy = false; - // The step failed and the page stays on it, so the question is - // outstanding again. - arm(); - const message = err instanceof Error ? err.message : 'Step failed.'; - res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: message })); - } - }); - return; - } - - res.writeHead(404).end('not found'); - }); - - /** Stop the clock. Nothing is being waited on that a person could supply. */ - const disarm = (): void => { - if (timer) clearTimeout(timer); - timer = null; - }; - - /** Start it again: the browser owes this flow an answer from now. */ - const arm = (): void => { - disarm(); - const ms = params.timeoutMs ?? DEFAULT_TIMEOUT_MS; - timer = setTimeout(() => { - cleanup(); - // A `BrowserRefusal`, not a bare `CapyError`: same message, same - // `SERVICE_ERROR` code, but `reason` lets a caller tell "nobody - // answered" apart from "the socket would not bind" without reading the - // sentence. Flows that end a silent run as the refusal it is need that. - reject( - new BrowserRefusal( - `Timed out waiting for the browser (${Math.round(ms / 1000)}s with the question unanswered).`, - 'timeout', - ), - ); - }, ms); - timer.unref(); - }; - - /** - * The flow is over and one document is still to be delivered. - * - * Resolves rather than rejects when it elapses. The ending already - * happened; this timer only decides how long we hold a page open for a - * window that may already be gone. - */ - const armFinalGrace = (): void => { - disarm(); - timer = setTimeout(() => { - const result = ending?.result; - ending = null; - cleanup(); - resolve(result); - }, params.finalGraceMs ?? DEFAULT_FINAL_GRACE_MS); - timer.unref(); - }; - - const cleanup = (): void => { - disarm(); - stillHere(); - // A wizard that resolved normally used to leave its SIGINT handler on the - // process for the rest of the run. One flow here opens two wizards per - // edited variable, so the listener count grew without bound and node - // started warning about a leak partway through a normal session. - process.off('SIGINT', onSigint); - try { - server.close(); - } catch { - /* ignore */ - } - for (const c of connections) { - try { - c.destroy(); - } catch { - /* ignore */ - } - } - connections.clear(); - }; - - server.on('connection', (c: Socket) => { - connections.add(c); - c.on('close', () => connections.delete(c)); - }); - server.on('error', (err) => { - cleanup(); - reject(err); - }); - - // The first question is outstanding from the moment there is a URL to open. - arm(); - - process.once('SIGINT', onSigint); - - server.listen(0, '127.0.0.1', async () => { - const addr = server.address(); - const port = addr && typeof addr === 'object' ? addr.port : 0; - const url = `http://127.0.0.1:${port}/?n=${nonce}`; - params.onListen?.(url); - console.log(''); - console.log(` Continue in your browser (your inputs never touch this terminal or the AI):`); - console.log(` ${url}`); - console.log(''); - if (params.open) { - // A dialog, never a tab: this is the CLI's own page, served on - // loopback behind a single-use nonce, with nothing to sign into. The - // window is sized from the document about to be served — see - // `wantsWideWindow`. - const { openScreen } = await import('./openScreen'); - const { wantsWideWindow } = await import('./screens/serve'); - await openScreen(url, { kind: 'dialog', wide: wantsWideWindow(current.html) }); - } - }); - }); -} - -/** The page shell: shared CSS/logo + a `#screen` container the client swaps as the - * wizard advances, plus the generic submit/serialize/advance JS. The screen HTML is - * built server-side by the caller (and must escape its own dynamic content). */ -export function wizardPage(title: string, firstScreenHtml: string, nonce: string, doneMessage?: string): string { - const fallbackDone = doneMessage ?? 'Done. You can close this tab.'; - return ` - - - - - Capy — ${title.replace(/</g, '<')} - - - -
-
-
${CAPY_LOGO_SVG}
-

${title.replace(/ -

-
-
-
- - -`; -} diff --git a/src/ui/byocScreens.ts b/src/ui/byocScreens.ts deleted file mode 100644 index afeddf9d..00000000 --- a/src/ui/byocScreens.ts +++ /dev/null @@ -1,318 +0,0 @@ -// `capy byoc --web` — pointing this machine at a self-hosted Capy, -// served as the compiled `byoc-connect` screen. -// -// The terminal version is a `while (true)` whose own header comment admits the -// only exit other than success is Ctrl-C: probe, print one red line, re-prompt, -// probe again, forever. Three things follow from that shape, and the screen -// answers all three — there is a Cancel; a failure says which of ENOTFOUND, -// ECONNREFUSED and a timeout it was, because those are three problems with -// three fixes; and the certificate is on screen before you agree to trust it, -// where the terminal asks "Trust it via a CA bundle?" with a default of yes and -// shows you nothing about the certificate at all. -// -// Every decision here keys off a `ProbeCode`, never off the reason sentence. -// Exactly one code opens the CA sub-flow, and deciding that by searching for -// "self-signed" in a string is a bug waiting for a reword. -// -// Renders no secret material: a URL, a hostname-derived profile name, and the -// path to a public certificate bundle. -import { runBrowserWizard } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { byocConnectPlan } from '../core/onboardingPlan'; -import type { - ByocConnectData, - ExistingProfile, - ProbeOutcome, -} from './screens/contract'; - -/** Which question the flow is standing on. `verifying` is not one of them. */ -export type ByocView = 'url' | 'ca-trust' | 'ca-path' | 'name'; - -export interface WebByocParams { - /** Prefill for the URL field: the argv positional or the built-in guess. */ - defaultUrl: string; - /** Where `defaultUrl` came from, so an unasked-for default is legible. */ - urlSource: 'argv' | 'builtin'; - /** - * Probe a URL with an optional CA bundle. Supplied by the caller so this - * module never opens a socket of its own, and so a test drives the whole - * flow without one. `attempt` is this flow's to count, not the prober's. - */ - probe: (url: string, caBundle?: string) => Promise>; - /** `deriveProfileName(url)` — `capy.acme.com` → `acme`, else `byoc`. */ - suggestName: (url: string) => string; - /** Everything already in the config, so a collision is seen before submit. */ - existingProfiles: ExistingProfile[]; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** What the browser settled. `cancelled` means nothing may be written. */ -export interface WebByocResult { - url: string; - caBundle?: string; - profileName: string; - /** A profile of this name already existed and the user agreed to replace it. */ - replaced: boolean; - cancelled: boolean; -} - -/** Everything this run has answered so far. Drives both the view and the rail. */ -export interface ByocState { - url?: string; - urlFromArgv?: boolean; - probe?: Omit & { attempt: number }; - /** The bundle that made this instance verify. */ - caBundle?: string; - /** The user asked to trust a bundle and has not supplied a working one yet. */ - bundleRequested?: boolean; - /** The last path typed, prefilled so a typo is edited rather than retyped. */ - typedBundlePath?: string; - /** - * The certificate offer is spent for this address. - * - * Two ways to spend it, both ending at the same place — the address question. - * The user declined it, or they accepted and the bundle they named was - * readable and still did not chain, which is the terminal's own conclusion - * that the address (or the instance) is the problem rather than the path. - */ - declinedBundle?: boolean; - verified?: boolean; - profileName?: string; -} - -/** - * Where the flow is standing, derived from what it has answered. - * - * Derived rather than assigned, for the same reason `branchScreens` derives - * its view: the page that gets served and the rail drawn beside it cannot - * disagree about where the traveller is if only one of them decides. - */ -export function byocView(s: ByocState): ByocView { - if (s.verified) return 'name'; - // A bundle that could not be opened is a fixable typo in a path, so the - // question stays where it was. The terminal throws you back to the URL - // prompt for this, which asks about the one thing that was not wrong. - if (s.probe?.code === 'ca_unreadable') return 'ca-path'; - if (s.probe?.code === 'tls_untrusted') { - if (s.bundleRequested) return 'ca-path'; - if (s.declinedBundle) return 'url'; - return 'ca-trust'; - } - return 'url'; -} - -/** - * How this step is answered without a browser. - * - * Honest about what argv actually accepts today: the URL is a positional, and - * there is no flag at all for the profile name or the CA bundle — the terminal - * asks for both and has nowhere else to get them. - */ -const NON_TTY: ByocConnectData['nonTty'] = { - command: 'capy byoc ', - why: 'The address is a positional argument. The profile name and any CA bundle have no flags at all, so a run with nowhere to ask has nothing to fall back on and refuses rather than naming a profile for you.', -}; - -/** - * What this run has learned about the instance's certificate, in three states. - * - * A handshake that COMPLETED is the only thing that answers the question. - * `connection_failed`, `http_status`, `not_json` and `not_capy` all happened - * without a certificate ever being judged — the name did not resolve, nothing - * was listening, the thing that answered was not Capy — so they say nothing - * about one, and answering `false` for them is what struck `Certificate — not - * needed` through the rail on the strength of a probe that never got that far. - * - * `ca_unreadable` only ever follows an untrusted certificate: it is produced - * when a bundle offered FOR one could not be opened, so the question is still - * very much open. - */ -function certVerdict(probe: ByocState['probe']): boolean | undefined { - if (!probe) return undefined; - if (probe.code === 'ok') return false; - if (probe.code === 'tls_untrusted' || probe.code === 'ca_unreadable') return true; - return undefined; -} - -export function buildByocConnectData( - p: WebByocParams & { state?: ByocState }, - nonce: string, -): ByocConnectData { - const s = p.state ?? {}; - const view = byocView(s); - const url = s.url ?? p.defaultUrl; - /** - * Whether the address this run is working from is SETTLED. - * - * Everything a probe answered belongs to the address that produced it. While - * the flow is standing back on the address question — the probe failed, or - * the certificate offer was declined — that address is not an answer, and - * neither is anything downstream of it. The rail goes back with the page - * instead of keeping a tick against a question being asked again. - */ - const settled = view !== 'url'; - - return { - nonce, - step: view, - stops: byocConnectPlan({ - url: settled ? s.url : undefined, - urlFromArgv: s.urlFromArgv ?? p.urlSource === 'argv', - verified: s.verified, - // Undefined until something has actually judged a certificate: the rail - // draws that stop as a blank it still needs an answer for, rather than - // promising to skip it. - certUntrusted: settled ? certVerdict(s.probe) : undefined, - caBundle: (settled && s.caBundle) || undefined, - profileName: s.profileName, - }), - defaultUrl: url, - urlSource: p.urlSource, - probe: s.probe, - suggestedName: view === 'name' ? p.suggestName(url) : undefined, - existingProfiles: p.existingProfiles, - caBundlePath: s.typedBundlePath, - nonTty: NON_TTY, - }; -} - -/** - * Serve the byoc connect flow and return what it settled. - * - * The probe runs inside the reducer, while the submit is in flight, so the - * page's own busy state is the "Trying …/health" line the terminal prints. - * `verifying` exists in the contract as a step of its own and is deliberately - * never served: a loopback server cannot push a navigation, so a page parked - * on it would have to be told to come back and there is nothing to tell it. - */ -export async function connectByocInBrowser(p: WebByocParams): Promise { - const state: ByocState = { urlFromArgv: p.urlSource === 'argv' }; - let nonce = ''; - let attempt = 0; - - const render = (): string => - renderScreen('byoc-connect', buildByocConnectData({ ...p, state }, nonce)); - - const cancelled: WebByocResult = { - url: '', - profileName: '', - replaced: false, - cancelled: true, - }; - - const runProbe = async (url: string, caBundle?: string): Promise => { - attempt += 1; - const outcome = await p.probe(url, caBundle); - state.probe = { ...outcome, attempt }; - state.url = outcome.url; - state.verified = outcome.code === 'ok'; - if (outcome.code === 'ok') { - state.caBundle = caBundle; - return; - } - // A bundle that did not make the instance verify is not a bundle this - // profile may be saved with. - state.caBundle = undefined; - if (caBundle !== undefined && outcome.code !== 'ca_unreadable') { - // The path was readable and still did not chain, so the answer is not a - // better path — the address or the instance is the problem. Back to the - // URL question, which is where the terminal lands too: its loop only - // offers `promptForCaBundle` while `!caBundle`, so a second failure with - // one in hand falls straight through to `promptForUrl`. - // - // Both flags, or `byocView` reads `tls_untrusted` with neither set and - // asks "trust it via a CA bundle?" all over again — the one question - // this run has already answered twice. - state.bundleRequested = false; - state.declinedBundle = true; - } - }; - - const out = await runBrowserWizard( - { - title: 'Connect a self-hosted Capy', - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Connected — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') return { done: true, result: cancelled }; - - const view = byocView(state); - - if (view === 'url') { - const url = typeof payload.url === 'string' ? payload.url.trim() : ''; - // The screen runs the CLI's own `new URL(normalize(input))` check and - // holds its button, so an unparseable address did not come from it. - if (!url) return { error: 'URL required' }; - // A new address drops every assumption the old one produced. - state.declinedBundle = false; - state.bundleRequested = false; - state.caBundle = undefined; - await runProbe(url); - return { screen: { html: render(), standalone: true } }; - } - - if (view === 'ca-trust') { - if (typeof payload.trust !== 'boolean') { - // Two rows, one boolean. Anything else would have this machine - // decide for itself what certificate authority to believe. - return { error: 'That is not an answer the certificate step can produce.' }; - } - if (!payload.trust) { - // Declined: back to the URL question, exactly as the terminal falls - // through when the confirm is answered no. - state.declinedBundle = true; - return { screen: { html: render(), standalone: true } }; - } - state.bundleRequested = true; - return { screen: { html: render(), standalone: true } }; - } - - if (view === 'ca-path') { - const path = typeof payload.caBundle === 'string' ? payload.caBundle.trim() : ''; - if (!path) return { error: 'Path required' }; - state.typedBundlePath = path; - await runProbe(state.url ?? p.defaultUrl, path); - return { screen: { html: render(), standalone: true } }; - } - - // name — and the overwrite gate that rides with it. - const name = typeof payload.name === 'string' ? payload.name.trim() : ''; - if (!name) return { error: 'Name required' }; - if (!/^[a-z0-9][a-z0-9-_]*$/i.test(name)) { - return { error: 'Use letters, digits, hyphen, underscore' }; - } - const collision = p.existingProfiles.find((e) => e.name === name && e.url); - if (collision && payload.replace !== true) { - // The screen holds its button until the replace toggle is on, so this - // is a submit it could not have produced — and `saveAndActivateProfile` - // upserts, so applying it would overwrite an address kept nowhere else. - return { error: `Replacing ${name} has to be agreed to first.` }; - } - state.profileName = name; - - return { - done: true, - result: { - url: state.url ?? p.defaultUrl, - caBundle: state.caBundle || undefined, - profileName: name, - replaced: Boolean(collision), - cancelled: false, - } satisfies WebByocResult, - }; - }, - ); - return out as WebByocResult; -} diff --git a/src/ui/commandErrorScreen.ts b/src/ui/commandErrorScreen.ts deleted file mode 100644 index a8bc8628..00000000 --- a/src/ui/commandErrorScreen.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * The browser half of `errorScreen.ts`. - * - * `displayErrorAndExit` writes ANSI and exits. Under `--web` that goes to a - * stream nobody is reading — the flag exists because the caller is an agent — - * and if a window is already open it is left holding a page whose server the - * exit has just closed. So the run's last fact, the one that says what to do - * next, was the one fact that never reached the surface anybody was looking at. - * - * This builds the same eight layouts as data. It is a straight port and the - * wording is deliberately identical: two surfaces describing one failure in - * two vocabularies is worse than either alone, and someone comparing a - * terminal transcript with a screenshot has to be able to see they match. - * - * WHAT IT MAY NOT DO. Decide which failure this is. That is settled before we - * get here — `classifyResponse` mints the code at the boundary and - * `renderError` switches on it — so this file switches on the same code and - * never looks at a message to choose a shape. It reads `error.message` in - * exactly one way: as a sentence to display. - */ -import { CapyError, ERROR_CODES } from '../types/index'; -import { isMembershipRevokedError } from '../errors/membershipRevoked'; -import type { CommandErrorData } from './screens/contract'; - -export interface ErrorContext { - projectName?: string; - projectId?: string; - branch?: string; -} - -/** Strip the bold/dim codes the CLI's own messages carry into a browser. */ -const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -const fact = (label: string, value?: string): Array<{ label: string; value: string }> => - value ? [{ label, value: plain(value) }] : []; - -/** - * The typed error as a payload. - * - * `code` is whatever the CLI branched on, verbatim, so the page carries the - * same handle an agent would have got from an exit path — never a - * re-description of it. - */ -export function buildCommandErrorData(error: any, ctx: ErrorContext = {}): CommandErrorData { - if (!(error instanceof CapyError)) { - return { - code: 'UNKNOWN', - title: 'An unexpected error occurred', - ...(error?.message ? { detail: plain(String(error.message)) } : {}), - }; - } - - const base = { code: error.code }; - const detail = plain(error.details?.detail || error.message || ''); - - switch (error.code) { - case ERROR_CODES.AUTH_FAILED: - return { - ...base, - title: 'Authentication failed', - detail, - remedies: [ - { text: 'Sign in again', command: 'capy' }, - { text: 'Check your internet connection' }, - { text: 'Verify your account at https://capy.sc' }, - { - text: 'If you own the org, decrypt offline with your seed phrase', - command: 'capy decrypt', - }, - ], - }; - - case ERROR_CODES.PERMISSION_DENIED: { - if (isMembershipRevokedError(error)) { - return { - ...base, - title: 'Access revoked', - detail: 'You are no longer a member of this organization.', - remedies: [{ text: 'Ask an org admin to re-invite you' }], - }; - } - // The wrong-key case, chosen on the typed code — including one carried - // through as a `cause`, which is how a decrypt failure arrives wrapped. - const wrongKey = - error.details?.cause?.code === ERROR_CODES.DECRYPT_KEY_MISMATCH; - if (wrongKey) return decryptKeyMismatch(error); - return { - ...base, - title: 'Permission denied', - detail: ctx.branch - ? `You do not have access to this branch (${plain(ctx.branch)}).` - : 'You do not have access to these secrets.', - context: fact('Branch', ctx.branch), - remedies: [ - { text: 'Ask your project admin for access' }, - { - text: 'Already set up on another machine? Copy a redeem code across', - command: 'capy transport', - }, - { text: 'Or have a teammate send you an invite' }, - ], - }; - } - - case ERROR_CODES.DECRYPT_KEY_MISMATCH: - return decryptKeyMismatch(error); - - case ERROR_CODES.NETWORK_ERROR: - return { - ...base, - title: 'Connection failed', - detail: 'Could not reach the Capy service.', - remedies: [ - { text: 'Check your internet connection' }, - { text: 'Check the service is running (capy-dev: http://localhost:3000)' }, - { - text: 'If you own the org, decrypt offline with your seed phrase', - command: 'capy decrypt', - }, - ], - }; - - case ERROR_CODES.PROJECT_NOT_FOUND: - return { - ...base, - title: 'Project not found', - detail: ctx.projectName - ? `Project "${plain(ctx.projectName)}" does not exist on the server.` - : 'This project does not exist on the server.', - context: [...fact('Project', ctx.projectName), ...fact('ID', ctx.projectId)], - causes: [ - 'The database was reset', - 'The project was deleted', - 'The keep.lock file is from a different environment', - ], - remedies: [ - { text: 'Delete keep.lock and .capy/, then re-initialize', command: 'capy' }, - { text: 'Or run capy — it will offer to recreate the project', command: 'capy' }, - ], - }; - - case ERROR_CODES.BRANCH_NOT_FOUND: - return { - ...base, - title: 'Branch not found', - detail, - context: fact('Project', ctx.projectName), - remedies: [{ text: 'See the branches you can reach', command: 'capy branch' }], - }; - - case ERROR_CODES.INVALID_FORMAT: - return { - ...base, - title: 'Invalid file format', - detail, - remedies: [ - { - text: 'Delete keep.lock and .capy/, then re-initialize', - command: 'capy', - }, - ], - }; - - case ERROR_CODES.NO_KEEP_FILE: - return { - ...base, - title: 'No keep.lock file found', - detail: 'This project has not been initialized with Capy yet.', - remedies: [{ text: 'Initialize this directory', command: 'capy' }], - }; - - // The local-state refusals, in the same order and the same words as - // `errorScreen.ts` tells them. These are the ones an agent is most likely - // to hit first — a wrong variable name, a directory that was never - // initialised — so `context` carries the branch and the names that WOULD - // have worked, which is the difference between a page that ends the run - // and one that lets the caller correct itself and try again. - case ERROR_CODES.NO_ACTIVE_BRANCH: - return { - ...base, - title: 'No active branch', - detail: 'Nothing here records which branch this directory is on.', - remedies: [{ text: 'Select a branch', command: 'capy' }], - }; - - case ERROR_CODES.NO_MANAGED_KEYS: - return { - ...base, - title: 'No managed keys to rotate on this branch', - detail: - 'Rotation goes through the provider, so a variable has to be linked to one first.', - context: fact('Branch', error.details?.branch ?? ctx.branch), - remedies: [ - { text: 'Link a variable to a provider', command: 'capy connect ' }, - { text: 'Or set up an existing variable', command: 'capy rotate' }, - ], - }; - - case ERROR_CODES.NO_VARIABLES: - return { - ...base, - title: 'No variables on this branch yet', - context: fact('Branch', error.details?.branch ?? ctx.branch), - remedies: [ - { text: 'Add one to .env, then sync', command: 'capy' }, - { text: 'Or pull one from a provider', command: 'capy connect ' }, - ], - }; - - case ERROR_CODES.VARIABLE_NOT_FOUND: { - const variable = error.details?.variable; - const branch = error.details?.branch ?? ctx.branch; - const available: string[] = error.details?.available ?? []; - return { - ...base, - title: 'Variable not found', - detail: `${plain(String(variable))} is not in your environment on branch ${plain( - String(branch), - )}.`, - context: [ - ...fact('Variable', variable), - ...fact('Branch', branch), - // The names that would have worked. A page that says "not found" - // and stops makes the caller guess; this one is the answer. - ...fact('Available', available.join(', ')), - ], - remedies: [ - { text: 'Add it to .env, then sync', command: 'capy' }, - ...(available.length > 0 ? [{ text: 'Or use one of the names above' }] : []), - ], - }; - } - - case ERROR_CODES.NO_CONNECTORS: - return { - ...base, - title: 'No connectors are registered', - detail: 'This build has no third-party integration to promote a variable to.', - }; - - case ERROR_CODES.DEV_LIVE_FIREWALL: { - // Told apart on the structured flag, never on the sentence. - const nothingLeft = error.details?.nothingLeft === true; - const variables: string[] = error.details?.variables ?? []; - return { - ...base, - title: nothingLeft ? 'Nothing to rotate' : 'Live mode is not allowed in capy-dev', - detail: nothingLeft - ? 'All managed keys on this branch are live-mode.' - : `${plain(String(variables[0]))} is configured for live mode.`, - context: variables.length > 0 ? [{ label: 'Live keys', value: plain(variables.join(', ')) }] : [], - causes: ['capy-dev never touches a live credential'], - remedies: [{ text: 'Rotate with the production binary', command: 'capy rotate' }], - }; - } - - case ERROR_CODES.QUOTA_EXCEEDED: - return quotaExceeded(error, detail); - - default: - return { - ...base, - title: error.details?.status - ? `Service error (${error.details.status})` - : 'Something went wrong', - detail, - context: [...fact('Project', ctx.projectName), ...fact('Branch', ctx.branch)], - }; - } -} - -function decryptKeyMismatch(error: CapyError): CommandErrorData { - return { - code: ERROR_CODES.DECRYPT_KEY_MISMATCH, - title: 'Cannot decrypt secrets', - detail: "These secrets were encrypted with a different project's key.", - context: fact('Variable', error.details?.variable), - causes: [ - 'The project was re-initialized to a different org', - 'The keep.lock/.capy files were reset without clearing .env', - ], - remedies: [ - { text: 'Delete .env and pull fresh secrets', command: 'capy' }, - { text: 'Or restore .env from your .env.pre-capy.old backup' }, - ], - }; -} - -function quotaExceeded(error: CapyError, detail: string): CommandErrorData { - const kind = error.details?.kind; - const limit = error.details?.limit; - - const upgradeUrl = error.details?.upgrade_url; - - // Creating an org is a PAYWALL now, not a hard cap (CAP-550/CAP-592: it is - // gated on the Team tier). This branch used to say "Each Capy account can - // own one organization", tell the user to go get invited, and deliberately - // withhold the upgrade link — so the paid gate reached the user with no way - // to pay. Vince, 2026-08-30: it should prompt them to pay. - if (kind === 'organization') { - return { - code: ERROR_CODES.QUOTA_EXCEEDED, - title: 'Upgrade required', - // The service sends the specific reason; fall back only if it didn't. - // COPY-FLAG: fallback line + remedies below are minimal-neutral - // placeholders. Vince approved the SERVICE strings on 2026-08-30 and - // the behaviour here ("prompt users to pay"), not this wording. - detail: detail || 'Creating an organization requires a Team plan.', - context: upgradeUrl ? [{ label: 'Upgrade', value: upgradeUrl }] : [], - remedies: [ - { text: 'Upgrade to add organizations' }, - { text: 'Or, to work in an existing org, ask its owner to invite you', command: 'capy invite' }, - ], - }; - } - - const noun = kind === 'project' ? 'Project' : 'Member'; - // COPY-FLAG: "Capy Business" was the single paid tier and is retired under - // the Free/Solo/Team model (CAP-550). Neutral placeholder until Vince - // approves per-tier wording. - const upgrade = - kind === 'project' - ? 'Upgrade your plan for more projects' - : 'Upgrade your plan to invite more members'; - return { - code: ERROR_CODES.QUOTA_EXCEEDED, - title: limit ? `${noun} limit reached (${limit}/org)` : `${noun} limit reached`, - detail, - context: [ - ...(limit ? [{ label: 'Limit', value: `${limit} per organization` }] : []), - ...(upgradeUrl ? [{ label: 'Upgrade', value: upgradeUrl }] : []), - ], - remedies: [ - { text: upgrade }, - { - text: - kind === 'project' - ? 'Or remove a project you no longer use' - : 'Or remove a member you no longer need', - }, - ], - }; -} diff --git a/src/ui/connectScreens.ts b/src/ui/connectScreens.ts deleted file mode 100644 index d8b1db39..00000000 --- a/src/ui/connectScreens.ts +++ /dev/null @@ -1,754 +0,0 @@ -// `capy connect`, served as compiled screens. -// -// Seven inquirer prompts live behind this command and none of them had a -// browser answer. Worse, most of them do not fire at all for the caller `--web` -// exists for: `isInteractive()` is false the moment stdin is piped, so an agent -// running `capy connect stripe` gets a silent `test` for the mode question, a -// refusal for the account picker, and a refusal for the variable picker. The -// questions were never asked, and the run either guessed or stopped. Under -// `--web` they are asked — in a browser, where the consequence of each one can -// actually be stated. -// -// Five screens, one per decision the command makes: -// -// connect-provider which connector, with what each will want from you -// connect-setup the variable, the mode, the account, the refresh offer -// connect-overwrite the guard in front of a variable that already holds a value -// connect-live-gate the typed account-ID echo in front of a live key -// connect-result what the run actually did -// -// The rail is `connectPlan`'s, not this file's and not the screens'. One -// builder feeds the payload and would feed `--json`, which is the only way the -// route a person reads and the array an agent parses can be claimed to be the -// same route. -// -// NO KEY VALUE REACHES A PAYLOAD. The comparison the overwrite guard draws is -// built from `fingerprint()`'s `abc…xyz` form and an eight-character key -// prefix — the same rule the terminal's own tables follow. `ctx.localPlaintext` -// holds the decrypted .env while these screens are served and none of it -// crosses. -import { runBrowserWizard } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { serveEndingPage } from './endingPage'; -import { connectPlan, type ConnectPlanInput } from '../commands/connectors/plans'; -import type { - Blocked, - ConnectAuthState, - ConnectIncomingKey, - ConnectLiveAction, - ConnectLiveGateData, - ConnectLiveGateStop, - ConnectModeOption, - ConnectOutcome, - ConnectOverwriteData, - ConnectProviderData, - ConnectResultData, - ConnectSetupData, - ConnectStep, - ConnectStop, - ConnectVarSlot, - ConnectVarState, - ConnectorChoice, - StripeAccount, -} from './screens/contract'; - -/** - * Strip terminal colour codes on the way into a payload. - * - * Applied to strings the CLI also PRINTS — a project name inside a header, a - * provider description — because a payload is not a terminal and an escape - * renders as a literal `[90m` in the browser. - * - * Deliberately NOT applied to a variable name or a provider id. Those are the - * identifiers the answer comes back as and the ones the command then writes to, - * so they have to round-trip byte for byte; they are checked against the list - * the CLI offered instead, which no rewriting can defeat. - */ -export const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -/** Shared options every one of these screens takes. */ -interface ServeOptions { - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -// --------------------------------------------------------------------------- -// connect-provider -// --------------------------------------------------------------------------- - -export interface WebConnectProviderParams extends ServeOptions { - projectName: string; - branch: string; - connectors: ConnectorChoice[]; - /** What the caller typed that matched nothing, so the mistake and the menu arrive together. */ - unknownProvider?: string; -} - -/** What the browser picked. `cancelled` means no connector may be run. */ -export interface WebConnectProviderResult { - provider: string; - cancelled: boolean; -} - -export function buildConnectProviderData( - p: WebConnectProviderParams, - nonce: string, -): ConnectProviderData { - return { - nonce, - projectName: stripAnsi(p.projectName), - branch: p.branch, - connectors: p.connectors.map((c) => ({ - ...c, - description: stripAnsi(c.description), - })), - ...(p.unknownProvider ? { unknownProvider: p.unknownProvider } : {}), - nonTty: { - command: 'capy connect ', - why: 'Which integration owns a credential is not guessable from the variable it lands in, so the provider is a positional argument rather than a default.', - }, - }; -} - -/** - * Serve the connector list and wait for a pick. - * - * `capy connect` with no provider prints two columns and stops — a catalogue, - * not a choice. Under `--web` the catalogue is the picker, and the row says - * the three things the terminal only reveals one command later: that the - * connector wants a binary you may not have, that it hands you off to a - * browser pairing, and how many variables on this branch it already owns. - * - * The pick is resolved against the list the CLI offered. A provider the screen - * could not have shown did not come from the screen. - */ -export async function chooseConnectorInBrowser( - p: WebConnectProviderParams, -): Promise { - const out = await runBrowserWizard( - { - title: `Connect a provider — ${p.projectName}`, - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Picked — back to your terminal.', - renderFirst: (nonce) => renderScreen('connect-provider', buildConnectProviderData(p, nonce)), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { provider: '', cancelled: true } }; - } - const provider = typeof payload.provider === 'string' ? payload.provider : ''; - const row = p.connectors.find((c) => c.id === provider); - if (!row) return { error: 'That connector is not in this build.' }; - if (row.toolFound === false) { - // The row draws the same refusal as a preview. Running it anyway would - // walk the user into a `precheck` that exits, having spent a click. - return { error: `${row.requiresTool ?? row.id} is not on your PATH yet.` }; - } - return { done: true, result: { provider: row.id, cancelled: false } }; - }, - ); - return out as WebConnectProviderResult; -} - -// --------------------------------------------------------------------------- -// connect-setup + connect-overwrite -// --------------------------------------------------------------------------- - -/** - * One question this run still has to ask, with everything its screen needs. - * - * A discriminated union rather than a bag of optional fields, because the - * caller assembles the list from five different points in `connect()` and the - * compiler is the only thing that can say a step was handed the wrong data. A - * screen that renders the wrong question is worse than a terminal prompt. - */ -export type ConnectQuestion = - | { kind: 'var'; vars: ConnectVarSlot[]; defaultVarName: string } - | { kind: 'mode'; modes: ConnectModeOption[] } - /** - * The sign-in hand-off. Not a question the user answers so much as one they - * ACT on: the only control is "Open Stripe", and pressing it is consent to be - * sent there, not an answer that comes back with a value. - */ - | { kind: 'auth'; command: string; state: ConnectAuthState; pairingCode?: string } - | { - kind: 'overwrite'; - varName: string; - current: ConnectVarState; - incoming: ConnectIncomingKey; - } - | { kind: 'account'; accounts: StripeAccount[] } - | { kind: 'refresh'; mode: 'test' | 'live'; expiresInDays: number; command: string }; - -/** What the browser answered. Only the keys whose questions were asked. */ -export interface ConnectAnswers { - var?: string; - mode?: 'test' | 'live'; - overwrite?: boolean; - account?: string; - refresh?: boolean; -} - -export interface WebConnectSetupParams extends ServeOptions { - provider: string; - projectName: string; - branch: string; - /** Everything `connectPlan` needs that this session does not answer itself. */ - plan: ConnectPlanInput; - /** The questions this session will ask, in the order the command asks them. */ - questions: ConnectQuestion[]; -} - -export interface WebConnectSetupResult { - answers: ConnectAnswers; - cancelled: boolean; -} - -/** The `ConnectStep` a question stands on. `overwrite` is not a stop; see below. */ -const STEP_FOR: Record = { - var: 'var', - mode: 'mode', - auth: 'auth', - // The overwrite guard is not a station on the route — it is the route being - // interrupted by something already in the way — so the rail keeps standing - // where it was and the guard draws no rail of its own. - overwrite: null, - account: 'account', - refresh: 'refresh', -}; - -/** - * How each question is answered without a browser. - * - * Written from the CLI's argv as it is today, and from what - * `refuseNonInteractive` already tells a headless caller at each of these - * sites — one source for the flag, so the footnote and the refusal cannot - * describe different arguments. - */ -function nonTtyEscape(kind: ConnectQuestion['kind']): ConnectSetupData['nonTty'] { - switch (kind) { - case 'auth': - return { - command: 'stripe login # in a terminal, then re-run capy connect stripe', - why: 'A pairing is approved by a person in a browser. No flag completes one, so the only headless answer is to already be signed in before the run starts.', - }; - case 'var': - return { - command: 'capy connect stripe --var STRIPE_SECRET_KEY', - why: 'Which variable holds the key decides what gets overwritten, so it is never guessed from the ones already on the branch.', - }; - case 'mode': - return { - command: 'capy connect stripe --live', - why: 'Test is the default and live is only ever reached by asking for it: a mode that could be defaulted into is a live key nobody chose.', - }; - case 'account': - return { - command: 'capy connect stripe --account acct_1234', - why: 'With several accounts paired there is no unambiguous one to take a key from, so the run refuses rather than picking.', - }; - case 'refresh': - return { - command: 'capy rotate ', - why: 'A near-expiry key is taken as-is without a browser; re-pairing is a hand-off no flag can complete for you.', - }; - case 'overwrite': - return { - command: 'capy connect stripe --var -f', - why: 'Overwriting a value Capy cannot recover is not a default. -f is the same flag the non-interactive refusal already names.', - }; - } -} - -export function buildConnectSetupData( - p: WebConnectSetupParams, - q: ConnectQuestion, - answers: ConnectAnswers, - nonce: string, -): ConnectSetupData { - const step = STEP_FOR[q.kind] ?? 'var'; - const stops = connectPlan({ - ...p.plan, - standing: step, - // Folded forward so the rail redraws itself from one place: this file - // never decides what a stop's state is. - ...(answers.var !== undefined ? { varName: answers.var } : {}), - ...(answers.mode !== undefined ? { mode: answers.mode } : {}), - ...(answers.account !== undefined ? { account: answers.account } : {}), - ...(answers.refresh !== undefined ? { refreshAccepted: answers.refresh } : {}), - }); - - return { - nonce, - provider: p.provider, - projectName: stripAnsi(p.projectName), - branch: p.branch, - stops, - step, - ...(q.kind === 'var' - ? { vars: q.vars, defaultVarName: q.defaultVarName } - : q.kind === 'mode' - ? { modes: q.modes } - : q.kind === 'account' - ? { accounts: q.accounts } - : q.kind === 'refresh' - ? { refresh: { mode: q.mode, expiresInDays: q.expiresInDays, command: q.command } } - : q.kind === 'auth' - ? { - auth: { - command: q.command, - state: q.state, - // Omitted rather than empty: the screen has two readings of - // its own instruction and picks between them on whether - // there is a code to compare, so an empty string would put - // "compare the code below" above a blank row. - ...(q.pairingCode ? { pairingCode: q.pairingCode } : {}), - }, - } - : {}), - nonTty: nonTtyEscape(q.kind), - }; -} - -export function buildConnectOverwriteData( - p: WebConnectSetupParams, - q: Extract, - nonce: string, -): ConnectOverwriteData { - return { - nonce, - provider: p.provider, - projectName: stripAnsi(p.projectName), - branch: p.branch, - varName: q.varName, - current: q.current, - incoming: q.incoming, - nonTty: nonTtyEscape('overwrite'), - }; -} - -/** The four names a variable step may answer with, beyond the offered list. */ -const VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; - -/** - * Serve the run's outstanding questions, one screen at a time, over one server. - * - * Standalone rather than the wizard shell, so advancing is a page RELOAD: a - * compiled screen is a whole document and cannot be spliced into the open page. - * The browser comes back to the same address and the CLI hands it the next - * question. - * - * Every refusal below is inline (`{ error }`) rather than a guess, because each - * one can only arrive from something that is not the screen — the account list - * offers exactly the accounts the config holds, the mode list holds two ids, - * the overwrite screen has one button and it means yes. - */ -export async function askConnectInBrowser( - p: WebConnectSetupParams, -): Promise { - if (p.questions.length === 0) return { answers: {}, cancelled: false }; - - // The nonce is minted inside `runBrowserWizard` and reaches a caller only - // through `renderFirst`. Later standalone steps have to be rendered with that - // same token, so it is captured on the way past rather than each flow minting - // its own — which would put the security token of every browser path in the - // hands of each path instead of in one place. - let nonce = ''; - let index = 0; - const answers: ConnectAnswers = {}; - - const render = (): string => { - const q = p.questions[index]; - return q.kind === 'overwrite' - ? renderScreen('connect-overwrite', buildConnectOverwriteData(p, q, nonce)) - : renderScreen('connect-setup', buildConnectSetupData(p, q, answers, nonce)); - }; - - const out = await runBrowserWizard( - { - title: `Connect ${p.provider} — ${p.projectName}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Answered — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { answers, cancelled: true } }; - } - - const q = p.questions[index]; - if (!q) return { error: 'There is nothing left to answer on this run.' }; - - if (q.kind === 'var') { - const name = typeof payload.var === 'string' ? payload.var.trim() : ''; - if (!name) return { error: 'Variable name cannot be empty' }; - // A name the list did not offer is a new one, and a new one has to pass - // the same check `validateVarName` applies in the terminal — the screen - // holds its button on a bad name, so this can only be a malformed - // submit. - if (!q.vars.some((v) => v.name === name) && !VAR_NAME_RE.test(name)) { - return { error: 'Must be UPPER_SNAKE_CASE (letters, digits, underscore).' }; - } - answers.var = name; - } else if (q.kind === 'mode') { - const mode = payload.mode; - const offered = q.modes.find((m) => m.id === mode); - if (!offered) return { error: 'That mode is not one this run offers.' }; - if (!offered.available) { - // The screen disables an unavailable mode. Writing a live key this - // machine has no live key for, or that the dev firewall refuses, is - // the failure this refusal exists to prevent. - return { error: 'That mode is not available for this account.' }; - } - answers.mode = offered.id; - } else if (q.kind === 'overwrite') { - // One button, and it means yes. Anything else did not come from it. - if (payload.overwrite !== true) { - return { error: 'That is not an answer the overwrite guard can produce.' }; - } - answers.overwrite = true; - } else if (q.kind === 'account') { - const account = typeof payload.account === 'string' ? payload.account : ''; - if (!q.accounts.some((a) => a.id === account)) { - return { error: 'That account is not in this provider’s config.' }; - } - answers.account = account; - } else { - if (typeof payload.relogin !== 'boolean') { - return { error: 'That is not an answer the refresh step can produce.' }; - } - answers.refresh = payload.relogin; - } - - index += 1; - if (index >= p.questions.length) { - return { done: true, result: { answers, cancelled: false } }; - } - // A whole document cannot be spliced into the open page, so it is handed - // back as `standalone` and the browser reloads to receive it. - return { screen: { html: render(), standalone: true } }; - }, - ); - return out as WebConnectSetupResult; -} - -// --------------------------------------------------------------------------- -// connect-setup, as the sign-in hand-off -// --------------------------------------------------------------------------- - -/** - * The Stripe pairing, in a browser. - * - * THE STOP THIS FLOW USED TO SKIP. Every other question `capy connect stripe` - * asks has had a `--web` answer since the screens landed; sign-in did not, and - * fell through to `stripe login` with an inherited stdout. On the MCP transport - * that stdout is the tool-result channel, so the pairing URL and the - * verification code were handed to the AI agent while the user — the only party - * who can approve a pairing — was never asked anything (CAP-365). - * - * WHY THE HAND-OFF RUNS INSIDE THE REDUCER. `approve` blocks: it opens Stripe - * and then polls until the pairing lands or five minutes pass. A reducer is the - * one place in this transport where that is allowed — the wizard's clock is - * disarmed for exactly as long as one is working, because during that time - * nothing is outstanding that a person could supply. The page holds its POST - * open and shows its own "Working…" until this returns. - * - * THREE ENDINGS, NOT TWO, and the third is why this does not return a boolean. - * A pairing nobody approved is a wall — the run could not proceed — and someone - * pressing Cancel is a refusal, which changed nothing and was the point. The - * exit code is the only thing an agent reads, so collapsing them would report - * "capy failed" for a user who decided not to. Closing the window is a refusal - * too: it has agreed to nothing. - * - * The caller draws whatever each ending says, so one place decides it rather - * than one per surface. - */ -export type WebSignInResult = 'paired' | 'declined' | 'not-approved'; - -export async function signInInBrowser( - p: WebConnectSetupParams, - approve: () => Promise, -): Promise { - const q = p.questions[0]; - if (p.questions.length !== 1 || !q || q.kind !== 'auth') { - throw new Error('signInInBrowser serves exactly one auth step.'); - } - - const out = await runBrowserWizard( - { - title: `Sign in to ${p.provider} — ${p.projectName}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Paired — back to your terminal.', - renderFirst: (nonce) => - renderScreen('connect-setup', buildConnectSetupData(p, q, {}, nonce)), - // A window closed on the pairing screen has approved nothing, and the - // command must not sit on the socket for five minutes finding that out. - closeIsRefusal: { result: 'declined' satisfies WebSignInResult }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') return { done: true, result: 'declined' }; - // One control, and it means "send me to Stripe". Anything else did not - // come from this screen — there is no field on it to send. - if (payload.step !== 'auth') { - return { error: 'That is not an answer the sign-in step can produce.' }; - } - return { done: true, result: (await approve()) ? 'paired' : 'not-approved' }; - }, - ); - // Anything the wizard can resolve with that is not one of the three is a - // transport that went somewhere this flow did not write, and the safe reading - // of "I do not know what happened" is that nobody got signed in. - return out === 'paired' || out === 'declined' ? out : 'not-approved'; -} - -// --------------------------------------------------------------------------- -// connect-live-gate -// --------------------------------------------------------------------------- - -export interface WebLiveGateParams extends ServeOptions { - action: ConnectLiveAction; - provider: string; - projectName: string; - branch: string; - varName: string; - /** - * The string the user must type back. Null when the provider's config - * section carried no account id — the terminal's fallback there is to ask - * for the literal `(unknown)`, which satisfies the prompt and proves - * nothing, so the screen treats null as a condition to surface instead. - */ - accountId: string | null; - accountName?: string; - /** `value.slice(0, 8)` — `rk_live_`. Exactly what the terminal prints. */ - keyPrefix?: string; - push: boolean; - pushFromFlag?: boolean; - accountFromFlag?: boolean; - varFromFlag?: boolean; - /** The route this run declared, so the gate draws the same rail as the rest. */ - stops: ConnectLiveGateStop[]; -} - -export function buildLiveGateData(p: WebLiveGateParams, nonce: string): ConnectLiveGateData { - return { - nonce, - action: p.action, - provider: p.provider, - projectName: stripAnsi(p.projectName), - branch: p.branch, - varName: p.varName, - accountId: p.accountId, - ...(p.accountName ? { accountName: stripAnsi(p.accountName) } : {}), - ...(p.keyPrefix ? { keyPrefix: p.keyPrefix } : {}), - ...(p.accountFromFlag ? { accountFromFlag: true } : {}), - ...(p.varFromFlag ? { varFromFlag: true } : {}), - push: p.push, - ...(p.pushFromFlag ? { pushFromFlag: true } : {}), - stops: p.stops, - // Always empty from the CLI. A page that arrived with the answer already in - // the box would be a gate that confirms itself. - typed: '', - nonTty: { - command: 'capy connect stripe --var # then confirm in a terminal', - why: 'Typing the account ID is the confirmation. No flag can stand in for it, because a flag is exactly the thing a script repeats without reading.', - }, - }; -} - -/** - * The live-mode gate, in a browser. - * - * `confirmLiveAction` returns false on any string that is not equal, and the - * command prints `Cancelled.` and exits — so a typo and a change of heart are - * indistinguishable and the typo costs the whole run. Here the submit is - * refused inline and the page stays where it is, which is the same outcome the - * terminal would have had if it had asked twice. - * - * The comparison is made HERE, against the account id the CLI resolved, and - * never in the page: a gate whose verdict is computed by the thing being gated - * is not a gate. - */ -export async function confirmLiveActionInBrowser(p: WebLiveGateParams): Promise { - const out = await runBrowserWizard( - { - title: `Confirm live mode — ${p.varName}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Confirmed — back to your terminal.', - renderFirst: (nonce) => renderScreen('connect-live-gate', buildLiveGateData(p, nonce)), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') return { done: true, result: false }; - if (p.accountId === null) { - // There is nothing to confirm against, and the screen draws no field. - return { error: 'Capy cannot name this account, so there is nothing to confirm.' }; - } - const typed = typeof payload.confirmed === 'string' ? payload.confirmed.trim() : ''; - if (typed !== p.accountId) { - return { error: 'That is not the account ID above. Compare it character by character.' }; - } - return { done: true, result: true }; - }, - ); - return out === true; -} - -// --------------------------------------------------------------------------- -// connect-result -// --------------------------------------------------------------------------- - -export interface WebConnectResultParams extends ServeOptions { - outcome: ConnectOutcome; - provider: string; - projectName: string; - branch: string; - varName: string; - mode?: 'test' | 'live'; - accountId?: string; - keyPrefix?: string; - fingerprint?: string; - expiresInDays?: number; - detail?: string; - /** The route the run travelled, as the CLI declared it. */ - stops: ConnectResultData['stops']; -} - -/** - * The command that finishes the job, keyed off the outcome. - * - * Two endings leave the key on this machine and nowhere else — `--no-push`, - * and a push that did not land — and both are finished by the same command. - * Derived here rather than at each call site so a new ending cannot quietly - * arrive with no next move. - */ -function followUpFor(outcome: ConnectOutcome): string | undefined { - return outcome === 'local-only' || outcome === 'push-failed' ? 'capy push' : undefined; -} - -export function buildConnectResultData(p: WebConnectResultParams): ConnectResultData { - return { - outcome: p.outcome, - provider: p.provider, - projectName: stripAnsi(p.projectName), - branch: p.branch, - varName: p.varName, - ...(p.mode ? { mode: p.mode } : {}), - ...(p.accountId ? { accountId: p.accountId } : {}), - ...(p.keyPrefix ? { keyPrefix: p.keyPrefix } : {}), - ...(p.fingerprint ? { fingerprint: p.fingerprint } : {}), - ...(typeof p.expiresInDays === 'number' ? { expiresInDays: p.expiresInDays } : {}), - ...(followUpFor(p.outcome) ? { followUp: followUpFor(p.outcome) as string } : {}), - ...(p.detail ? { detail: stripAnsi(p.detail) } : {}), - stops: p.stops, - nonTty: { - command: `capy connect ${p.provider} --var ${p.varName}`, - why: 'The same run without a browser. Nothing on this page is a decision, so there is nothing here a flag has to answer.', - }, - }; -} - -/** - * Show what the run did, and do not ask for anything. - * - * A display-only ending: `serveEndingPage` serves it under the strict - * `connect-src 'none'` policy `screenHeaders()` applies by default, so a page - * that names which key landed where cannot open a socket at all. - * - * IT DOES NOT RETURN UNTIL THE BROWSER HAS THE PAGE. Both callers are at the - * end of a run — one of them is a push that failed, which is the ending whose - * whole reason for existing is that the user cannot otherwise tell whether - * .env holds a key nobody else has — and a command that exits on the next line - * closes the loopback server that is serving this. The wait is bounded by - * `timeoutMs`. - */ -export async function showConnectResultInBrowser(p: WebConnectResultParams): Promise { - const { url } = await serveEndingPage('connect-result', buildConnectResultData(p), { - ...(p.open === undefined ? {} : { open: p.open }), - ...(p.onListen ? { onListen: p.onListen } : {}), - ...(p.timeoutMs === undefined ? {} : { timeoutMs: p.timeoutMs }), - lead: 'What this run did, in your browser:', - }); - return url; -} - -// --------------------------------------------------------------------------- -// connect-setup, as a wall -// --------------------------------------------------------------------------- - -export interface WebConnectBlockedParams extends ServeOptions { - provider: string; - projectName: string; - branch: string; - /** - * The step the run died on, so the rail keeps standing where it stopped. - * - * Every step that can be STOOD ON, which is each one with a `nonTtyEscape`. - * `push` is the exception and stays out: it is not a question, so a wall - * there would have no question to replace and nothing to tell a headless - * caller to run instead. - * - * `auth` earns its place the hard way. A pairing that is never approved is a - * dead end like any other, and before CAP-365 it was the one dead end that - * exited the process instead of drawing itself — which under `--web` killed - * the loopback server mid-run and left the browser on a page that just - * stopped. - */ - step: Extract; - stops: ConnectStop[]; - blocked: Blocked; -} - -/** - * A question this run cannot ask, served as the reason instead. - * - * `connect-setup` renders `blocked` in place of its controls — no submit, no - * cancel, nothing to answer — so it must NOT be served through the wizard: a - * wizard waits for a submit, and a page with no control to produce one leaves - * the run sitting there until the five-minute timeout. That is the failure - * this exists to prevent, so it goes out as an ending: served, read, over. - * - * The nonce is empty and that is deliberate. A page with nothing to submit - * needs no token to submit it with, and minting one would be handing out a - * credential for a channel that does not exist. - */ -export async function showConnectBlockedInBrowser( - p: WebConnectBlockedParams, -): Promise { - const data: ConnectSetupData = { - nonce: '', - provider: p.provider, - projectName: stripAnsi(p.projectName), - branch: p.branch, - stops: p.stops, - step: p.step, - blocked: p.blocked, - nonTty: nonTtyEscape(p.step), - }; - const { url } = await serveEndingPage('connect-setup', data, { - ...(p.open === undefined ? {} : { open: p.open }), - ...(p.onListen ? { onListen: p.onListen } : {}), - ...(p.timeoutMs === undefined ? {} : { timeoutMs: p.timeoutMs }), - lead: 'Why this run cannot continue, in your browser:', - }); - return url; -} diff --git a/src/ui/declineBridge.ts b/src/ui/declineBridge.ts deleted file mode 100644 index 4d08e48d..00000000 --- a/src/ui/declineBridge.ts +++ /dev/null @@ -1,151 +0,0 @@ -// The "no" a screen has no way to send. -// -// Several compiled screens confirm a destructive act with two controls: the -// destructive one POSTs, and the decline — which is the answer the terminal -// prompt DEFAULTS to — is CLIENT-SIDE ONLY. It flips the view back to the -// listing it came from and the CLI is never told. Driven in a real browser the -// run is still pending after that click and ends minutes later on the wizard's -// timeout, on a page whose remaining controls cannot answer the question that -// was asked. Nobody is ever removed, revoked or deleted by it — the outcome -// fails safe — but a flow whose most common ending is unreachable is not a -// flow, and "the user waited five minutes" is not a refusal anyone should have -// to perform. -// -// The real fix is the screen's, and every call site writes down which screen -// and which control: the decline should POST `{__action:'cancel'}` exactly the -// way `packages/ui`'s own `Wizard` cancel already does. Until it does, this is -// the CLI holding up its end of the contract — a browser flow has exactly two -// endings, and both must be reachable from the page. -// -// It lives here rather than in one command's screen module because three -// screens now need it, and a second copy of this script would be a second way -// for a page to say no. -import { CapyError } from '../types'; - -export interface DeclineBridge { - /** The wizard nonce the served document was rendered with. */ - nonce: string; - /** - * A CSS selector for THE QUESTION — not for the decline button. - * - * The confirmation is the only thing on the page this run can be answered - * with, so a document with no such element left in it is a document where - * the question can no longer be answered, which is a refusal however the - * page got there. Bound to a design-system variant (`button.danger`, - * `.callout.danger`) and never to a label: copy is written for humans and is - * never what code keys off. - */ - question: string; - /** The ending's heading. States that nothing happened. */ - headline: string; - /** One sentence naming what is still true. `You can close this tab.` follows. */ - detail: string; -} - -/** - * The script that watches the question and posts the refusal. - * - * WHAT IT WATCHES is the question, not a button — see `question` above. - * - * IT CANNOT TURN A CONFIRMATION INTO A CANCEL. The wizard marks itself done - * inside the handler that resolved it, so a cancel that arrives after a - * confirmed removal is answered 409 and this script leaves the page exactly as - * the screen drew it. - */ -function bridgeScript(p: DeclineBridge): string { - const js = (s: string): string => JSON.stringify(s).replace(/ -(function () { - // Out of the document before anything else. A script element's source counts - // as page text — document.body.textContent returns it — and this page's text - // is the screen's copy, not the CLI's plumbing. Removing the node does not - // stop the code already running from it. - var self = document.currentScript; - if (self && self.parentNode) self.parentNode.removeChild(self); - - var NONCE = ${js(p.nonce)}; - var STILL = ${js(p.detail)}; - var HEAD = ${js(p.headline)}; - // The thing this view answers with, by its design-system variant — the - // structural attribute, never the label rendered inside it. - var ANSWERS_WITH = ${js(p.question)}; - var sent = false; - - function ending() { - document.title = 'Cancelled'; - document.body.textContent = ''; - var wrap = document.createElement('div'); - wrap.setAttribute('style', 'max-width:34rem;margin:4rem auto;padding:0 1.5rem;font:16px/1.6 ui-sans-serif,system-ui,sans-serif'); - var h = document.createElement('h1'); - h.setAttribute('style', 'font-size:1.25rem;margin:0 0 .5rem;font-weight:600'); - h.textContent = HEAD; - var pEl = document.createElement('p'); - pEl.setAttribute('style', 'margin:0;opacity:.7'); - pEl.textContent = STILL + ' You can close this tab.'; - wrap.appendChild(h); - wrap.appendChild(pEl); - document.body.appendChild(wrap); - } - - function decline() { - fetch('/submit', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ nonce: NONCE, payload: { __action: 'cancel' } }) - }).then(function (r) { - // Not ok is the destructive act that already went through (409 already - // finished): the CLI has its answer and this page is not it. - if (!r.ok) return null; - return r.json().catch(function () { return null; }); - }).then(function (b) { - if (b && b.done) ending(); - }).catch(function () { - /* the CLI is gone: it already has its answer, or its timeout. */ - }); - } - - // Watching from the first mutation, never from a poll that could start after - // the click it exists to catch. The screen mounts itself from its own inline - // script, so the question may arrive before or after this runs — either way - // the mount is a mutation, and it is the mutation that records having seen - // the question. A question that never rendered at all leaves this inert: a - // page that failed to draw is a page problem, and answering it with a cancel - // would hide that behind a tidy ending. - var seen = !!document.querySelector(ANSWERS_WITH); - var obs = new MutationObserver(function () { - if (sent) return; - if (document.querySelector(ANSWERS_WITH)) { - seen = true; - return; - } - if (!seen) return; - sent = true; - obs.disconnect(); - decline(); - }); - obs.observe(document.body, { childList: true, subtree: true }); -})(); -`; -} - -/** Put the bridge inside the document the screen build produced. */ -export function withDeclineBridge(html: string, p: DeclineBridge): string { - const script = bridgeScript(p); - const close = html.lastIndexOf(''); - return close === -1 ? html + script : html.slice(0, close) + script + html.slice(close); -} - -/** - * Turn "nobody answered" into a refusal, and leave a broken server a failure. - * - * Structural, never a message match: `runBrowserWizard` mints a `CapyError` for - * its deadline and for Ctrl-C, and both mean the same thing — the question was - * never answered, so the destructive act was not approved. A server that could - * not listen is a different fact and still throws. - */ -export function refusalOn(refused: T): (err: unknown) => T { - return (err: unknown): T => { - if (err instanceof CapyError) return refused; - throw err; - }; -} diff --git a/src/ui/decryptScreen.ts b/src/ui/decryptScreen.ts deleted file mode 100644 index fe1f9fbf..00000000 --- a/src/ui/decryptScreen.ts +++ /dev/null @@ -1,286 +0,0 @@ -// `capy decrypt --web`, served as the compiled `seed-phrase-decrypt` screen. -// -// The one prompt this command has is `Enter your 24-word seed phrase:` — an -// inquirer password field. It is the offline escape hatch: hold the phrase and -// this project's secrets open with no server, no session and nobody's -// permission. -// -// SECRET-BEARING ON BOTH ENDS, and they are different kinds of secret: -// -// in the phrase, typed into the page and posted once to the loopback -// server. It is never in a payload, never in a URL, never echoed back, -// and this module holds it for exactly as long as the attempt takes. -// out a real plaintext file on disk. The result payload carries a COUNT and -// a FILENAME and nothing else — no variable names, no values. The CLI -// is careful about this too, and the browser, which is screen-shared -// far more often than a terminal is, has to be more so. -// -// Two things the terminal does silently and the browser says out loud. An -// unexpired `~/.capy/recover/session.json` makes this command prompt for -// nothing at all and reuse a cached master key; that is a station on the rail -// now, with a sentence before it happens. And `capy decrypt` has no -// `assertNotLocalOnly` gate, so in local mode the recovery phrase decrypts -// around the passphrase lock — the screen says so rather than repeating a -// promise the code does not keep. -import { runBrowserWizard, BrowserRefusal, type WebRefusal } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { decryptPlan } from '../core/decryptPlan'; -import type { DecryptPhraseError, SeedPhraseDecryptData } from './screens/contract'; - -/** The phrase length the CLI expects. Carried in the payload; never assumed. */ -export const SEED_PHRASE_WORDS = 24; - -/** - * Strip terminal colour codes on the way into a payload. - * - * The CLI bolds the project name and the output path when it PRINTS them, and - * a payload is not a terminal: an escape that renders as dim grey in a shell - * renders as a literal `[90m` in a browser. Applied to every display string - * this screen carries, at the one place they are assembled. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -/** How this run ended when it did not decrypt anything. */ -export type DecryptRefusal = WebRefusal; - -/** - * How long the result page stays reachable when nobody comes for it. - * - * The page is served by a socket in THIS process, so the process has to stay - * up for the browser to be able to load it — but only until it does. A run - * where nothing opens it (a headless agent, `--no-open`, an `open()` that - * silently failed) used to hold the terminal for the ScreenServer's full two - * minutes AFTER the plaintext was written and the result already printed. - */ -export const RESULT_PAGE_GRACE_MS = 20_000; - -export interface WebDecryptParams { - projectName: string; - branch: string; - /** `.env.{branch}.decrypted` — what the plaintext lands in. */ - outputFile: string; - /** An unexpired recovery session already holds this org's master key. */ - session?: { orgName: string; startedAt: string }; - /** This profile is local-only, so the passphrase lock is not consulted. */ - localOnly?: boolean; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** - * What the browser answered. `cancelled` means nothing was decrypted. - * - * `reason` says which refusal it was — the page's own Cancel, a closed window, - * or a step nobody answered. All three write nothing; they read differently in - * the terminal, and a run that ends by throwing "Timed out waiting for the - * browser" reads as a decrypt that broke rather than one that never happened. - */ -export type WebDecryptResult = - | { action: 'decrypted'; count: number; wrote: boolean } - | { action: 'cancelled'; reason: DecryptRefusal }; - -/** - * One attempt at opening this `.env`, run by the caller because the key - * material is the command's business and not this transport's. - * - * The two failures are the CLI's own two: `INVALID` is the client-side test - * (the word count, the wordlist, the checksum) and `KEY_MISMATCH` is the - * answer after the trial — no KDF version decrypted anything. The terminal - * distinguishes them and so does the screen, which is why the reason is an - * enum rather than a sentence to match on. - */ -export type DecryptAttempt = - | { ok: true; count: number; wrote: boolean } - | { ok: false; reason: DecryptPhraseError }; - -export function buildDecryptData( - p: WebDecryptParams, - nonce: string, - state: { view: SeedPhraseDecryptData['view']; phraseError?: DecryptPhraseError; result?: { count: number; wrote: boolean } } = { - view: 'phrase', - }, -): SeedPhraseDecryptData { - const usingSession = p.session !== undefined; - const outputFile = stripAnsi(p.outputFile); - const data: SeedPhraseDecryptData = { - nonce, - // A run with a live session is not asked for a phrase at all, so the view - // it opens on is the CLI's fact rather than the caller's choice. - view: state.view === 'phrase' && usingSession ? 'session' : state.view, - stops: decryptPlan({ - wordCount: SEED_PHRASE_WORDS, - outputFile, - usingSession, - finished: state.view === 'result', - }), - projectName: stripAnsi(p.projectName), - branch: stripAnsi(p.branch), - outputFile, - wordCount: SEED_PHRASE_WORDS, - nonTty: usingSession - ? { - command: 'capy decrypt', - why: 'A recovery session on this machine already holds the key, so this run needs no phrase. Headlessly it writes the plaintext file straight away — the question above is what a browser adds.', - } - : { - command: 'capy decrypt', - why: 'A seed phrase can never come from a flag or an environment variable — argv lands in your shell history and in every process listing. This step refuses with exit 3 and asks for a terminal; today it blocks on a piped stdin and then prints "Cancelled." and exits 0, which reads as a decrypt that succeeded and wrote nothing.', - }, - }; - - if (p.session) { - data.session = { orgName: stripAnsi(p.session.orgName), startedAt: stripAnsi(p.session.startedAt) }; - } - if (p.localOnly) data.localOnly = true; - if (state.phraseError) data.phraseError = state.phraseError; - if (state.result) data.result = state.result; - // The result view is COUNTS ONLY. Nothing else about the file crosses: the - // whole point of the command is that the plaintext goes to a file, not to a - // screen someone might be sharing. - return data; -} - -/** - * Serve the phrase step and hand each answer to `attempt`. - * - * The phrase never reaches this module's return value, is never printed, and - * is not held past the call that uses it. A wrong phrase is answered by - * re-serving the step with `phraseError` — the payload has a field for exactly - * that, and the screen renders the CLI's own sentence for it — rather than by - * an inline message the user has to interpret while 24 boxes still hold the - * words that did not work. - */ -export async function decryptInBrowser( - p: WebDecryptParams, - attempt: (input: { phrase: string } | { useSession: true }) => Promise, -): Promise { - let nonce = ''; - const usingSession = p.session !== undefined; - const render = (state: Parameters[2]): string => - renderScreen('seed-phrase-decrypt', buildDecryptData(p, nonce, state)); - - let out: unknown; - try { - out = await runBrowserWizard( - { - title: `Decrypt secrets — ${p.projectName}/${p.branch}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Decrypted — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render({ view: 'phrase' }); - }, - // Closing the tab decrypts nothing, and now says so instead of holding - // the terminal for five minutes and throwing. A wrong phrase re-serves - // this same step, which is a reload — the wizard waits out its grace - // before believing a page that left, so retyping is not a refusal. - closeIsRefusal: { - result: { action: 'cancelled', reason: 'closed' } satisfies WebDecryptResult, - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { action: 'cancelled', reason: 'declined' } }; - } - if (payload.__action !== 'submit') { - return { error: 'That is not an action this screen offers.' }; - } - - let input: { phrase: string } | { useSession: true }; - if (usingSession) { - // The session view sends one flag and nothing else. Anything else did - // not come from it, and reusing a cached master key off the back of a - // submit the screen could not have produced is not a thing to guess at. - if (payload.useSession !== true) { - return { error: 'That is not an answer the session step can produce.' }; - } - input = { useSession: true }; - } else { - const phrase = typeof payload.phrase === 'string' ? payload.phrase : null; - // The screen checks all three conditions — the word count, the - // wordlist and the checksum — before anything leaves the tab, and - // holds its button on every one. A phrase that is not even a string - // did not come from it. - if (phrase === null) { - return { error: 'That is not an answer the phrase step can produce.' }; - } - input = { phrase }; - } - - const verdict = await attempt(input); - if (!verdict.ok) { - if (usingSession) { - // Nothing to retype: the key came from a cached session, so asking - // the step again would ask the same question and get the same - // answer. The CLI's own sentence, refused inline. - return { - error: - 'Decryption failed. The recovery session on this machine does not open this project. Run capy end-recover and try again with the seed phrase for this org.', - }; - } - // Re-served rather than refused inline: the words are wrong, so - // keeping them in the boxes would only invite the same submit again — - // and `phraseError` exists so the screen can say WHICH of the CLI's - // two refusals happened, in the CLI's own words. - return { screen: { html: render({ view: 'phrase', phraseError: verdict.reason }), standalone: true } }; - } - return { - done: true, - result: { action: 'decrypted', count: verdict.count, wrote: verdict.wrote }, - }; - }, - ); - } catch (err) { - // A step nobody answered wrote nothing, which is what `cancelled` means. - // A socket that would not bind is a fault and still throws. - if (err instanceof BrowserRefusal && err.reason === 'timeout') { - return { action: 'cancelled', reason: 'timeout' }; - } - throw err; - } - return out as WebDecryptResult; -} - -/** - * Put the outcome in front of whoever asked for it. - * - * Its own page rather than the wizard's generic ending, because the one thing - * on it that changes what the reader does next is "That file is plaintext" — - * the CLI's own line is `Run capy end-recover when done to clean up`, which - * reads as tidiness rather than as a live secret sitting in the directory. - * - * Served by `ScreenServer`, which is the display-only server: its policy gives - * the page no `connect-src` at all, so a page rendering the aftermath of a - * decrypt has no way to speak to anything, including us. - */ -export async function showDecryptResult( - p: WebDecryptParams, - result: { count: number; wrote: boolean }, - opts: { timeoutMs?: number } = {}, -): Promise { - // `serveEndingPage` is the one implementation of "serve the last page, print - // where it is, and hold until the browser has it". This used to be a second - // one — a hand-rolled `onServed` hook on `ScreenServer` plus a racing timer — - // which is exactly the kind of parallel mechanism that drifts: the shared one - // already waits on `delivered`, already prints before it opens, and already - // pays the flush margin. The only thing decrypt needs of its own is the lead - // line and a shorter ceiling than a general ending gets. - const { serveEndingPage } = await import('./endingPage'); - const { url } = await serveEndingPage( - 'seed-phrase-decrypt', - buildDecryptData(p, '', { view: 'result', result }), - { - open: p.open, - onListen: p.onListen, - timeoutMs: opts.timeoutMs ?? RESULT_PAGE_GRACE_MS, - lead: 'The result, in your browser:', - }, - ); - return url; -} diff --git a/src/ui/deployScreens.ts b/src/ui/deployScreens.ts deleted file mode 100644 index 0fddb439..00000000 --- a/src/ui/deployScreens.ts +++ /dev/null @@ -1,1258 +0,0 @@ -// `capy deploy`, served as compiled screens. -// -// Twenty questions live in this command and none of them had a browser answer. -// They are asked as a run of `inquirer` prompts with nothing between them, and -// how many there are depends on the platform: pick Vercel and a mode question -// appears, pick Heroku and it silently does not. Three of them are quietly -// load-bearing — -// -// 1. the variable checkbox is the only boundary between a runtime secret and -// a public browser bundle: a Pages build inlines whatever is ticked into -// JavaScript the browser downloads; -// 2. the Vercel git-branch question is asked one prompt after the *capy* -// branch question, in almost the same words, and answering the second -// with the first pushes every variable to a Preview environment nothing -// is wired to; -// 3. the confirm gate reads one raw keypress, and `d` deletes the saved -// target immediately — no second question, no summary of what is going. -// -// Under `--web` each of them becomes a stop on a route that is declared BEFORE -// anything opens. The route is `deployPlan`'s, not this file's and not the -// screen's: one builder feeds the payload and `--json`, which is the only way -// the rail a person reads and the array an agent parses can be claimed to be -// the same array. -// -// Renders variable NAMES only, everywhere. Preflight deliberately runs before -// a single value is decrypted and these screens sit in the same window, so no -// plaintext exists yet when they are drawn — and the deploy PR body already -// holds itself to the same rule. -import { runBrowserWizard } from './browserWizard'; -import { refusalOn, withDeclineBridge } from './declineBridge'; -import { renderScreen } from './screens/serve'; -import { deployPlan, SIGNIN_COMMAND, type DeployStopId } from '../core/deployPlan'; -import type { - DeployAdapterChoice, - DeployChangeGate, - DeployDelivery, - DeployDestinationData, - DeployDestinationStep, - DeployDrift, - DeployPlanConfirmData, - DeployPlanTarget, - DeployPlatform, - DeployPreflightCheck, - DeployRunResultData, - DeploySettings, - DeploySetupIntent, - DeploySetupStep, - DeployTargetRow, - DeployTargetSetupData, - DeployTargetsData, - DeployTargetsPurpose, - DeployTokenRow, - DeployTokensData, - DeployVar, - Gloss, - NonTtyEscape, - ScreenDataMap, - ScreenName, -} from './screens/contract'; -import type { DeployMode } from '../deploy/adapter'; - -/** - * Strip terminal colour codes on the way into a payload. - * - * Applied to strings the CLI also PRINTS — the detected-defaults line, an - * adapter's dim description, a step's detail — because a payload is not a - * terminal and an escape renders as a literal `[90m` in the browser. - * - * Deliberately NOT applied to an identifier that has to round-trip: a target - * name, a variable name and a branch name come back as the answer this command - * then acts on, so rewriting them here would mean the browser could only ever - * ask for a target that does not exist. Those are checked against the list the - * server sent instead, which no rewriting can defeat. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -/** `stripAnsi` for an optional field, keeping `undefined` as `undefined`. */ -const stripOpt = (s: string | undefined): string | undefined => - s === undefined ? undefined : stripAnsi(s); - -/** - * `stripAnsi` across a `Gloss` — the segmented one-liners a screen marks up. - * - * A gloss is built from an adapter's own description, which is written for a - * terminal. Only the prose segments are rewritten; a `{ code }` segment is a - * command the user retypes and is left exactly as it is. - */ -const stripGloss = (g: Gloss): Gloss => - g.map((seg) => (typeof seg === 'string' ? stripAnsi(seg) : seg)); - -const plural = (n: number, one: string, many: string): string => - `${n} ${n === 1 ? one : many}`; - -/** Common test/production knobs every serve function takes. */ -interface WebServeOptions { - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** - * How long a screen with no way to refuse stays open. - * - * `deploy-targets` and `deploy-tokens` are the two screens here that are not - * wizards: neither has a Cancel, and their `list` views offer nothing but the - * action itself — `Edit target` / `Remove target`, `Revoke token`. So a run - * that opens one and is never answered has only ONE signal available to it, - * silence, and this deadline is what turns that silence into an ending. - * - * Deliberately not the wizard's five minutes. Five minutes of a terminal that - * has printed nothing, after a user has already decided not to do the thing, - * is not an ending anybody experiences as one — and `capy deploy targets --web` - * is a LISTING, which should cost about as much as `ls`. Two minutes is the - * CLI's own display-screen default: long enough to read a listing and type a - * name back, short enough that a command that is over feels over. - * - * It is the backstop, not the mechanism. A CONFIRM view — one question, one - * destructive answer — ends the moment the user declines, through - * `withDeclineBridge`; this deadline is what is left for the listings, where - * there is no decline to detect because there is no question outstanding. - */ -export const NO_REFUSAL_TIMEOUT_MS = 120_000; - -// --------------------------------------------------------------------------- -// deploy-destination — where this project deploys, and how -// --------------------------------------------------------------------------- - -export interface WebDeployDestinationParams extends WebServeOptions { - /** Every platform the CLI knows, in the order it offers them. */ - platforms: DeployPlatform[]; - /** The platform recorded in `.capy/config`, pre-selected. */ - lastPlatform?: string; - /** Settled by `--platform`; that step is not asked. */ - platform?: string; - /** Settled by `--mode`; that step is not asked. */ - mode?: 'connector' | 'token'; - /** Why the previous answer was refused — an invalid `--platform`, usually. */ - rejected?: { argv?: string; message: string }; -} - -export interface WebDeployDestinationResult { - platform: string; - /** - * Null when the chosen platform has no connector: that question does not - * exist for it, which is a different fact from "they chose the token route". - */ - mode: 'connector' | 'token' | null; - cancelled: boolean; -} - -/** Which question this run is standing on, or null when nothing is left. */ -function destinationStep(p: WebDeployDestinationParams): DeployDestinationStep | null { - if (!p.platform) return 'platform'; - const row = p.platforms.find((x) => x.id === p.platform); - if (row?.hasConnector && p.mode === undefined) return 'mode'; - return null; -} - -export function buildDeployDestinationData( - p: WebDeployDestinationParams, - nonce: string, -): DeployDestinationData { - const step = destinationStep(p); - const row = p.platform ? p.platforms.find((x) => x.id === p.platform) : undefined; - - const answers: Partial> = {}; - if (row) answers.platform = row.name; - // CAP-679 follow-up: display text says "Target" now — 'connector' stays - // the internal value (untouched: `WebDeployDestinationParams.mode` etc.). - if (p.mode) answers.mode = p.mode === 'connector' ? 'Target' : 'Deploy token'; - - // A platform with no target adapter never reaches the mode question — the - // terminal skips it in silence, and this is the line that says so. - const skipped: DeployStopId[] = row && !row.hasConnector ? ['mode'] : []; - - return { - nonce, - // `currentStep` returning null means there is nothing to serve and the - // caller never opens a browser. Falling back to the platform step keeps - // the type total without inventing a third view. - step: step ?? 'platform', - stops: deployPlan({ at: step, answers, skipped }), - platforms: p.platforms, - lastPlatform: p.lastPlatform, - platform: row, - rejected: p.rejected, - nonTty: { - // `--mode target` is the new spelling; `--mode connector` still works - // as a hidden alias (see index.ts's normalization) — the example shown - // to a non-TTY caller uses the new one. - command: 'capy deploy --platform --mode target', - why: 'The platform decides which target adapter runs and the mode decides whether Capy deploys at all, so neither is guessed: an unanswered run refuses rather than picking one.', - }, - }; -} - -/** - * Serve the two questions at the front of `capy deploy`. - * - * Two stops over one server, so answering the first is a page RELOAD and the - * same address then serves the second — a compiled screen is a whole document - * and cannot be spliced into the open page. - */ -export async function chooseDeployDestinationInBrowser( - p: WebDeployDestinationParams, -): Promise { - // The nonce is minted inside `runBrowserWizard` and reaches a caller only - // through `renderFirst`. A second standalone step has to be rendered with - // that same token, so it is captured on the way past rather than this flow - // minting one of its own. - let nonce = ''; - let answered: WebDeployDestinationParams = { ...p }; - const render = (): string => - renderScreen('deploy-destination', buildDeployDestinationData(answered, nonce)); - - const out = await runBrowserWizard( - { - title: 'Deploy', - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Answered — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { platform: '', mode: null, cancelled: true } }; - } - - const step = destinationStep(answered); - if (step === 'platform') { - const id = typeof payload.platform === 'string' ? payload.platform : ''; - // The page offers exactly the list this run sent, so anything else is - // a malformed submit rather than a user mistake — and the answer is - // written to `.capy/config` and decides which connector runs. - if (!p.platforms.some((x) => x.id === id)) { - return { error: 'That platform is not one this run offers.' }; - } - answered = { ...answered, platform: id }; - } else if (step === 'mode') { - const mode = payload.mode; - if (mode !== 'connector' && mode !== 'token') { - return { error: 'That is not an answer the mode step can produce.' }; - } - answered = { ...answered, mode }; - } else { - return { error: 'There is nothing left to answer on this run.' }; - } - - if (destinationStep(answered) === null) { - const row = p.platforms.find((x) => x.id === answered.platform); - return { - done: true, - result: { - platform: answered.platform!, - // A platform with no connector was never asked, so it answers - // null rather than a mode nobody chose. - mode: row?.hasConnector ? (answered.mode ?? 'connector') : null, - cancelled: false, - }, - }; - } - return { screen: { html: render(), standalone: true } }; - }, - ); - return out as WebDeployDestinationResult; -} - -// --------------------------------------------------------------------------- -// deploy-target-setup — the target being described -// --------------------------------------------------------------------------- - -/** - * Everything the settings, variables, delivery and name steps need, resolved - * once the adapter is known. - * - * A callback rather than a field because the adapter can be chosen IN the - * browser: `adapter.detect(cwd)` is async and only meaningful afterwards, so - * the CLI runs it in the reducer — which is async — exactly where the terminal - * runs it, one line after the picker. - */ -export interface WebDeployAdapterContext { - id: string; - label: string; - /** - * One line on what this adapter does with your secrets, as segments — the - * terminal's dim description. Segmented because every one of these names - * something the user could retype (`wrangler secret bulk`, `VITE_*`), and a - * plain string cannot mark up its own middle. - */ - detail: Gloss; - /** The terminal's `Detected: …` line. Absent when detection found nothing. */ - detected?: string; - /** Settings defaults, already merged saved-over-detected as the CLI merges them. */ - defaults: Record; - /** Variables on the active branch, by NAME. Never a value. */ - vars: DeployVar[]; - /** What the pre-ticked set was chosen by: `all vars`, a prefix list. */ - presetLabel: string; - delivery: DeployDelivery; - /** Local + origin git refs, most-recent first. For a Vercel Preview target. */ - gitBranches: string[]; - /** False when `aws configure get region` had no answer and us-east-1 was assumed. */ - regionDetected: boolean; - /** The variable the aws-ssm naming preview is demonstrated on. */ - exampleVar: string; -} - -export interface WebDeploySetupParams extends WebServeOptions { - intent: DeploySetupIntent; - /** - * The steps this run asks, in order. The CLI decides — an existing target - * has already chosen its adapter, and the Vercel heal path asks for settings - * and nothing else. - */ - steps: DeploySetupStep[]; - /** Rail context: the platform answer from earlier in the run. */ - platformAnswer?: string; - /** Set only when the mode question was really asked. Absent → that stop is skipped. */ - modeAnswer?: string; - /** Offered on the `adapter` step, planned rows included. */ - adapters?: DeployAdapterChoice[]; - /** Chosen before the browser opened: a saved target, or `--target `. */ - adapterId?: string; - /** Capy branches in keep.lock. Empty → the CLI offers `development` only. */ - capyBranches: string[]; - /** The CLI's own default branch for this target. */ - branch: string; - /** Saved target names, so a silent overwrite is caught before it happens. */ - existingNames: string[]; - /** The saved target's name, when this is an edit. */ - existingName?: string; - /** Resolve label, detected defaults and everything downstream of the adapter. */ - resolveAdapter: (id: string) => Promise; -} - -export interface WebDeploySetupResult { - adapterId: string; - branch: string; - options: Record; - vars: string[]; - mode: DeployMode; - gitBaseBranch?: string; - name: string; - cancelled: boolean; -} - -/** The answers folded forward as the run walks its steps. */ -interface SetupAnswers { - adapterId?: string; - branch?: string; - options?: Record; - vars?: string[]; - mode?: DeployMode; - gitBaseBranch?: string; - name?: string; -} - -/** Which step id draws which stop. `drift` is the variables stop, re-asked. */ -const STEP_STOP: Record = { - adapter: 'platform', - branch: 'branch', - settings: 'settings', - variables: 'variables', - drift: 'variables', - delivery: 'delivery', - name: 'name', -}; - -/** A one-line summary of the adapter settings, for the rail. */ -function settingsAnswer(adapterId: string, options: Record): string | undefined { - const s = (k: string): string | undefined => - typeof options[k] === 'string' ? (options[k] as string) : undefined; - switch (adapterId) { - case 'cf-worker': { - const name = s('workerName'); - const dir = s('workerDir'); - return name ? (dir && dir !== '.' ? `${name} in ${dir}` : name) : undefined; - } - case 'cf-pages': - return s('projectName'); - case 'vercel': { - const dir = s('projectDir'); - const env = s('vercelEnv'); - return dir && env ? `${dir} · ${env}` : (env ?? dir); - } - case 'aws-ssm': { - const region = s('region'); - const prefix = s('pathPrefix'); - return region && prefix ? `${region} · ${prefix}` : (region ?? prefix); - } - default: - return undefined; - } -} - -/** The settings block for the step, built from the CLI's own defaults. */ -function settingsFor( - ctx: WebDeployAdapterContext, - d: Record, -): DeploySettings | undefined { - switch (ctx.id) { - case 'cf-worker': - return { - kind: 'cf-worker', - workerName: d.workerName ?? '', - workerDir: d.workerDir ?? '.', - }; - case 'cf-pages': - return { - kind: 'cf-pages', - projectName: d.projectName ?? '', - buildDir: d.buildCwd ?? '.', - buildCommand: d.buildCmd ?? 'bun run build', - distDir: d.distDir ?? 'dist', - }; - case 'vercel': - return { - kind: 'vercel', - projectDir: d.projectDir ?? '.', - env: d.vercelEnv === 'production' ? 'production' : 'preview', - gitBranch: d.gitBranch ?? '', - gitBranches: ctx.gitBranches, - }; - case 'aws-ssm': - return { - kind: 'aws-ssm', - region: d.region ?? 'us-east-1', - regionDetected: ctx.regionDetected, - pathPrefix: d.pathPrefix ?? '/capy/', - naming: d.naming === 'kebab' ? 'kebab' : 'verbatim', - exampleVar: ctx.exampleVar, - }; - default: - return undefined; - } -} - -/** The CLI's own path-prefix rule, so the two surfaces cannot disagree. */ -const PATH_PREFIX_RULE = /^\/[a-zA-Z0-9_.\-/]*\/$/; -/** The CLI's own target-name rule: `upsertTarget` keys by exactly this shape. */ -const TARGET_NAME_RULE = /^[a-z0-9][a-z0-9-]*$/; - -const SETUP_NON_TTY: Record = { - adapter: { - command: 'capy deploy --target --yes', - why: 'The adapter decides which vendor CLI runs and what a variable means to it, so it is never picked for you.', - }, - branch: { - command: 'capy deploy --target --yes', - why: 'Off a TTY the branch falls back to production, or to the first branch in keep.lock — a guess about whose secrets ship.', - }, - settings: { - command: 'capy deploy --target --yes', - why: 'Settings come from what Capy can detect in this directory. Anything it cannot detect has no headless answer, so run the picker once interactively.', - }, - variables: { - command: 'capy deploy --target --yes', - why: 'The pre-ticked set is a prefix rule, not a decision. Under --yes it is taken as one, and on a build-time target that decides what ends up in a public bundle.', - }, - drift: { - command: 'capy deploy --yes', - why: 'Under --yes a variable added since the target was saved refuses the run, and one removed is dropped silently. Neither is an answer to what should ship.', - }, - delivery: { - command: 'capy deploy --target --yes', - why: 'Delivery is the difference between opening a pull request and shipping from this machine. A target saved without it resolves to direct — the irreversible one.', - }, - name: { - command: 'capy deploy --target --yes', - why: 'An unnamed run builds an ad-hoc target that is never written to disk, so the next deploy re-detects everything from scratch.', - }, -}; - -export function buildDeployTargetSetupData( - p: WebDeploySetupParams, - nonce: string, - step: DeploySetupStep, - ctx: WebDeployAdapterContext | null, - answered: SetupAnswers, -): DeployTargetSetupData { - const answers: Partial> = {}; - if (p.platformAnswer) answers.platform = stripAnsi(p.platformAnswer); - else if (ctx) answers.platform = stripAnsi(ctx.label); - if (p.modeAnswer) answers.mode = stripAnsi(p.modeAnswer); - if (answered.branch) answers.branch = answered.branch; - if (answered.options && ctx) { - const summary = settingsAnswer(ctx.id, answered.options); - if (summary) answers.settings = stripAnsi(summary); - } - if (answered.vars) answers.variables = plural(answered.vars.length, 'variable', 'variables'); - if (answered.mode) answers.delivery = answered.mode === 'ci' ? 'CI' : 'Direct'; - if (answered.name) answers.name = answered.name; - - // Stops this run will not travel: the mode question when it was never asked, - // and any setup stop no step in this run's route reaches (an existing target - // has already chosen its adapter; the Vercel heal path asks settings alone). - const willVisit = new Set(p.steps.map((s) => STEP_STOP[s])); - const skipped: DeployStopId[] = []; - if (!p.modeAnswer) skipped.push('mode'); - for (const stop of new Set(Object.values(STEP_STOP))) { - if (!willVisit.has(stop) && answers[stop] === undefined && !skipped.includes(stop)) { - skipped.push(stop); - } - } - - return { - nonce, - step, - intent: p.intent, - stops: deployPlan({ at: STEP_STOP[step], answers, skipped }), - adapters: step === 'adapter' ? p.adapters : undefined, - adapter: ctx - ? { id: ctx.id, label: stripAnsi(ctx.label), detail: stripGloss(ctx.detail) } - : undefined, - detected: ctx?.detected ? stripAnsi(ctx.detected) : undefined, - capyBranches: p.capyBranches, - branch: answered.branch ?? p.branch, - settings: ctx - ? settingsFor(ctx, { ...ctx.defaults, ...(answered.options as Record) }) - : undefined, - vars: ctx?.vars, - presetLabel: ctx?.presetLabel, - delivery: ctx - ? { - ...ctx.delivery, - mode: answered.mode ?? ctx.delivery.mode, - prBase: answered.gitBaseBranch ?? ctx.delivery.prBase, - } - : undefined, - name: answered.name ?? p.existingName ?? (ctx ? `${ctx.id}-${answered.branch ?? p.branch}` : ''), - existingNames: p.existingNames, - nonTty: SETUP_NON_TTY[step], - }; -} - -/** Read a trimmed string field off a submit. */ -const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : ''); - -/** - * Serve the picker. - * - * One step per render, folding each answer forward, so the rail redraws itself - * from `deployPlan` rather than this file deciding what a stop's state is. - * Every refusal below is inline: the screen holds its button on all of them, - * so an answer that breaks one did not come from the screen, and applying a - * guess would write a target that pushes somebody's secrets somewhere. - */ -export async function setUpDeployTargetInBrowser( - p: WebDeploySetupParams, -): Promise { - let nonce = ''; - let ctx: WebDeployAdapterContext | null = null; - let answered: SetupAnswers = {}; - let index = 0; - - if (p.adapterId) { - ctx = await p.resolveAdapter(p.adapterId); - answered.adapterId = p.adapterId; - } - - const render = (): string => - renderScreen( - 'deploy-target-setup', - buildDeployTargetSetupData(p, nonce, p.steps[index], ctx, answered), - ); - - /** Everything the caller asked for, with the CLI's defaults for the rest. */ - const settle = (): WebDeploySetupResult => ({ - adapterId: answered.adapterId ?? p.adapterId ?? '', - branch: answered.branch ?? p.branch, - options: answered.options ?? {}, - vars: answered.vars ?? (ctx?.vars.filter((v) => v.checked).map((v) => v.name) ?? []), - mode: answered.mode ?? ctx?.delivery.mode ?? 'direct', - gitBaseBranch: answered.gitBaseBranch ?? (ctx?.delivery.mode === 'ci' ? ctx.delivery.prBase : undefined), - name: answered.name ?? p.existingName ?? '', - cancelled: false, - }); - - const out = await runBrowserWizard( - { - title: 'Set up a deploy target', - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Target described — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { ...settle(), cancelled: true } }; - } - - const step = p.steps[index]; - if (step === undefined) return { error: 'There is nothing left to answer on this run.' }; - - switch (step) { - case 'adapter': { - const id = str(payload.adapter); - const row = p.adapters?.find((a) => a.id === id); - // A planned adapter is rendered disabled: it is announced, not - // shippable, and a target saved against one deploys nothing. - if (!row || row.planned) { - return { error: 'That adapter is not one this run can deploy to.' }; - } - ctx = await p.resolveAdapter(id); - answered = { ...answered, adapterId: id }; - break; - } - case 'branch': { - const branch = str(payload.branch); - const offered = p.capyBranches.length > 0 ? p.capyBranches : ['development']; - if (!offered.includes(branch)) { - return { error: 'That branch is not one this project has.' }; - } - answered = { ...answered, branch }; - break; - } - case 'settings': { - const problem = settingsProblem(ctx, payload); - if (typeof problem === 'string') return { error: problem }; - answered = { ...answered, options: problem }; - break; - } - case 'variables': - case 'drift': { - const offered = new Set((ctx?.vars ?? []).map((v) => v.name)); - const raw = Array.isArray(payload.vars) ? payload.vars : []; - const vars = raw.filter((v): v is string => typeof v === 'string' && offered.has(v)); - if (vars.length !== raw.length) { - return { error: 'That list contains a variable this branch does not have.' }; - } - // The CLI's own validator: `select at least one`. A target with no - // variables has nothing to push, and saving one is a deploy that - // reports success having sent nothing. - if (vars.length === 0) { - return { error: 'Select at least one variable — a target with none has nothing to push.' }; - } - answered = { ...answered, vars }; - break; - } - case 'delivery': { - const mode = payload.mode; - if (mode !== 'ci' && mode !== 'direct') { - return { error: 'That is not an answer the delivery step can produce.' }; - } - // A CI-only adapter has no direct mode at all — Capy never runs its - // CLI — so `direct` here would push secrets and hope. - if (ctx?.delivery.ciOnly && mode !== 'ci') { - return { error: `${ctx.label} always deploys through CI.` }; - } - if (mode === 'ci') { - const base = str(payload.gitBaseBranch); - if (!base) return { error: 'Enter the git branch the deploy PR should open against.' }; - answered = { ...answered, mode, gitBaseBranch: base }; - } else { - answered = { ...answered, mode, gitBaseBranch: undefined }; - } - break; - } - case 'name': { - const name = str(payload.name); - if (!TARGET_NAME_RULE.test(name)) { - return { error: 'Target names are lowercase letters, numbers and dashes, starting with a letter or a number.' }; - } - answered = { ...answered, name }; - break; - } - } - - index += 1; - if (index >= p.steps.length) return { done: true, result: settle() }; - // A whole document cannot be spliced into the open page, so it is handed - // back as `standalone` and the browser reloads to receive it. - return { screen: { html: render(), standalone: true } }; - }, - ); - return out as WebDeploySetupResult; -} - -/** - * Validate a settings submit against the adapter it claims to be for. - * - * Returns the options to save, or the sentence to show. Each rule is the CLI's - * own — the same emptiness checks its `validate` callbacks make and the same - * path-prefix regex — so a value one surface accepts is never refused by the - * other. - */ -function settingsProblem( - ctx: WebDeployAdapterContext | null, - payload: Record, -): string | Record { - if (!ctx) return 'No adapter has been chosen on this run.'; - switch (ctx.id) { - case 'cf-worker': { - const workerName = str(payload.workerName); - const workerDir = str(payload.workerDir); - if (!workerName) return 'Worker name cannot be empty — it is the name field in wrangler.toml.'; - if (!workerDir) return 'Worker directory cannot be empty — use . if wrangler.toml is at the repository root.'; - return { workerName, workerDir }; - } - case 'cf-pages': { - const projectName = str(payload.projectName); - const buildCwd = str(payload.buildCwd); - const buildCmd = str(payload.buildCmd); - const distDir = str(payload.distDir); - if (!projectName) return 'Pages project name cannot be empty — wrangler pages project list prints the names.'; - if (!buildCwd) return 'Build directory cannot be empty — use . if package.json is at the repository root.'; - if (!buildCmd) return 'Build command cannot be empty.'; - if (!distDir) return 'Dist directory cannot be empty — it is where the build leaves its output.'; - return { projectName, buildCwd, buildCmd, distDir }; - } - case 'vercel': { - const projectDir = str(payload.projectDir); - const vercelEnv = payload.vercelEnv; - if (!projectDir) return 'Project directory cannot be empty — use . if the Vercel project is at the repository root.'; - if (vercelEnv !== 'preview' && vercelEnv !== 'production') { - return 'That is not a Vercel environment this step offers.'; - } - if (vercelEnv === 'production') { - // Dropped rather than sent empty: Production is not branch-scoped, and - // a blank would preflight as "missing" on the next run. - return { projectDir, vercelEnv }; - } - const gitBranch = str(payload.gitBranch); - if (!gitBranch) { - return 'Pick the git branch the Preview environment is wired to — this is a git branch, not a capy branch.'; - } - return { projectDir, vercelEnv, gitBranch }; - } - case 'aws-ssm': { - const region = str(payload.region); - const pathPrefix = str(payload.pathPrefix); - const naming = payload.naming; - if (!region) return 'AWS region cannot be empty.'; - if (!PATH_PREFIX_RULE.test(pathPrefix)) { - return 'Parameter path prefix must start and end with / — for example /capy/prod/.'; - } - if (naming !== 'verbatim' && naming !== 'kebab') { - return 'That is not a naming scheme this step offers.'; - } - return { region, pathPrefix, naming }; - } - default: - return 'This adapter has no settings this run can save.'; - } -} - -// --------------------------------------------------------------------------- -// deploy-plan-confirm — the last gate before anything is decrypted -// --------------------------------------------------------------------------- - -/** What the gate came back with. `edit` is an answer the CLI acts on. */ -export type WebDeployDecision = 'deploy' | 'edit' | 'delete'; - -export interface WebDeployConfirmParams extends WebServeOptions { - target: DeployPlanTarget; - /** Whether the primary button opens a PR or ships from this machine. */ - action: 'ci' | 'direct'; - dryRun: boolean; - preflight: DeployPreflightCheck[]; - /** True once `adapter.preflight` has passed — the only thing that can say - * the manual vendor sign-in really happened. */ - signedIn: boolean; - drift?: DeployDrift; - changeGate?: DeployChangeGate; - /** Working-tree changes other than keep.lock, which a direct deploy stashes. */ - otherGitChanges?: number; - /** Set only when the mode question was really asked earlier in the run. */ - modeAnswer?: string; - /** - * The decisions this gate will honour. - * - * The confirm gate takes all three. The change-gate question that comes - * after decryption takes only `deploy` — the picker cannot be re-entered - * from there, and the CLI's own prompt at that point is a bare y/N. - */ - allow?: readonly WebDeployDecision[]; - rejected?: string; -} - -export interface WebDeployConfirmResult { - /** Null when the window was cancelled: an unanswered gate is a refusal. */ - decision: WebDeployDecision | null; - /** The change-gate switch. False unless the page asked and it was set. */ - force: boolean; - cancelled: boolean; -} - -export function buildDeployPlanConfirmData( - p: WebDeployConfirmParams, - nonce: string, -): DeployPlanConfirmData { - const t = p.target; - const answers: Partial> = { - platform: stripAnsi(t.adapterLabel), - branch: t.branch, - variables: plural(t.vars.length, 'variable', 'variables'), - delivery: t.mode === 'ci' ? 'CI' : 'Direct', - }; - if (p.modeAnswer) answers.mode = stripAnsi(p.modeAnswer); - const settings = settingsAnswer( - t.adapterId, - Object.fromEntries(t.options.map((o) => [o.key, o.value])), - ); - if (settings) answers.settings = stripAnsi(settings); - // Preflight is the only place Capy learns whether the vendor session the - // user established by hand exists. Before it, the stop is honestly unknown. - if (p.signedIn && SIGNIN_COMMAND[t.adapterId]) answers.signin = SIGNIN_COMMAND[t.adapterId]; - if (t.saved) answers.name = t.name; - - const skipped: DeployStopId[] = []; - if (!p.modeAnswer) skipped.push('mode'); - // An ad-hoc target was assembled from `--target` and never named, so the - // naming question did not happen rather than being answered. - if (!t.saved) skipped.push('name'); - - return { - nonce, - stops: deployPlan({ at: 'review', answers, skipped, dryRun: p.dryRun }), - // The label and the preflight rows are strings the CLI also PRINTS, so - // they arrive coloured and would render as a literal `[90m` on the page. - // `name` is deliberately NOT rewritten: it is the string the delete gate - // makes the user type back, and it has to match what is on disk. - target: { ...t, adapterLabel: stripAnsi(t.adapterLabel) }, - action: p.action, - dryRun: p.dryRun, - preflight: p.preflight.map((c) => ({ - ...c, - label: stripAnsi(c.label), - detail: stripOpt(c.detail), - fix: stripOpt(c.fix), - })), - drift: p.drift, - changeGate: p.changeGate, - otherGitChanges: p.otherGitChanges, - rejected: p.rejected, - nonTty: { - command: `capy deploy ${t.name} --yes`, - why: '--yes is not optional off a TTY: without it the confirm resolves to cancel and the run exits 0 having deployed nothing.', - }, - deleteNonTty: { - command: `capy deploy targets-remove ${t.name}`, - why: 'Removing a target deletes its platform, branch, settings and variable list from .capy/deploy.json, and nothing keeps a copy.', - }, - }; -} - -/** - * Serve the gate and wait for a decision. - * - * `edit` is an ANSWER, not an exit: the CLI re-enters the picker and comes - * back here with the edited target, exactly as pressing `e` does at the TTY. - */ -export async function confirmDeployInBrowser( - p: WebDeployConfirmParams, -): Promise { - const allow = p.allow ?? (['deploy', 'edit', 'delete'] as const); - const out = await runBrowserWizard( - { - title: `Review this deploy — ${p.target.name}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Answered — back to your terminal.', - renderFirst: (nonce) => - renderScreen('deploy-plan-confirm', buildDeployPlanConfirmData(p, nonce)), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { decision: null, force: false, cancelled: true } }; - } - const decision = payload.decision; - if (decision !== 'deploy' && decision !== 'edit' && decision !== 'delete') { - return { error: 'That is not a decision this gate can take.' }; - } - if (!allow.includes(decision)) { - // The change-gate question is asked after the plan was already - // approved and after the secrets were decrypted; there is no picker - // left to re-enter and no target left to delete without stranding the - // run half-done. - return { error: 'That is not available at this point in the run.' }; - } - if (decision === 'delete') { - // The screen holds its button until the target's name has been typed - // back, so a delete that names anything else did not come from it. - if (str(payload.target) !== p.target.name) { - return { error: 'That is not the target this page is about.' }; - } - } - return { - done: true, - result: { decision, force: payload.force === true, cancelled: false }, - }; - }, - ); - return out as WebDeployConfirmResult; -} - -// --------------------------------------------------------------------------- -// deploy-targets — the saved destinations, read and answered -// --------------------------------------------------------------------------- - -/** What the listing came back with. */ -export type WebTargetsAction = 'use' | 'new' | 'edit' | 'remove'; - -export interface WebDeployTargetsParams extends WebServeOptions { - projectName: string; - /** Where the list was read from, so an empty list has an explanation. */ - configPath: string; - purpose: DeployTargetsPurpose; - /** The adapter kind a `pick` was narrowed to, from `--target `. */ - filterKind?: string; - filterKindLabel?: string; - targets: DeployTargetRow[]; - activeBranch?: string; - view?: DeployTargetsData['view']; - subjectTarget?: string; - /** The actions this run will honour. Anything else is refused inline. */ - allow: readonly WebTargetsAction[]; -} - -export interface WebDeployTargetsResult { - action: WebTargetsAction | null; - /** Empty for `new`, and for a cancelled window. */ - target: string; - cancelled: boolean; -} - -export function buildDeployTargetsData( - p: WebDeployTargetsParams, - nonce: string, -): DeployTargetsData { - return { - nonce, - projectName: stripAnsi(p.projectName), - configPath: p.configPath, - purpose: p.purpose, - filterKind: p.filterKind, - filterKindLabel: stripOpt(p.filterKindLabel), - // `name` round-trips as the answer, so it is left alone; the label is - // printed prose and an escape in it renders as `[90m` on the page. - targets: p.targets.map((t) => ({ ...t, adapterLabel: stripOpt(t.adapterLabel) })), - activeBranch: p.activeBranch, - view: p.view, - subjectTarget: p.subjectTarget, - nonTty: { - command: 'capy deploy targets', - why: 'The listing prints name, adapter, branch and variable names. It never prints a value — .capy/deploy.json holds none.', - }, - nonTtyRemove: { - command: 'capy deploy targets-remove ', - why: 'Removing a target deletes its platform, branch, settings and variable list from .capy/deploy.json, and nothing keeps a copy.', - }, - }; -} - -/** - * Serve the listing and wait for a row. - * - * The submitted name is resolved against the list the server sent, never - * trusted: `use` is followed by a real deploy against whatever comes back, and - * a target the screen could not have offered did not come from the screen. - * - * TWO ENDINGS, and this function is responsible for both of them REACHING the - * caller — every caller, which is why it is here and not repeated at the three - * call sites. Picked, edited, removed and "set up a new one" are submits. - * Everything else — the window closed, `Keep it` clicked, the deadline reached - * — is a refusal, and a refusal RESOLVES as `{ action: null, cancelled: true }`. - * It does not reject. `capy deploy targets-remove --web` used to hang - * for the wizard's five minutes on a decline and `capy deploy --web` used to - * throw its timeout out of the picker, both for a user who had simply chosen - * not to go on. - * - * SCREEN CHANGE NEEDED (packages/ui/screens/deploy-targets/Screen.svelte — not - * made here, this parcel may not edit packages/ui): - * 1. `confirm-remove`'s "Keep it" (line ~248) sets `view = 'list'` and posts - * nothing. It should `submitToCli('/submit', data.nonce, { __action: - * 'cancel' })` the way the same package's `Wizard` cancel does. - * 2. The `list` view has no exit control at all — Edit and Remove are the - * only buttons. It needs a quiet "Done" posting the same thing. - * Until (1) lands, `withDeclineBridge` answers the confirm view from this side. - * Until (2) lands, `NO_REFUSAL_TIMEOUT_MS` is the listing's only ending. - */ -export async function chooseDeployTargetInBrowser( - p: WebDeployTargetsParams, -): Promise { - const refused: WebDeployTargetsResult = { action: null, target: '', cancelled: true }; - const out = await runBrowserWizard( - { - title: p.purpose === 'pick' ? 'Which target?' : `Deploy targets — ${p.projectName}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs ?? NO_REFUSAL_TIMEOUT_MS, - doneMessage: 'Picked — back to your terminal.', - renderFirst: (nonce) => { - const html = renderScreen('deploy-targets', buildDeployTargetsData(p, nonce)); - // Only when the CONFIRM is the whole question. On the listing, backing - // out of a remove leaves the user on a page they still have business - // with — ending the run there would be answering a question nobody - // asked. - return p.view === 'confirm-remove' - ? withDeclineBridge(html, { - nonce, - // The confirm view's danger callout: `deploy-targets` has exactly - // one, and it is the sentence that makes this a question. Not the - // danger BUTTON, which the sibling screen keeps in its listing — - // one selector for both, bound to a design-system variant. - question: '.callout.danger', - headline: 'Kept — nothing was removed.', - detail: `${stripAnsi(p.subjectTarget ?? 'The target')} is still in ${p.configPath}.`, - }) - : html; - }, - }, - async (_step, payload) => { - const action = payload.__action; - if (action === 'cancel') { - return { done: true, result: refused }; - } - if ( - action !== 'use' && - action !== 'new' && - action !== 'edit' && - action !== 'remove' - ) { - return { error: 'That is not an action this screen offers.' }; - } - if (!p.allow.includes(action)) { - return { error: 'That is not something this run can do.' }; - } - if (action === 'new') { - return { done: true, result: { action, target: '', cancelled: false } }; - } - const name = str(payload.target); - if (!p.targets.some((t) => t.name === name)) { - return { error: 'That target is not saved for this project.' }; - } - return { done: true, result: { action, target: name, cancelled: false } }; - }, - ).catch(refusalOn(refused)); - return out as WebDeployTargetsResult; -} - -// --------------------------------------------------------------------------- -// deploy-tokens — the minted credentials, and the one irreversible thing -// --------------------------------------------------------------------------- - -export interface WebDeployTokensParams extends WebServeOptions { - /** Null when the project record has no name. The CLI prints `"undefined"`. */ - projectName: string | null; - tokens: DeployTokenRow[]; - view?: DeployTokensData['view']; - subjectToken?: string; -} - -export interface WebDeployTokensResult { - /** The full deploy id to revoke, or null when nothing was revoked. */ - deployId: string | null; - /** True whenever the flow ended without a revoke. Never an error. */ - cancelled: boolean; -} - -export function buildDeployTokensData( - p: WebDeployTokensParams, - nonce: string, -): DeployTokensData { - return { - nonce, - projectName: p.projectName === null ? null : stripAnsi(p.projectName), - tokens: p.tokens, - view: p.view, - subjectToken: p.subjectToken, - nonTty: { - command: 'capy deploy list', - why: 'A deploy id is an identifier, not a credential — the token itself is never returned by the service after minting.', - }, - nonTtyRevoke: { - command: 'capy deploy revoke ', - why: 'Revoking cuts every pipeline holding that token off from this project immediately, and it cannot be undone.', - }, - }; -} - -/** - * Serve the token listing. - * - * TWO ENDINGS, and this function is responsible for both of them REACHING the - * caller. Revoked is a submit. Everything else — the window closed, "Leave it - * active" clicked, the deadline reached — is a refusal, and a refusal RESOLVES - * here as `{ deployId: null, cancelled: true }`. It does not reject. - * - * That last part is the fix for a real defect: declining a revoke used to - * reject with a timeout, which `capy deploy revoke --web` turned into an - * error screen and a non-zero exit — for a user who had correctly chosen NOT to - * revoke. A decline is not a failure, and the only thing the CLI truthfully - * knows in every one of these cases is the same: nothing was revoked. - * - * A refusal is distinguished from a broken server structurally, never by - * reading a message: `runBrowserWizard` rejects with `CapyError` when nobody - * answered (deadline, Ctrl-C) and with the raw socket error when the server - * itself failed. Only the first is an ending; the second is still a throw. - * - * SCREEN CHANGE NEEDED (packages/ui/screens/deploy-tokens/Screen.svelte — not - * made here, this parcel may not edit packages/ui): - * 1. "Leave it active" (line ~195) sets `view = 'list'` and posts nothing. - * It should `submitToCli('/submit', data.nonce, { __action: 'cancel' })` - * so the decline is the page's own act rather than something inferred. - * 2. The `list` view has NO exit control at all — the only button is Revoke. - * It needs a quiet "Done" that posts the same `__action: 'cancel'`. - * With those two, the reducer below already answers them: `__action: 'cancel'` - * is handled. Until (1) lands, `withDeclineBridge` posts it from this side the - * instant the question leaves the page, so `capy deploy revoke --web` - * answers a decline at once and shows the user that it did. Until (2) lands, - * `NO_REFUSAL_TIMEOUT_MS` is the listing's only ending. - */ -export async function showDeployTokensInBrowser( - p: WebDeployTokensParams, -): Promise { - const refused: WebDeployTokensResult = { deployId: null, cancelled: true }; - const out = await runBrowserWizard( - { - title: `Deploy tokens — ${p.projectName ?? 'this project'}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs ?? NO_REFUSAL_TIMEOUT_MS, - doneMessage: 'Done — back to your terminal.', - renderFirst: (nonce) => { - const html = renderScreen('deploy-tokens', buildDeployTokensData(p, nonce)); - // Only when the CONFIRM is the whole question — `capy deploy revoke - // --web`. On `capy deploy list --web` the same click walks back to - // a listing the user may still want, and ending the run there would be - // answering a question nobody asked. - return p.view === 'confirm-revoke' - ? withDeclineBridge(html, { - nonce, - // The confirm view's danger callout — "This cannot be undone". - // NOT `button.danger`: this screen keeps a danger button in its - // listing too, so the button survives the decline and the callout - // does not. - question: '.callout.danger', - headline: 'Left active — nothing was revoked.', - detail: `${stripAnsi(p.subjectToken ?? 'The token')} can still fetch this project's secrets.`, - }) - : html; - }, - }, - async (_step, payload) => { - // Already the vocabulary every other screen answers in, so the moment - // the screen grows the two controls named above this path is live. - if (payload.__action === 'cancel') { - return { done: true, result: refused }; - } - // This screen posts a bare `action`, not the wizard's `__action`: it - // drives its own submit rather than going through the shared form. - if (payload.action !== 'revoke') { - return { error: 'That is not an action this screen offers.' }; - } - const id = str(payload.deployId); - // The full id, resolved against the list the server sent. Two tokens can - // share twelve characters, and a prefix the screen did not offer would - // revoke whichever one the service matched first. - if (!p.tokens.some((t) => t.deployId === id)) { - return { error: 'That token is not one this project has.' }; - } - return { done: true, result: { deployId: id, cancelled: false } }; - }, - ).catch(refusalOn(refused)); - return out as WebDeployTokensResult; -} - -// --------------------------------------------------------------------------- -// deploy-run-result — what the run actually did -// --------------------------------------------------------------------------- - -export function buildDeployRunResultData(d: DeployRunResultData): DeployRunResultData { - return { - ...d, - projectName: stripAnsi(d.projectName), - // The adapter label is printed beside every step in the terminal, and the - // epilogue is lifted straight out of the adapter's own stdout block — the - // two places colour is most likely to have been baked in already. - target: { ...d.target, adapterLabel: stripAnsi(d.target.adapterLabel) }, - steps: d.steps.map((s) => ({ - ...s, - label: stripAnsi(s.label), - detail: stripOpt(s.detail), - output: stripOpt(s.output), - })), - epilogue: - d.epilogue === undefined - ? undefined - : { - ...d.epilogue, - title: stripAnsi(d.epilogue.title), - snippet: stripAnsi(d.epilogue.snippet), - note: stripOpt(d.epilogue.note), - }, - }; -} - -/** - * Serve a screen that only displays, and wait for the browser to fetch it. - * - * This is `serveEndingPage` — the shared ending-page wait — with the one thing - * a deploy adds on top: a serve that FAILS must not look like a deploy that - * failed. Everything else it needs is already there, and deliberately is not - * reimplemented here: `ScreenServer.delivered` is the fact that the page - * reached a browser, and awaiting it is what stops the run exiting out from - * under the socket it just opened. `deploy-run-result` is the last thing a - * deploy does, so that window is exactly where the page would have died. - * - * The wait ends when the page has been served, or when the deadline says - * nobody is coming. - */ -export async function showScreenInBrowser( - screen: K, - data: ScreenDataMap[K], - opts: WebServeOptions & { note?: string } = {}, -): Promise { - const { serveEndingPage } = await import('./endingPage'); - try { - await serveEndingPage(screen, data, { - open: opts.open ?? true, - onListen: opts.onListen, - timeoutMs: opts.timeoutMs ?? 120_000, - lead: opts.note ?? 'Open this in your browser:', - }); - } catch (err) { - // A page that cannot be served must not undo a deploy that already - // happened — but it is said out loud rather than swallowed. - console.log(` Could not open the result page: ${err instanceof Error ? err.message : err}`); - } -} - -/** Serve the step log the terminal prints as `renderResult`. */ -export function showDeployRunResultInBrowser( - data: DeployRunResultData, - opts: WebServeOptions = {}, -): Promise { - return showScreenInBrowser('deploy-run-result', buildDeployRunResultData(data), { - ...opts, - note: 'What this deploy did (your secrets never reach this page):', - }); -} diff --git a/src/ui/endingPage.ts b/src/ui/endingPage.ts deleted file mode 100644 index 84b4c6f6..00000000 --- a/src/ui/endingPage.ts +++ /dev/null @@ -1,82 +0,0 @@ -// The last page a `--web` command serves — and the wait that makes it arrive. -// -// An ending is a page with nothing to send back: what the rotation did, which -// key landed where, why the run could not start. It is served by `ScreenServer` -// rather than the wizard, so `screenHeaders()` gives it `connect-src 'none'` — -// a page that renders the outcome of a credential rotation cannot open a socket -// to anywhere, including us. -// -// TWO THINGS EVERY ENDING NEEDS, and both were missing at every call site: -// -// 1. The URL has to be printed. `open()` resolves when the browser process -// was SPAWNED, not when it loaded anything, and under `CAPY_WEB_NO_OPEN` -// — every agent-driven run — nothing is spawned at all. A URL that is -// returned to the caller and never written down is a page nobody can -// reach. -// 2. The run has to hold until the browser has the page. `start()` resolves -// when the socket is LISTENING; the command then exits, which closes the -// socket microseconds later and the browser gets ECONNREFUSED. Endings -// are precisely where commands exit, so this is the one place that cannot -// be got wrong by accident. -// -// The wait is bounded by the server's own timeout: a browser that never comes -// costs that ceiling, and the caller carries on and exits either way. -import { ScreenServer } from './screens/serve'; -import type { ScreenDataMap, ScreenName } from './screens/contract'; - -export interface EndingPageOptions { - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - /** - * Ceiling for a browser that never arrives. The work this page reports is - * already over, so a finished command must not sit on the `ScreenServer`'s - * two-minute default waiting for a report nobody is reading. - */ - timeoutMs?: number; - /** The line printed above the URL. Says what is on the page, in one clause. */ - lead: string; -} - -/** - * Serve one display-only screen, print where it is, and return once the - * browser has it (or the wait ran out). - * - * Returns the URL, and whether it was ever fetched — a caller that wants to - * say "nobody read it" has the fact rather than having to infer it. - */ -export async function serveEndingPage( - screen: K, - data: ScreenDataMap[K], - p: EndingPageOptions, -): Promise<{ url: string; delivered: boolean }> { - const server = new ScreenServer(screen, data, { timeoutMs: p.timeoutMs ?? 60_000 }); - const url = await server.start(); - p.onListen?.(url); - - // Printed before the open attempt, so the address exists in the transcript - // even when opening throws — and so an agent relaying this run has something - // to hand its user. - console.log(''); - console.log(` ${p.lead}`); - console.log(` ${url}`); - console.log(''); - - if (p.open ?? true) { - // An ending is a loopback screen like any other, so it gets the same - // chromeless window — and it knows its own name, so it can be sized right. - const { openScreen } = await import('./openScreen'); - const { SCREEN_WIDE } = await import('./screens/generated'); - await openScreen(url, { kind: 'dialog', wide: SCREEN_WIDE[screen] }); - } - - const delivered = await server.delivered; - // A short beat after the response was handed to the socket. `finish` means - // the body reached the kernel, not that the kernel drained it, and the very - // next thing several callers do is end the process — so this is the margin - // between "the browser has the page" and "the browser has most of the page". - // Not paid at all when nobody came: there is nothing to flush. - if (delivered) await new Promise((r) => setTimeout(r, 100)); - return { url, delivered }; -} diff --git a/src/ui/errorScreen.ts b/src/ui/errorScreen.ts index 25c705ee..cdf64f59 100644 --- a/src/ui/errorScreen.ts +++ b/src/ui/errorScreen.ts @@ -50,11 +50,7 @@ export function renderError(error: any, context: ErrorContext = {}): string { return renderNoKeepFile(); case ERROR_CODES.QUOTA_EXCEEDED: return renderQuotaExceeded(error); - // The local-state refusals. Each of these was a bare `console.error` at - // the top of a command, which is fine in a terminal and invisible under - // `--web` — the flag exists because the caller is an agent. Routing them - // through here costs the terminal nothing and gives the browser half a - // payload to draw, from one switch on one code. + // The local-state refusals, one switch on one code. case ERROR_CODES.NO_ACTIVE_BRANCH: return renderNoActiveBranch(); case ERROR_CODES.NO_MANAGED_KEYS: @@ -78,19 +74,9 @@ export function renderError(error: any, context: ErrorContext = {}): string { } /** - * End the run on the failure, wherever the caller is looking. + * End the run on the failure: print it, then exit 1. * - * Async, and every call site awaits it. That is not ceremony: under `--web` - * this has to hold the process open until the browser has actually fetched the - * page. `ScreenServer.start()` resolves when the socket is LISTENING, and a - * command that exits on the next line closes it microseconds before the - * browser connects — the same defect that made every ending page in the - * connectors parcel undeliverable. `serveEndingPage` waits for delivery; this - * function cannot return before it does. - * - * The terminal still gets its ANSI in both modes. A `--web` run has a terminal - * somewhere even when nobody is watching it, and a transcript that goes quiet - * at the moment of failure is worse than one nobody reads. + * Async because every call site awaits it; the exit code is the contract. */ export async function displayErrorAndExit( error: any, @@ -103,24 +89,6 @@ export async function displayErrorAndExit( const output = renderError(error, context); if (output) console.log(output); - const { isWebMode } = await import('./webMode'); - if (isWebMode()) { - try { - const { buildCommandErrorData } = await import('./commandErrorScreen'); - const { serveEndingPage } = await import('./endingPage'); - await serveEndingPage('command-error', buildCommandErrorData(error, context), { - lead: 'What went wrong is in your browser:', - // Shorter than an ending that reports work: the run is already over - // and nothing is pending, so a page nobody collects must not hold a - // failed command open for two minutes. - timeoutMs: 60_000, - }); - } catch { - // A failure while reporting a failure is not worth a second failure. - // The ANSI above already went out, and the exit code is the contract. - } - } - process.exit(1); } diff --git a/src/ui/initWizardScreen.ts b/src/ui/initWizardScreen.ts deleted file mode 100644 index d800458e..00000000 --- a/src/ui/initWizardScreen.ts +++ /dev/null @@ -1,605 +0,0 @@ -// The first run — `capy` in a directory with no keep.lock — served as the -// compiled `init-wizard` screen. -// -// WHAT WAS WRONG. The six prompts of `initializeProject` had a `--web` path -// already, and it was six unrelated pages: an org list, a project list, two -// bare text boxes, another list, and a yes/no. Each one opened its own server, -// its own tab and its own URL, and none of them could say what the other five -// were, so a person answering "development" had no way to know a consent gate -// that rewrites their .env was two questions away. The route existed only in -// the order the CLI happened to ask. -// -// This serves ONE window for the whole run. `initWizardPlan` computes all ten -// stops before the first page is rendered — including the ones this run will -// skip and the ones whose fork is not settled yet — and every step redraws the -// same rail with the answers folded in. Advancing is a page RELOAD, which is -// what `standalone` means: a compiled screen is a whole document, and the -// browser fetches the next step rather than being handed its markup. -// -// SECRET MATERIAL. Variable NAMES and counts only. The values in that .env are -// still plaintext on disk at this point and the last stop's entire question is -// whether they may stop being — putting even a snippet of one on the page -// would be showing more than the terminal does to ask it. The recovery phrase -// belongs to the same flow and never appears here at all: `orgCreation.ts` -// shows and confirms it on its own surface, and the `recovery` stop is drawn -// `manual` to say that this page is not where that happens. -// -// TWO ENDINGS, AND A THIRD THAT IS NOT "DONE". A submit and a cancel are the -// browser's own endings and the screen draws them from the button that was -// pressed. A run that STOPS — no key on this device, a push that failed after -// consent — is neither, and it may not be reported with `{ done }`: the page -// would draw the ending the button implied, which is a green check over a -// failure. Those land on a final page instead: `blocked` for a stop the run -// cannot get past, `encryptFailure` for the one failure that happens after the -// last question and changes what is on disk. -import { runBrowserWizard, type WizardDecision } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { initWizardPlan, type InitWizardInput } from '../core/initWizardPlan'; -import { CapyError, ERROR_CODES } from '../types'; -import { isReservedProjectName, PROJECT_NAME_RESERVED_MESSAGE } from '../system/reservedProjectName'; -import type { - Blocked, - InitEncryptFailure, - InitLocalEnv, - InitOrg, - InitProject, - InitStep, - InitTarget, - InitWizardData, -} from './screens/contract'; - -/** - * Strip terminal colour codes on the way into a payload. - * - * Applied to names the CLI also PRINTS — an org name inside a bolded prompt, a - * project name out of `getDefaultProjectName` — because a payload is not a - * terminal and an escape renders as a literal `[1m` in the browser. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -/** The sentinel the org list uses for "create a new one". Never leaves the CLI. */ -export const CREATE_NEW_ORG = '__create_new__'; -/** The sentinel the project list uses for "start a new one". Never leaves the CLI. */ -export const CREATE_NEW_PROJECT = '__create_new_project__'; - -/** Everything one render of the wizard needs: which step, and the run so far. */ -export interface InitWizardView { - step: InitStep; - /** What has been answered and discovered. The rail is derived from this. */ - input: InitWizardInput; - orgs?: InitOrg[]; - projects?: InitProject[]; - projectsUnavailable?: boolean; - localEnv?: InitLocalEnv; - target?: InitTarget; - /** Prefill for a text step — the directory-derived default project name. */ - value?: string; - /** Why the previous answer was refused, in the CLI's own words. */ - rejected?: string; - /** The run cannot go past this stop. Replaces the question with the reason. */ - blocked?: Blocked; - /** Identifiers the block is about — the variables under a foreign key. */ - blockedNames?: string[]; - /** Labelled singletons the block is about — the organization, the branch. */ - blockedFacts?: { label: string; value: string }[]; - /** Consent was given and the push failed. What it had done by then. */ - encryptFailure?: InitEncryptFailure; -} - -/** - * How each stop is answered without a browser. - * - * The root `capy` command has no flags for any of this and no `--non-tty` - * either, so every one of these prompts hangs forever on a closed stdin. The - * declared route is what makes the gap expressible rather than mysterious, and - * `redeem` is a refusal on purpose: an invite code is key material, and a - * flag would leave it in shell history and `ps` output whoever typed it. - */ -const NON_TTY: Record = { - auth: { - command: 'capy', - why: 'Signing in opens a browser at capy.sc; there is no flag that replaces it.', - }, - organization: { - command: 'capy org', - why: 'Which organization a directory belongs to decides who can read its secrets, so it is never picked for you.', - }, - 'organization-name': { - command: 'capy org', - why: 'Creating an organization mints a master key and a recovery phrase, which cannot be done unattended.', - }, - redeem: { - command: 'capy redeem ', - why: 'An invite code is key material. Passing it as a flag would leave it in shell history and in ps output.', - }, - project: { - command: 'capy', - why: 'There is no flag for this: the root command asks, and bootstrapping the wrong project overwrites the .env in this directory.', - }, - 'project-name': { - command: 'capy', - why: 'The project name defaults to this directory\'s name, but creating a project is not something a run does without being asked.', - }, - branch: { - command: 'capy', - why: 'The first branch is what every later pin is written against, so it is not guessed.', - }, - 'branch-name': { - command: 'capy', - why: 'The first branch is what every later pin is written against, so it is not guessed.', - }, - encrypt: { - command: 'capy push', - why: 'Encrypting rewrites the .env in this directory as ciphertext. Nothing does that without being asked.', - }, -}; - -export function buildInitWizardData(v: InitWizardView, nonce: string): InitWizardData { - const data: InitWizardData = { - nonce, - step: v.step, - stops: initWizardPlan(v.input), - nonTty: NON_TTY[v.step], - }; - - if (v.orgs) data.orgs = v.orgs.map((o) => ({ ...o, name: stripAnsi(o.name) })); - if (v.projects) data.projects = v.projects.map((p) => ({ ...p, name: stripAnsi(p.name) })); - if (v.projectsUnavailable) data.projectsUnavailable = true; - if (v.localEnv) { - // Names, and a count. Never a value, and never a snippet of one: this is - // the payload of the page that asks whether these may be encrypted at all. - data.localEnv = { count: v.localEnv.count, names: v.localEnv.names.map(stripAnsi) }; - } - if (v.target) { - data.target = { - projectName: stripAnsi(v.target.projectName), - orgName: stripAnsi(v.target.orgName), - branch: stripAnsi(v.target.branch), - }; - } - if (v.value !== undefined) data.value = stripAnsi(v.value); - if (v.rejected !== undefined) data.rejected = v.rejected; - if (v.blocked) { - // `detail` is prose the CLI also prints, and printing is where the bold - // comes from. An escape that renders as `[1m` in a browser turns the one - // sentence explaining why a run stopped into gibberish. - data.blocked = { - ...v.blocked, - title: stripAnsi(v.blocked.title), - detail: stripAnsi(v.blocked.detail), - ...(v.blocked.remedy === undefined ? {} : { remedy: stripAnsi(v.blocked.remedy) }), - }; - } - if (v.blockedNames?.length) data.blockedNames = v.blockedNames.map(stripAnsi); - if (v.blockedFacts?.length) { - data.blockedFacts = v.blockedFacts.map((f) => ({ label: f.label, value: stripAnsi(f.value) })); - } - if (v.encryptFailure) { - data.encryptFailure = { ...v.encryptFailure, reason: stripAnsi(v.encryptFailure.reason) }; - } - return data; -} - -/** - * A stop the run could not get past, built from the error that stopped it. - * - * `code` is the CLI's own stable code and is the only thing anything may - * branch on. The message is carried as `detail` for a person to read and is - * never parsed — which is also why no remedy is invented here: several of - * these errors print a `capy redeem ` inside their prose, and digging it - * back out of a sentence is exactly the thing that breaks the next time the - * sentence is reworded. A call site that knows the remedy states it in fields - * with `willBlock` before it throws. - */ -export function blockedFromError(err: unknown): Blocked { - return { - code: err instanceof CapyError ? err.code : 'UNKNOWN', - title: 'This run stopped before it finished.', - detail: - err instanceof Error && err.message - ? err.message - : 'The CLI stopped without saying why. Its terminal output has the details.', - // Re-running the first run is the way out of every generic failure here: - // nothing this flow does before the last stop writes anything. - remedy: 'capy', - }; -} - -/** - * The CLI's own project-name validator, in the CLI's own words. - * - * Both sentences are `initializeProject`'s, copied so the browser refuses a - * name for the same reason and with the same sentence the terminal does. The - * screen holds its button on both, so either arriving over the wire means the - * submit did not come from the screen. - */ -export function projectNameProblem(name: string): string | undefined { - const trimmed = name.trim(); - if (trimmed.length === 0) return 'Project name cannot be empty'; - if (!/^[a-zA-Z0-9-_]+$/.test(trimmed)) { - return 'Project name can only contain letters, numbers, hyphens, and underscores'; - } - // `_system` is reserved for the org's system store (CAP-664) — see - // src/system/reservedProjectName.ts. Same refusal `initializeProject`'s - // terminal validator gives (promptEngine.ts), so the screen holds its - // button for the same reason the terminal would. - if (isReservedProjectName(trimmed)) return PROJECT_NAME_RESERVED_MESSAGE; - return undefined; -} - -/** A step's verdict: refuse inline, or take the answer. */ -type Verdict = { error: string } | { value: T }; - -export interface InitWizardOptions { - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - /** - * How long ONE outstanding question may go unanswered. Not a budget for the - * run: the clock stops while the CLI is working between two stops, which is - * where creating an organization and writing down 24 words happen. - */ - timeoutMs?: number; - /** How long a final page (blocked, or a failed push) waits to be collected. */ - finalGraceMs?: number; -} - -/** - * One browser window, held open across the whole first run. - * - * The CLI asks its questions in the order it always has — this is a channel, - * not a rewrite of the flow. `ask` renders the step and waits; the answer's - * POST is then HELD OPEN while the CLI does the work that step unlocked - * (refreshing a token, creating the project, creating the branch), and is - * released with the next screen when the CLI asks its next question. That is - * what makes the page truthful: it says "Working…" exactly while work is - * happening, and the step it reloads into is one the CLI has actually reached. - * - * A closed window answers nothing. Every `ask` resolves to `null` on cancel, - * and each call site decides what that means — for the consent gate it means - * NO, which is the same thing `confirmEncrypt = chosen === 'yes'` already - * meant. Nothing here ever turns silence into agreement. - */ -export class InitWizardSession { - private nonce = ''; - private wizard: Promise | null = null; - private pending: { - view: InitWizardView; - decide: (payload: Record) => Verdict; - resolve: (value: unknown) => void; - reject: (err: unknown) => void; - } | null = null; - /** Releases the POST the reducer is holding open, once there is a next step. */ - private handoff: ((d: WizardDecision) => void) | null = null; - private input: InitWizardInput = {}; - private ended = false; - /** True once the wizard promise has settled, however it settled. */ - private settled = false; - /** The stop the browser is on. What a blocked page redraws itself as. */ - private step: InitStep = 'organization'; - /** Declared by the call site that is about to throw. See `willBlock`. */ - private block: { step: InitStep; view: Omit } | null = null; - /** What the consent gate was asked ABOUT, for the page a failure redraws. */ - private encryptView: { localEnv: InitLocalEnv; target: InitTarget } | null = null; - - constructor(private opts: InitWizardOptions = {}) {} - - /** Fold a fact or an answer into the run, so the rail redraws from one place. */ - record(patch: Partial): void { - this.input = { ...this.input, ...patch }; - } - - /** - * State, in fields, why the run is about to stop. - * - * Called immediately before the `throw`, by the one caller that knows what - * the condition IS: which stop it belongs to, the code behind it, and the - * command that clears it. Without this the browser gets `abort`'s generic - * page, which can carry the error's code and its sentence and cannot invent - * a remedy out of prose — see `blockedFromError`. - */ - willBlock( - step: InitStep, - blocked: Blocked, - extra: { names?: string[]; facts?: { label: string; value: string }[] } = {}, - ): void { - this.block = { - step, - view: { blocked, blockedNames: extra.names, blockedFacts: extra.facts }, - }; - } - - private render(view: InitWizardView): string { - return renderScreen('init-wizard', buildInitWizardData({ ...view, input: this.input }, this.nonce)); - } - - private async ask( - view: Omit, - decide: (payload: Record) => Verdict, - ): Promise { - if (this.ended) { - // A code, not a bare Error: this reaches the same handler every other - // failure does, and "the window closed" is a thing callers may want to - // tell apart from a service that refused them. - throw new CapyError('The setup window has already closed.', ERROR_CODES.SERVICE_ERROR); - } - this.step = view.step; - const full: InitWizardView = { ...view, input: this.input }; - - const answer = new Promise((resolve, reject) => { - this.pending = { - view: full, - decide: decide as (p: Record) => Verdict, - resolve: resolve as (v: unknown) => void, - reject, - }; - }); - - if (!this.wizard) { - this.wizard = runBrowserWizard( - { - title: 'Set up this directory', - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: this.opts.open ?? true, - onListen: this.opts.onListen, - timeoutMs: this.opts.timeoutMs, - finalGraceMs: this.opts.finalGraceMs, - doneMessage: 'Set up — back to your terminal.', - renderFirst: (n) => { - this.nonce = n; - return this.render(this.pending!.view); - }, - }, - async (_step, payload) => this.onSubmit(payload), - ); - // A window that times out, or a Ctrl+C, must not leave the CLI waiting on - // an answer that can no longer arrive. - this.wizard.catch((err) => { - this.ended = true; - const p = this.pending; - this.pending = null; - p?.reject(err); - }); - // Whether the window is still there decides whether an ending has - // anywhere to be delivered — see `end`. - void this.wizard.then( - () => (this.settled = true), - () => (this.settled = true), - ); - } else { - // The reducer is holding the previous answer's POST open. Releasing it - // with this step is what makes the browser reload into it. - this.release({ screen: { html: this.render(full), standalone: true } }); - } - - return answer; - } - - private release(decision: WizardDecision): void { - const h = this.handoff; - this.handoff = null; - h?.(decision); - } - - private onSubmit(payload: Record): Promise | WizardDecision { - const p = this.pending; - if (!p) { - // Nothing is outstanding, so no step asked this. Refusing keeps a stray - // submit from answering a question the CLI never put. - return { error: 'There is nothing left to answer on this run.' }; - } - - if (payload.__action === 'cancel') { - this.pending = null; - this.ended = true; - p.resolve(null); - return { done: true, result: { cancelled: true } }; - } - - const verdict = p.decide(payload); - // An inline refusal keeps the user on the step with the reason on screen, - // rather than applying a guess: an answer the screen could not have - // produced did not come from the screen. - if ('error' in verdict) return verdict; - - this.pending = null; - p.resolve(verdict.value); - // Hold this POST until the CLI reaches its next question — or finishes. - return new Promise((resolve) => { - this.handoff = resolve; - }); - } - - // ------------------------------------------------------------------------- - // the six questions, in the order `initializeProject` asks them - // ------------------------------------------------------------------------- - - /** `Select organization for project:` — or the "create new" row. */ - async askOrganization(orgs: InitOrg[]): Promise { - return this.ask({ step: 'organization', orgs }, (payload) => { - if (payload.createOrganization === true) { - // The name is not known yet — `capy` asks for it in the org-creation - // flow that follows — so the fork is recorded and the name is not. - this.record({ organization: { kind: 'new' } }); - return { value: 'create' as const }; - } - const id = typeof payload.organizationId === 'string' ? payload.organizationId : ''; - // The page offers only what this session can reach, so anything else is a - // malformed submit — and picking the wrong organization decides which key - // this directory's secrets get encrypted to. - const org = orgs.find((o) => o.id === id); - if (!org) return { error: 'That organization is not one this session can reach.' }; - this.record({ organization: { kind: 'existing', name: org.name } }); - return { value: id }; - }); - } - - /** `Which project do you want to use?` — or the "New project" row. */ - async askProject(projects: InitProject[]): Promise { - return this.ask({ step: 'project', projects }, (payload) => { - if (payload.newProject === true) { - this.record({ project: { kind: 'new' } }); - return { value: 'new' as const }; - } - const id = typeof payload.projectId === 'string' ? payload.projectId : ''; - const project = projects.find((p) => p.id === id); - if (!project) return { error: 'That project is not in this organization.' }; - this.record({ project: { kind: 'existing', name: project.name } }); - return { value: id }; - }); - } - - /** `Project name (default: "…")` — the CLI's own validator, verbatim. */ - async askProjectName(defaultName: string): Promise { - return this.ask({ step: 'project-name', value: defaultName }, (payload) => { - const name = typeof payload.projectName === 'string' ? payload.projectName.trim() : ''; - const problem = projectNameProblem(name); - if (problem) return { error: problem }; - this.record({ project: { kind: 'new', name } }); - return { value: name }; - }); - } - - /** `What branch should this project start with?` */ - async askBranchChoice(): Promise<'development' | 'other' | null> { - return this.ask<'development' | 'other'>({ step: 'branch' }, (payload) => { - const choice = payload.branchChoice; - if (choice !== 'development' && choice !== 'other') { - return { error: 'That is not a branch this step offers.' }; - } - this.record({ branchChoice: choice }); - return { value: choice }; - }); - } - - /** - * `Branch name:` — the CLI's own validator, in the CLI's own words. - * - * `input.trim().length > 0`, which is all the terminal prompt this replaces - * checks. It used to borrow `capy checkout`'s stricter validator, which also - * refuses whitespace and a leading hyphen — and a channel that accepts a - * different set of names than the terminal is not a rendering change, it is - * a change of what the product does. (The screen holds its own button on a - * name with a space. That is a rendering difference and stays one: it is the - * page being more careful, not this flow being pickier.) - */ - async askBranchName(): Promise { - return this.ask({ step: 'branch-name' }, (payload) => { - const name = typeof payload.branchName === 'string' ? payload.branchName.trim() : ''; - if (name.length === 0) return { error: 'Branch name cannot be empty' }; - this.record({ branchName: name }); - return { value: name }; - }); - } - - /** - * The consent gate: `Encrypt these N secrets and push to () - * on ?` - * - * Answers `false` for a closed window, because that is what the terminal - * already does — `confirmEncrypt = chosen === 'yes'` — and because after - * this step the .env in the directory is ciphertext. Nothing about leaving - * may look like agreement to that. - */ - async askEncrypt(localEnv: InitLocalEnv, target: InitTarget): Promise { - // Kept for the failure page, which redraws this step: its checklist says - // how many secrets reached Keep, and a page rebuilt without the count - // would report a successful push of zero of them. - this.encryptView = { localEnv, target }; - const answer = await this.ask({ step: 'encrypt', localEnv, target }, (payload) => { - if (typeof payload.encrypt !== 'boolean') { - return { error: 'That is not an answer the encrypt step can produce.' }; - } - this.record({ encrypt: payload.encrypt }); - return { value: payload.encrypt }; - }); - return answer === true; - } - - /** Nothing more will be asked: release the browser and let the CLI finish. */ - async finish(): Promise { - if (!this.wizard) return; - if (this.ended) { - // Already over — cancelled, blocked, or a window that closed. `finish` - // runs on the way out of every successful path, and a run that ended - // badly still comes back through here. - await this.wizard.catch(() => undefined); - return; - } - this.ended = true; - this.release({ done: true, result: { cancelled: false } }); - await this.wizard.catch(() => undefined); - } - - /** - * The run stopped between two questions. - * - * The page is holding a submit at this moment, and the compiled screen draws - * its ending from the control that was pressed — so `{ done }` here would - * print "Done. You can close this tab." over a run that just died. It gets - * the reason instead: the same rail, with the question replaced by what - * stopped it, the code behind it, and (when a call site declared one with - * `willBlock`) the command that clears it. - * - * The error is still the terminal's to report. This decides only what the - * browser is left looking at. - */ - async abort(err?: unknown): Promise { - if (!this.wizard) return; - const declared = this.block; - await this.end( - declared?.step ?? this.step, - declared?.view ?? { blocked: blockedFromError(err) }, - ); - } - - /** - * Consent was given, and the push that followed failed. - * - * The one failure that happens AFTER the last question, which is why it is - * not an `abort`: by this point the answer was yes, some of it may have - * happened, and what is on disk right now is the only thing worth saying. - * The CLI states that in fields — `pushed`, `backupWritten`, `envRewritten`, - * and the code — and the screen draws them as the checklist it already has. - * - * The consent stop goes back to being the stop this run is STANDING at: it - * was answered and it did not complete, and a rail that ticks it off would - * be claiming the thing that failed is done. - */ - async reportEncryptFailure(failure: InitEncryptFailure): Promise { - if (!this.wizard) return; - this.record({ encrypt: undefined }); - await this.end('encrypt', { ...(this.encryptView ?? {}), encryptFailure: failure }); - } - - /** - * Serve one last page and stop. - * - * A held POST is what an ending is delivered THROUGH: the page reloads out - * of it. With nothing held there is nowhere to put one, and there is nothing - * to say either — the flow settled on its own (cancelled, timed out, Ctrl+C) - * and the page drew that itself, or no question was ever asked. The CLI is - * on its way out with an error to print, so that case returns rather than - * waiting on a window that has already answered. - */ - private async end(step: InitStep, view: Omit): Promise { - const wizard = this.wizard; - if (!wizard) return; - const holding = this.handoff !== null; - this.ended = true; - this.pending = null; - if (!holding) { - if (this.settled) await wizard.catch(() => undefined); - return; - } - this.release({ - screen: { html: this.render({ ...view, step, input: this.input }), standalone: true, final: true }, - result: { cancelled: true }, - }); - await wizard.catch(() => undefined); - } -} diff --git a/src/ui/inlineProjectPicker.ts b/src/ui/inlineProjectPicker.ts new file mode 100644 index 00000000..cb7f4a8c --- /dev/null +++ b/src/ui/inlineProjectPicker.ts @@ -0,0 +1,90 @@ +/** + * Key handling for the in-TUI multi-select project picker that `capy users` + * opens when a role is scoped to projects (see `InteractiveTable`). + * + * Pure: `stepProjectPicker` takes the picker state and one raw key chunk and + * returns what to do next, without touching the terminal or the state it was + * given. + * + * Bindings (all pre-existing except the filter): + * Up / Down move the cursor (wraps) + * Space tick / untick the highlighted project + * Enter confirm — resolves with every ticked project, visible or not + * Esc cancel — but while a filter is typed, Esc clears the filter + * first (same as `capy secrets`), and only a second Esc cancels + * (new) any other printable text types into the filter; Backspace edits it + * + * The picker has no letter hotkeys of its own (it absorbs every key while + * open, and the table's `q`/`r`/`g` do nothing here), so — like the + * `capy secrets` search bar — typing filters directly with no mode switch. + * Space is the tick key, so a filter cannot contain a space; type a fragment + * of the name instead. + * + * Matching is the shared rule in `searchMatch` (case-insensitive substring). + * Ticks are kept by project id, so a project hidden by the filter stays + * ticked. + */ +import { filterByQuery } from './searchMatch'; + +const ESC = '\x1b'; + +export interface PickerProject { + readonly id: string; + readonly name: string; +} + +export interface ProjectPickerState { + readonly projects: ReadonlyArray; + /** Index into the VISIBLE (filtered) projects. */ + readonly cursor: number; + readonly selected: ReadonlySet; + /** Absent in states built before the filter existed — treated as ''. */ + readonly query?: string; +} + +export type ProjectPickerStep = + | { readonly kind: 'state'; readonly state: S } + | { readonly kind: 'cancel' } + | { readonly kind: 'confirm'; readonly chosen: readonly string[] }; + +/** The projects the filter lets through, in list order. */ +export function visibleProjects(state: ProjectPickerState): ReadonlyArray { + return filterByQuery(state.projects, state.query ?? '', (p) => p.name); +} + +/** Printable text with no control characters (so no Esc sequences, Enter or Tab). */ +const PLAIN_TEXT = /^[^\x00-\x1f\x7f]+$/; + +function withQuery(state: S, query: string): S { + return { ...state, query, cursor: 0 }; +} + +function move(state: S, offset: number): S { + const count = visibleProjects(state).length; + return count === 0 ? state : { ...state, cursor: (state.cursor + offset + count) % count }; +} + +function toggleCurrent(state: S): S { + const current = visibleProjects(state)[state.cursor]; + if (!current) return state; + const selected = state.selected.has(current.id) + ? new Set(Array.from(state.selected).filter((id) => id !== current.id)) + : new Set([...state.selected, current.id]); + return { ...state, selected }; +} + +export function stepProjectPicker(state: S, key: string): ProjectPickerStep { + const query = state.query ?? ''; + if (key === `${ESC}[A`) return { kind: 'state', state: move(state, -1) }; + if (key === `${ESC}[B`) return { kind: 'state', state: move(state, 1) }; + if (key === ' ') return { kind: 'state', state: toggleCurrent(state) }; + if (key === ESC || key === `${ESC}${ESC}`) { + return query !== '' ? { kind: 'state', state: withQuery(state, '') } : { kind: 'cancel' }; + } + if (key === '\r' || key === '\n') return { kind: 'confirm', chosen: Array.from(state.selected) }; + if (key === '\x7f' || key === '\b') { + return { kind: 'state', state: query === '' ? state : withQuery(state, query.slice(0, -1)) }; + } + if (PLAIN_TEXT.test(key)) return { kind: 'state', state: withQuery(state, query + key) }; + return { kind: 'state', state }; +} diff --git a/src/ui/interactiveTable.ts b/src/ui/interactiveTable.ts index 97bfd122..3b6f7d97 100644 --- a/src/ui/interactiveTable.ts +++ b/src/ui/interactiveTable.ts @@ -1,4 +1,6 @@ import { MemberDetail, MemberProject, MemberProjectBranch } from '../service/serviceClient'; +import { ACCENT } from './colors'; +import { stepProjectPicker, visibleProjects, type ProjectPickerState } from './inlineProjectPicker'; // ANSI escape codes const ESC = '\x1b'; @@ -33,6 +35,16 @@ const ASSIGNABLE_BY_CALLER: Record> = { 'project-admin': ['project-admin', 'member'], }; +/** Runs `task`; resolves to its error message, or null when it succeeded. */ +async function errorMessageOf(task: () => Promise): Promise { + try { + await task(); + return null; + } catch (err: any) { + return err.message || String(err); + } +} + export interface ProjectChoice { id: string; name: string; @@ -105,13 +117,11 @@ export class InteractiveTable { private editingProjectRoleIndex = 0; private statusMessage: { text: string; isError: boolean } | null = null; // In-TUI multi-select project picker (Flow B). - private projectPicker: { - projects: ProjectChoice[]; - cursor: number; - selected: Set; - resolve: (ids: string[] | null) => void; - prompt: string; - } | null = null; + // Replaced wholesale on every key (see `inlineProjectPicker`), never edited in place. + private projectPicker: (ProjectPickerState & { + readonly resolve: (ids: string[] | null) => void; + readonly prompt: string; + }) | null = null; computeColumnWidths(termWidth: number): { widths: number[]; showAdded: boolean } { const available = Math.min(termWidth, 130) - MARGIN * 2; @@ -447,16 +457,16 @@ export class InteractiveTable { } if (this.projectPicker) { - output.push(`${m} ${this.projectPicker.prompt}`); - output.push(`${m} ${DIM}↑↓ navigate ${RESET}${BOLD_WHITE}Space${RESET}${DIM} select ${RESET}${BOLD_WHITE}Enter${RESET}${DIM} confirm ${RESET}${BOLD_WHITE}Esc${RESET}${DIM} cancel${RESET}`); - for (let i = 0; i < this.projectPicker.projects.length; i++) { - const p = this.projectPicker.projects[i]; - const atCursor = i === this.projectPicker.cursor; - const checked = this.projectPicker.selected.has(p.id); - const mark = checked ? '[x]' : '[ ]'; + const picker = this.projectPicker; + const query = picker.query ?? ''; + // COPY-FLAG: `search:` and `type to filter` are the wording `capy secrets` search already uses. + output.push(`${m} ${picker.prompt}${query === '' ? '' : ` ${DIM}search:${RESET} ${ACCENT}${query}${RESET}`}`); + output.push(`${m} ${DIM}↑↓ navigate ${RESET}${BOLD_WHITE}Space${RESET}${DIM} select ${RESET}${BOLD_WHITE}Enter${RESET}${DIM} confirm ${RESET}${BOLD_WHITE}Esc${RESET}${DIM} cancel type to filter${RESET}`); + visibleProjects(picker).forEach((p, i) => { + const mark = picker.selected.has(p.id) ? '[x]' : '[ ]'; const line = `${m} ${mark} ${p.name}`; - output.push(atCursor ? `${INVERSE}${this.padToWidth(line, totalWidth)}${RESET}` : line); - } + output.push(i === picker.cursor ? `${INVERSE}${this.padToWidth(line, totalWidth)}${RESET}` : line); + }); } return output.map(line => line + CLEAR_EOL).join('\n'); @@ -720,42 +730,26 @@ export class InteractiveTable { private handleProjectPickerKey(key: string): void { if (!this.projectPicker) return; - const { projects } = this.projectPicker; - if (key === `${ESC}[A`) { - this.projectPicker.cursor = (this.projectPicker.cursor - 1 + projects.length) % projects.length; - this.draw(); - return; - } - if (key === `${ESC}[B`) { - this.projectPicker.cursor = (this.projectPicker.cursor + 1) % projects.length; - this.draw(); - return; - } - if (key === ' ') { - const current = projects[this.projectPicker.cursor]; - if (current) { - if (this.projectPicker.selected.has(current.id)) { - this.projectPicker.selected.delete(current.id); - } else { - this.projectPicker.selected.add(current.id); - } - } - this.draw(); - return; - } - if (key === ESC || key === `${ESC}\x1b`) { - const { resolve } = this.projectPicker; + const { resolve } = this.projectPicker; + const step = stepProjectPicker(this.projectPicker, key); + if (step.kind === 'state') { + this.projectPicker = step.state; + } else { this.projectPicker = null; - resolve(null); - this.draw(); - return; + resolve(step.kind === 'cancel' ? null : Array.from(step.chosen)); } - if (key === '\r' || key === '\n') { - const { resolve, selected } = this.projectPicker; - const chosen = Array.from(selected); - this.projectPicker = null; - resolve(chosen); - this.draw(); + this.draw(); + } + + /** `pickProjectsInline`, with a refusal (e.g. no projects) returned as `{ error }` instead of thrown. */ + private async tryPickProjects( + prompt: string, + initialSelected: Set, + ): Promise<{ readonly chosen: string[] | null } | { readonly error: string }> { + try { + return { chosen: await this.pickProjectsInline(prompt, initialSelected) }; + } catch (err: any) { + return { error: err.message || String(err) }; } } @@ -773,6 +767,7 @@ export class InteractiveTable { projects, cursor: 0, selected: new Set(initialSelected), + query: '', resolve, prompt, }; @@ -818,17 +813,13 @@ export class InteractiveTable { .map((p) => p.id), ); - let chosen: string[] | null; - try { - chosen = await this.pickProjectsInline( - `Assign ${newRole} on which projects?`, - existingAtTarget, - ); - } catch (err: any) { - this.statusMessage = { text: `Error: ${err.message || err}`, isError: true }; + const picked = await this.tryPickProjects(`Assign ${newRole} on which projects?`, existingAtTarget); + if ('error' in picked) { + this.statusMessage = { text: `Error: ${picked.error}`, isError: true }; this.draw(); return; } + const { chosen } = picked; if (!chosen) { this.statusMessage = { text: 'Cancelled', isError: false }; this.draw(); @@ -850,40 +841,28 @@ export class InteractiveTable { // at least once to flip the WorkOS role. Pair it with the first add if we // have one; otherwise drop through and just call changeRole with the first // remaining project (if any). - const errors: string[] = []; - let successCount = 0; const workosRoleNeedsFlip = member.role !== newRole; - - try { - if (workosRoleNeedsFlip) { - const firstProject = toAdd.shift() ?? Array.from(chosenSet)[0]; - if (!firstProject) { - throw new Error('Pick at least one project'); - } - await this.ctx.changeRole(member.userId, newRole, firstProject); - successCount++; - } - } catch (err: any) { - errors.push(err.message || String(err)); - } - - for (const pid of toAdd) { - try { - await this.ctx.assignProjectRole(pid, member.email, newRole); - successCount++; - } catch (err: any) { - errors.push(err.message || String(err)); - } - } - - for (const pid of toRemove) { - try { - await this.ctx.removeProjectRole(pid, member.userId); - successCount++; - } catch (err: any) { - errors.push(err.message || String(err)); - } - } + const ctx = this.ctx; + const flipProject = toAdd[0] ?? Array.from(chosenSet)[0]; + const tasks: ReadonlyArray<() => Promise> = [ + ...(workosRoleNeedsFlip + ? [async () => { + if (!flipProject) throw new Error('Pick at least one project'); + await ctx.changeRole(member.userId, newRole, flipProject); + }] + : []), + ...(workosRoleNeedsFlip ? toAdd.slice(1) : toAdd).map( + (pid) => () => ctx.assignProjectRole(pid, member.email, newRole), + ), + ...toRemove.map((pid) => () => ctx.removeProjectRole(pid, member.userId)), + ]; + // One at a time, in order; each task yields its error message or null. + const outcomes = await tasks.reduce( + async (soFar, task) => [...(await soFar), await errorMessageOf(task)], + Promise.resolve([] as ReadonlyArray), + ); + const errors = outcomes.flatMap((o) => (o === null ? [] : [o])); + const successCount = outcomes.length - errors.length; await this.refreshAfterMutation(member.userId); diff --git a/src/ui/memberScreens.ts b/src/ui/memberScreens.ts deleted file mode 100644 index 6538b27d..00000000 --- a/src/ui/memberScreens.ts +++ /dev/null @@ -1,630 +0,0 @@ -// `capy invite` and `capy kick`, served as compiled screens. -// -// Three prompts between them, and one of the three is the most expensive -// question the CLI asks: -// -// 1. `capy invite ` asks `Select a role for :` — a three-item -// list whose entries are `Member` / `Project Admin` / `Admin`, display -// labels that cannot be pasted into the `--role` flag they belong to and -// that say nothing about what each one reaches. Under `--web` it is the -// `invite-teammate` screen's role step, which shows the slug the flag -// takes and one line of what it can do beside each. -// 2. `capy invite ` then asks `Grant access to which -// projects?` — a checkbox list with the working directory's project -// pre-ticked and no indication that that is why it is ticked. Under -// `--web` the row says where the tick came from. -// 3. `capy kick ` asks one confirm, defaulting to No, with the entire -// consequence compressed into the question itself. Under `--web` it is -// `org-members`' `confirm-remove` view: what they lose and what removal -// does NOT reach are two callouts ABOVE the button, and the button holds -// until the address is typed back. A destructive action's consequence -// never goes inside the control that performs it. -// -// The route is `invitePlan`'s array, not this file's and not the screen's. One -// builder feeds both `--json` and the payload, which is the only way the rail a -// person reads and the array an agent parses can be claimed to be the same. -// -// KEY MATERIAL. A redeem code carries a double-wrapped copy of the organization -// key: whoever holds it can join the org until it expires, exactly the way a -// recovery phrase can. So it travels CLI → page and never back. It is inlined -// into a display-only screen — `SCREEN_CSP` gives that page `connect-src -// 'none'`, so the document that renders it has no socket to send it down — it -// is never a wizard answer, and under `--web` it is never printed, because an -// agent shelling `capy` reads stdout. -import { runBrowserWizard } from './browserWizard'; -import { withDeclineBridge, type DeclineBridge } from './declineBridge'; -import { renderScreen, ScreenServer } from './screens/serve'; -import { - invitePlan, - unansweredInviteStops, - roleNeedsProjects, - parseTtl, - formatRelativeFuture, - type InvitePlanInput, -} from '../core/invitePlan'; -import type { - ExistingMember, - ExpiryPreset, - GrantableRole, - InviteProject, - InviteStop, - InviteTeammateData, - IssuedInvite, - OrgMember, - OrgMembersData, - OrgRole, - ProjectRef, -} from './screens/contract'; - -/** - * Strip terminal colour codes on the way into a payload. - * - * Applied to everything the CLI also PRINTS — the organization name it puts in - * bold, a service error it echoes back — because a payload is not a terminal - * and an escape renders as a literal `[90m` in the browser. - * - * Deliberately NOT applied to an email or a project id: those are identifiers - * the answer comes back as and the command then acts on, so they have to - * round-trip byte for byte. They are resolved against the list the server sent - * instead, which no rewriting can defeat. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -// --------------------------------------------------------------------------- -// invite-teammate -// --------------------------------------------------------------------------- - -/** - * What each role reaches, in one line. - * - * The CLI has no copy for this at all — its picker offers three display labels - * and nothing else — so rather than invent a fourth vocabulary these are the - * lines the `org-members` screen already uses for the same three roles. One - * product, one description of what `admin` means. - */ -const ROLE_NOTES: Record = { - admin: 'Every project, every branch', - 'project-admin': 'Runs the projects you pick', - member: 'Named projects, and protected branches you grant', -}; - -/** - * The lifetimes offered without typing. - * - * Increments up to the 12-hour ceiling. Nothing longer is offerable, because - * nothing longer can be issued. - */ -const TTL_PRESETS = ['30m', '1h', '2h', '4h', '8h', '12h'] as const; - -/** The ceiling, in hours. Nothing longer can be requested or issued. */ -const MAX_TTL_HOURS = 12; - -export interface WebInviteParams { - /** - * The address the redeem code is BOUND to. - * - * `innerWrap` lowercases into the HKDF salt (`${orgId}:${email.toLowerCase()}`), - * so the lowercased address is the one that decides whether the code can ever - * be redeemed — and it is therefore the one the page names. It does NOT trim, - * and neither does this: a page that showed a trimmed address would be - * claiming a binding the code does not have. - */ - email: string; - /** Argv as typed, when normalising changed it. */ - rawEmail?: string; - orgName: string; - callerEmail: string; - callerRole: string; - /** Already filtered by what this caller may grant. */ - grantableRoles: string[]; - /** Every project in the organization, cwd first, as the CLI orders them. */ - projects: Array<{ id: string; name: string; isCwd: boolean }>; - /** The membership this address already has, when it has one. */ - existing?: { - role: string; - status: string; - projects: ProjectRef[]; - }; - /** The plan input, so the rail is `invitePlan`'s and not a second one. */ - plan: InvitePlanInput; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; - /** Test hook only: fixes `now` so every computed expiry is deterministic. */ - now?: Date; -} - -/** What the browser answered. `cancelled` means no invite may be minted. */ -export interface WebInviteResult { - role: string; - /** Ids, in the order the screen returned them. Empty for an org-wide role. */ - projectIds: string[]; - /** The chosen lifetime in `--ttl`'s vocabulary, when the browser asked. */ - ttl?: string; - cancelled: boolean; -} - -/** Answers folded forward as the wizard advances. */ -export interface InviteAnswers { - role?: string; - projectIds?: string[]; - ttl?: string; -} - -/** - * How each question is answered without a browser. - * - * Two escapes rather than one, because the last stop has no safe headless form - * and pretending otherwise would be the dangerous kind of helpful. - */ -function nonTtyEscapes(email: string): InviteTeammateData['nonTty'] { - return { - questions: { - command: `capy invite ${email} --role member --project --ttl 12h`, - why: 'Off a TTY every question falls back rather than asking: the role becomes member, and the projects become whichever one you happen to be standing in. An invite scoped to the wrong project is a key handed to the wrong person, so name both.', - }, - reveal: { - command: `capy invite ${email} --json`, - why: 'The code carries a double-wrapped copy of the organization key. That flag puts it on stdout, where your shell history, your scrollback and anything piping this command all keep a copy. This page is why it does not have to.', - }, - }; -} - -/** The lifetimes, resolved to the absolute instants the CLI would bind them to. */ -function expiryPresets(now: number): ExpiryPreset[] { - return TTL_PRESETS.map((ttl) => { - const at = now + parseTtl(ttl)!; - return { ttl, expiresAtIso: new Date(at).toISOString(), relative: formatRelativeFuture(at, now) }; - }); -} - -/** - * The instant a settled expiry names. - * - * A settled expiry is either `--ttl`'s grammar (a duration from now) or - * `--expires`' (an absolute date), and the plan carries whichever the caller - * typed. Resolving both here keeps the page from having to know which flag it - * came from — and `resolveNotAfter` reads them in the same order. - */ -function expiryAt(value: string, now: number): number { - const ttl = parseTtl(value); - if (ttl !== null) return now + ttl; - const absolute = Date.parse(value); - return Number.isNaN(absolute) ? now : absolute; -} - -const expiryIso = (value: string, now: number): string => new Date(expiryAt(value, now)).toISOString(); -const expiryRelative = (value: string, now: number): string => formatRelativeFuture(expiryAt(value, now), now); - -/** Which question this run is standing on, or null when nothing is left to ask. */ -function currentStep(p: WebInviteParams, answered: InviteAnswers): InviteStop | null { - const [next] = unansweredInviteStops(planFor(p, answered)); - if (next === 'role') return 'role'; - if (next === 'projects') return 'projects'; - if (next === 'expiry') return 'expiry'; - return null; -} - -/** - * The plan, folded forward with whatever the browser has answered so far. - * - * Recomputed from one builder on every render rather than mutated in place, so - * the rail redraws itself and this file never decides what a stop's state is. - * - * `minted` closes the route. Once the code exists nothing is outstanding, and a - * lifetime nobody was asked about was decided by `resolveNotAfter` — so the - * stop is `done`, marked with the source that settled it, rather than `current` - * on a run that has already finished. That is the difference between a rail and - * a decoration: every state on it describes something the run did. - */ -function planFor(p: WebInviteParams, answered: InviteAnswers, minted = false): ReturnType { - const names = (answered.projectIds ?? []) - .map((id) => p.projects.find((x) => x.id === id)?.name) - .filter((n): n is string => !!n); - - // An answer the browser gave carries no `flag`: nobody has to be told why - // they were not asked about a question they just answered. Only what argv or - // an existing membership settled arrives already marked, and that marking is - // `p.plan`'s, made before this browser opened. - return invitePlan({ - ...p.plan, - canAskExpiry: p.plan.canAskExpiry && !minted, - role: p.plan.role ?? (answered.role ? { value: answered.role } : undefined), - projects: names.length > 0 ? { names } : p.plan.projects, - expiry: p.plan.expiry ?? (answered.ttl ? { value: answered.ttl } : undefined), - }); -} - -export function buildInviteData( - p: WebInviteParams, - nonce: string, - answered: InviteAnswers = {}, - issued?: IssuedInvite, -): InviteTeammateData { - const now = (p.now ?? new Date()).getTime(); - - const grantableRoles: GrantableRole[] = p.grantableRoles.map((value) => ({ - value, - note: ROLE_NOTES[value] ?? '', - needsProjects: roleNeedsProjects(value), - })); - - const projects: InviteProject[] = p.projects.map((x) => ({ - id: x.id, - name: stripAnsi(x.name), - isCwd: x.isCwd, - })); - - const existing: ExistingMember | undefined = p.existing - ? { - role: p.existing.role, - projects: p.existing.projects.map((x) => ({ id: x.id, name: stripAnsi(x.name) })), - // The service's own word, not a re-spelling: the CLI fetches this field - // and renders it nowhere, which is why re-inviting someone still - // holding an unredeemed code looks like a first invite. - status: p.existing.status, - } - : undefined; - - const stops = planFor(p, answered, !!issued); - const step = issued ? 'code' : (currentStep(p, answered) ?? 'code'); - - // The code page carries two things the CLI also PRINTS — the names of the - // projects this invite granted, and the service's own message for each one it - // could not — and a payload is not a terminal. `\x1b[90m` renders in a - // browser as the literal `[90m`, so the same stripping the org name gets - // applies here rather than only on the way to stdout. - const cleaned: IssuedInvite | undefined = issued - ? { - ...issued, - grantedProjects: issued.grantedProjects.map((x) => ({ id: x.id, name: stripAnsi(x.name) })), - assignmentFailures: issued.assignmentFailures.map((f) => ({ - project: { id: f.project.id, name: stripAnsi(f.project.name) }, - error: stripAnsi(f.error), - })), - } - : undefined; - - return { - nonce, - inviteeEmail: p.email, - ...(p.rawEmail && p.rawEmail !== p.email ? { rawInviteeEmail: p.rawEmail } : {}), - orgName: stripAnsi(p.orgName), - callerEmail: p.callerEmail, - callerRole: p.callerRole, - grantableRoles, - // The CLI's own `default: 'member'`, carried rather than restated. - defaultRole: 'member', - projects, - ...(existing ? { existing } : {}), - expiry: { - presets: expiryPresets(now), - defaultTtl: p.plan.defaultTtl, - ...(p.plan.envTtl ? { envOverrideTtl: p.plan.envTtl } : {}), - maxTtlHours: MAX_TTL_HOURS, - }, - // What each control opens on. Only answers this run actually holds — never - // a default dressed up as one, which is how a rail ends up reporting `Role - // member` for a run nobody was asked about. - resolved: { - ...(p.plan.role?.value ?? answered.role - ? { role: p.plan.role?.value ?? answered.role } - : {}), - ...(answered.projectIds - ? { projectIds: answered.projectIds } - : p.existing && p.existing.projects.length > 0 - ? { projectIds: p.existing.projects.map((x) => x.id) } - : {}), - ...(p.plan.expiry - ? { - expiry: { - ttl: p.plan.expiry.value, - expiresAtIso: expiryIso(p.plan.expiry.value, now), - relative: expiryRelative(p.plan.expiry.value, now), - }, - } - : {}), - }, - ...(cleaned ? { issued: cleaned } : {}), - stops, - step, - nonTty: nonTtyEscapes(p.email), - }; -} - -/** - * Ask whatever the plan left outstanding, in the browser. - * - * Only the QUESTION stops are served here. The code stop is a whole page the - * CLI cannot ask anything on — see `serveInviteCode` — and it does not exist - * yet at this point in the run anyway: the lifetime chosen on the last stop is - * bound into the KMS wrap, so the code cannot be minted until this resolves. - */ -export async function askInviteInBrowser(p: WebInviteParams): Promise { - // The nonce is minted inside `runBrowserWizard` and reaches a caller only - // through `renderFirst`. A second standalone step has to be rendered with - // that same token, so it is captured on the way past rather than each flow - // minting its own — which would put the security token of every browser path - // in the hands of each path instead of in one place. - let nonce = ''; - let answered: InviteAnswers = {}; - const render = (): string => renderScreen('invite-teammate', buildInviteData(p, nonce, answered)); - - const out = await runBrowserWizard( - { - title: `Invite ${p.email} — ${p.orgName}`, - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Answered — minting the invite.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { role: '', projectIds: [], cancelled: true } }; - } - - const step = currentStep(p, answered); - - if (step === 'role') { - const role = typeof payload.role === 'string' ? payload.role : ''; - if (!p.grantableRoles.includes(role)) { - // The screen offers only what this caller may grant, so anything else - // is a malformed submit rather than a user mistake. The wording is - // the CLI's own refusal, which says the same thing at the same point. - return { - error: `Your role (${p.callerRole}) can't grant "${role}". Allowed: ${p.grantableRoles.join(', ')}.`, - }; - } - answered = { ...answered, role }; - } else if (step === 'projects') { - const raw = Array.isArray(payload.projectIds) ? payload.projectIds : null; - if (!raw) return { error: 'That is not an answer the projects step can produce.' }; - const ids = raw.filter((id): id is string => typeof id === 'string' && p.projects.some((x) => x.id === id)); - if (ids.length !== raw.length) { - // A project id the screen never offered did not come from the screen, - // and granting access to a project nobody named is the failure this - // refusal exists to prevent. - return { error: 'That project is not in this organization.' }; - } - if (ids.length === 0) { - // The screen holds its button until at least one is ticked, and the - // CLI's own checkbox validator says the same sentence. - return { error: 'Pick at least one project' }; - } - answered = { ...answered, projectIds: ids }; - } else if (step === 'expiry') { - const ttl = typeof payload.ttl === 'string' ? payload.ttl : ''; - if (parseTtl(ttl) === null) { - // `--ttl`'s own refusal, verbatim, so the flag and the page reject an - // unparseable lifetime with one sentence rather than two. - return { error: `Invalid --ttl "${ttl}". Use e.g. 30m, 2h, 12h, or a number of seconds (max 12h).` }; - } - answered = { ...answered, ttl }; - } else { - // Nothing was outstanding, so no screen asked this. Refusing keeps a - // stray submit from minting a key nobody was asked about. - return { error: 'There is nothing left to answer on this run.' }; - } - - if (currentStep(p, answered) === null) { - return { - done: true, - result: { - role: (p.plan.role?.value ?? answered.role)!, - projectIds: answered.projectIds ?? [], - ttl: answered.ttl, - cancelled: false, - }, - }; - } - // A whole document cannot be spliced into the open page, so it is handed - // back as `standalone` and the browser reloads to receive it. - return { screen: { html: render(), standalone: true } }; - }, - ); - return out as WebInviteResult; -} - -/** - * Show the minted code, on a page that cannot send it anywhere. - * - * A second server, not a fourth wizard step, and the screen is the reason: the - * `code` view of `invite-teammate` is a Page, not a Wizard — it has no form, no - * submit and no cancel, because a code is something you are handed rather than - * something you answer. A wizard step that can never be answered is a CLI that - * never returns. - * - * `ScreenServer` is the right vehicle for exactly that: single-use tokenised - * URL, 127.0.0.1 only, closes itself the moment it has served, and the strict - * `SCREEN_CSP` — `connect-src 'none'` — so the one document in this flow that - * holds key material is also the one document with no way to open a socket. - */ -export async function serveInviteCode( - p: WebInviteParams, - issued: IssuedInvite, - answered: InviteAnswers, - opts: { open?: boolean; timeoutMs?: number } = {}, -): Promise<{ url: string; close: () => void }> { - const server = new ScreenServer( - 'invite-teammate', - // The nonce is inert here: the page has nothing to post it back with. It is - // still filled rather than left blank, because a payload field that is - // sometimes a token and sometimes an empty string is a field nobody can - // reason about at the point it matters. - buildInviteData(p, 'display-only', answered, issued), - { timeoutMs: opts.timeoutMs ?? 120_000 }, - ); - const url = await server.start(); - if (opts.open) { - const { openScreen } = await import('./openScreen'); - const { SCREEN_WIDE } = await import('./screens/generated'); - await openScreen(url, { kind: 'dialog', wide: SCREEN_WIDE['invite-teammate'] }); - } - return { url, close: () => server.close() }; -} - -// --------------------------------------------------------------------------- -// org-members · confirm-remove -// --------------------------------------------------------------------------- - -export interface WebKickParams { - orgName: string; - /** The signed-in caller, so removing yourself is visible as that. */ - callerRole: string; - currentUserId: string; - /** The one membership this command is about. */ - member: { - membershipId: string; - userId: string; - email: string; - role: string; - status: string; - createdAt?: string; - projects: Array<{ id: string; name: string; role?: 'project-admin' | 'member' }>; - }; - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** Roles that hold every branch of every project. */ -const ALL_ACCESS_ROLES: ReadonlySet = new Set(['owner', 'admin', 'org-admin']); - -export function buildKickData(p: WebKickParams, nonce: string): OrgMembersData { - const m = p.member; - - const member: OrgMember = { - membershipId: m.membershipId, - userId: m.userId, - email: m.email, - role: m.role as OrgRole, - // The service says `active` / `invited`; the screen's vocabulary is - // `active` / `pending`. Mapped here rather than passed through, because - // an unrecognised status renders as no badge at all — which is exactly how - // an unredeemed invite becomes invisible in the terminal today. - status: m.status === 'active' ? 'active' : 'pending', - createdAt: m.createdAt ?? null, - hasAllAccess: ALL_ACCESS_ROLES.has(m.role), - projects: m.projects.map((x) => ({ - id: x.id, - name: stripAnsi(x.name), - role: x.role ?? null, - // `capy kick` never reads branch grants — it deletes the membership, and - // every grant under it goes with it. An empty list is the honest answer; - // a fabricated one would draw toggles this command cannot honour. - branches: [], - })), - }; - - return { - nonce, - orgName: stripAnsi(p.orgName), - callerRole: p.callerRole as OrgRole, - currentUserId: p.currentUserId, - // `capy kick` has exactly one verb. It grants no role, so the role menu on - // this screen has nothing to offer and the list view is a roster to read. - assignableRoles: [], - allProjects: [], - members: [member], - view: 'confirm-remove', - subjectUserId: m.userId, - nonTty: { - command: 'capy users --json', - why: 'Reading the roster changes nothing, so it needs no browser and no confirmation.', - }, - removeNonTty: { - command: `capy kick ${m.email}`, - why: 'There is no flag that says yes. Removal is confirmed by typing the address back — here, or at the terminal prompt — so a run with nowhere to ask leaves the membership alone rather than assuming.', - }, - }; -} - -/** - * The "no" this screen has no way to send. - * - * `confirm-remove` offers two controls. The destructive one POSTs. The decline - * — `Keep them`, which is the answer the terminal DEFAULTS to — is CLIENT-SIDE - * ONLY: it clears the field and flips the view back to the roster, and the CLI - * is never told. Driven in a real browser, the run is still pending 1.5s after - * that click and ends on the wizard's five-minute timeout, on a page that has - * nothing left to answer with. A flow whose most common ending cannot be - * reached is not a flow, and "the user waited five minutes" is not a refusal - * anyone should have to perform. - * - * The real fix is the screen's and is reported as such: `confirm-remove`'s - * decline should POST `{__action:'cancel'}`, exactly the way the same package's - * `Wizard` cancel already does — which is why `capy invite`'s Cancel needs - * nothing here. - * - * The bridge itself is `src/ui/declineBridge.ts`: the same defect turned up on - * `deploy-targets`' `confirm-remove` and `deploy-tokens`' `confirm-revoke`, and - * three copies of a script that answers for the user would be three ways for a - * page to say no. What it watches here is `button.danger` — the only control on - * this view that can answer the question, by its design-system variant and - * never by its label. - */ -const kickDeclineBridge = (nonce: string, stillAMember: string): DeclineBridge => ({ - nonce, - question: 'button.danger', - headline: 'Cancelled — nothing was changed.', - detail: stillAMember, -}); - -/** - * Serve the removal confirm and wait for it. - * - * Returns false for every ending that is not an explicit confirmation: - * cancelled, declined, closed, timed out, interrupted. That is not defensive - * coding — a step nobody answered has not been approved, and the one thing this - * flow must never do is read a closed window as agreement to cut somebody off - * from every secret in the organization. - */ -export async function confirmKickInBrowser(p: WebKickParams): Promise { - const out = await runBrowserWizard( - { - title: `Remove ${p.member.email} — ${p.orgName}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Removed — back to your terminal.', - renderFirst: (nonce) => - withDeclineBridge( - renderScreen('org-members', buildKickData(p, nonce)), - kickDeclineBridge( - nonce, - `${stripAnsi(p.member.email)} is still a member of ${stripAnsi(p.orgName)}.`, - ), - ), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') return { done: true, result: false }; - if (payload.__action === 'apply') { - // The list view can queue role and grant edits. `capy kick` cannot - // apply one — it holds a single DELETE — so applying a batch here - // would mean reporting changes the command never made. - return { error: 'capy kick only removes a member. Change roles and grants with capy users.' }; - } - if (payload.action !== 'remove') { - return { error: 'That is not an action this screen offers.' }; - } - // Resolved against the membership the CLI already found, never trusted: - // the id in this payload is what a DELETE is about to be aimed at. - if (payload.membershipId !== p.member.membershipId) { - return { error: 'That is not the member this command is about.' }; - } - return { done: true, result: true }; - }, - ); - return out === true; -} diff --git a/src/ui/onboardingWeb.ts b/src/ui/onboardingWeb.ts deleted file mode 100644 index 8c6e605f..00000000 --- a/src/ui/onboardingWeb.ts +++ /dev/null @@ -1,551 +0,0 @@ -// The three browser flows that handle a recovery phrase or a passphrase, -// served as compiled screens: local-mode setup (`capy byoc --web`), creating a -// cloud organization (`capy org --web` → "Create new organization +"), and -// unlocking the local key. -// -// SECURITY INVARIANT, and the reason this file exists at all: a 24-word -// recovery phrase generated here is rendered into the loopback page for the -// user to write down and NOTHING ELSE. It is never printed to stdout/stderr, -// never logged, never part of a return value, and never permitted back in a -// submit payload — so when an agent shells `capy byoc --web` or `capy org -// --web` through the MCP, the phrase cannot reach the model. The phrase lives -// in a closure here and is handed straight to the caller's `finalize`, which -// derives the master key. `tests/commands/webPhraseLeak.test.ts` holds the -// invariant against a real spawned process. -// -// The generated-phrase steps therefore answer with a BOOLEAN — `confirmed: -// true` — and a payload that carries a phrase on those steps is refused rather -// than trusted: the screen cannot produce one, so it did not come from the -// screen. Restoring a phrase the user already holds is the deliberate -// exception and a different risk: they type it, it has to reach the CLI to be -// checked, and it crosses the same pinned loopback the passphrase does. -// -// This replaces the hand-written HTML these flows used to serve. Every -// behaviour is kept — the same questions, the same validation, the same -// closure-only phrase — and only the rendering moved. -import { runBrowserWizard } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { generateSeedPhrase, validateSeedPhrase } from '../crypto/keyManager'; -import { - createOrgPlan, - localOnboardingPlan, - PHRASE_SOURCE_LABELS, -} from '../core/onboardingPlan'; -import type { - CreateOrganizationData, - LocalOnboardingData, - LocalPassphraseUnlockData, - UnlockTrigger, -} from './screens/contract'; - -/** - * How many words a valid recovery phrase has. - * - * Mirrors `validateSeedPhrase`, which refuses anything but this count, and is - * carried in the payload so no screen hardcodes 24 in its own copy. - */ -export const SEED_PHRASE_WORDS = 24; - -/** - * The CLI's floor for a local passphrase. - * - * A default, not the definition: `byocCommand` owns the number because its TTY - * validator is the thing that refuses, and it hands the same value in. This - * exists so a caller that forgets is still holding a floor rather than none. - */ -export const MIN_PASSPHRASE_LENGTH = 8; - -/** - * Strip terminal colour codes on the way into a payload. - * - * The warning blocks these screens render are the same arrays the TTY path - * prints, and a payload is not a terminal: an escape would render as a literal - * `[90m` in the browser. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -export interface OnboardingWebOptions { - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; - /** The warning block the CLI prints beside the phrase. Display only. */ - bodyLines?: string[]; - /** The CLI's own floor, so the page and the validator cannot disagree. */ - minPassphraseLength?: number; -} - -// --------------------------------------------------------------------------- -// local-onboarding — capy byoc --web -// --------------------------------------------------------------------------- - -/** Which question local-mode setup is standing on, given what it has answered. */ -type LocalOnboardingView = 'choose-source' | 'phrase' | 'enter-phrase' | 'passphrase'; - -export interface LocalOnboardingState { - source?: 'generate' | 'enter'; - /** The generated phrase, when this run generated one. NEVER returned. */ - phraseWords?: string[]; - phraseSettled?: boolean; -} - -function localOnboardingView(s: LocalOnboardingState): LocalOnboardingView { - if (s.source === undefined) return 'choose-source'; - if (!s.phraseSettled) return s.source === 'generate' ? 'phrase' : 'enter-phrase'; - return 'passphrase'; -} - -/** - * How this step is answered without a browser: it is not. - * - * An empty `command` is the contract's way of saying a step refuses headlessly - * rather than taking a recovery phrase or a passphrase off a command line, - * where it would land in shell history, `ps` output and an agent's transcript. - */ -const NO_HEADLESS_SECRET: LocalOnboardingData['nonTty'] = { - command: '', - why: 'A recovery phrase and a passphrase are the two things this command must never read from argv or the environment: both would outlive the run in shell history and in whatever captured its output. This step needs a person.', -}; - -export function buildLocalOnboardingData( - p: LocalOnboardingState & { bodyLines?: string[]; minPassphraseLength?: number }, - nonce: string, -): LocalOnboardingData { - const view = localOnboardingView(p); - return { - nonce, - view, - stops: localOnboardingPlan({ - source: p.source, - phraseSettled: p.phraseSettled, - }), - source: p.source, - // Only ever on the display step, and only the words this process generated. - // A restored phrase is never echoed back into a page. - phraseWords: view === 'phrase' ? p.phraseWords : undefined, - bodyLines: p.bodyLines?.map(stripAnsi), - expectedWords: SEED_PHRASE_WORDS, - minPassphraseLength: p.minPassphraseLength ?? MIN_PASSPHRASE_LENGTH, - nonTty: NO_HEADLESS_SECRET, - }; -} - -/** - * Drive local-only onboarding in the browser: where the recovery phrase comes - * from, the phrase itself, and a local passphrase — then `finalize`, which - * derives and persists the wrapped master key, entirely inside this process. - * Resolves true on success, false if the user cancelled or closed the page. - * - * The phrase is intentionally NOT part of the return value. - */ -export async function runLocalOnboardingWeb( - finalize: (phrase: string, passphrase: string) => void, - opts: OnboardingWebOptions = {}, -): Promise { - // Closure-only state. `phrase` is never serialized into a result and only - // ever reaches a payload as `phraseWords` on the one step that displays it. - let phrase: string | null = null; - const minLength = opts.minPassphraseLength ?? MIN_PASSPHRASE_LENGTH; - const state: LocalOnboardingState = {}; - let nonce = ''; - - const render = (): string => - renderScreen( - 'local-onboarding', - buildLocalOnboardingData( - { ...state, bodyLines: opts.bodyLines, minPassphraseLength: minLength }, - nonce, - ), - ); - - const result = await runBrowserWizard( - { - title: 'Set up Capy — local mode', - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: opts.open ?? true, - onListen: opts.onListen, - timeoutMs: opts.timeoutMs, - doneMessage: 'Local mode is ready — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') return { done: true, result: { ok: false } }; - - const view = localOnboardingView(state); - - if (view === 'choose-source') { - const source = payload.source; - if (source !== 'generate' && source !== 'enter') { - // The screen offers exactly two rows, so anything else is a - // malformed submit rather than a user mistake. - return { error: 'That is not a phrase source this step offers.' }; - } - state.source = source; - if (source === 'generate') { - phrase = generateSeedPhrase(); - state.phraseWords = phrase.split(/\s+/).filter(Boolean); - } - return { screen: { html: render(), standalone: true } }; - } - - if (view === 'phrase') { - // A generated phrase travels one way only. The display step sends back - // consent and nothing else, so a payload carrying words did not come - // from that step — and accepting it would be taking a master key off - // the wire to key somebody's machine with. - if ('phrase' in payload) { - return { error: 'The recovery phrase is not something this step sends back.' }; - } - if (payload.confirmed !== true) { - return { error: 'Confirm you have written down the recovery phrase first.' }; - } - state.phraseSettled = true; - return { screen: { html: render(), standalone: true } }; - } - - if (view === 'enter-phrase') { - const entered = typeof payload.phrase === 'string' ? payload.phrase.trim() : ''; - if (!validateSeedPhrase(entered)) { - // Inline, NOT a fresh page: re-serving would wipe what the user - // typed, and a phrase is 24 words to retype over one wrong one. - return { - error: `That is not a valid ${SEED_PHRASE_WORDS}-word recovery phrase. Check the words and try again.`, - }; - } - phrase = entered; - state.phraseSettled = true; - return { screen: { html: render(), standalone: true } }; - } - - // passphrase. Inline errors keep the two fields on screen; the checks are - // the CLI's own, in the CLI's own order (length before match). - const pass = typeof payload.passphrase === 'string' ? payload.passphrase : ''; - const confirm = typeof payload.confirm === 'string' ? payload.confirm : ''; - if (pass.length < minLength) { - return { error: `Use at least ${minLength} characters.` }; - } - if (pass !== confirm) return { error: 'Passphrases do not match.' }; - if (!phrase) { - // Unreachable through the screens, and not survivable if it happens: - // there is nothing to derive a key from. - return { error: 'Lost the recovery phrase — please restart setup.' }; - } - try { - finalize(phrase, pass); - } catch (err) { - // Never "nothing was changed": finalize writes the wrapped key, the - // profile and the session in sequence, so a failure part-way through - // has changed something. - const detail = err instanceof Error ? err.message : 'the key could not be written'; - return { error: `Could not set up local mode: ${detail}.` }; - } - return { done: true, result: { ok: true } }; - }, - ); - - return !!(result && typeof result === 'object' && (result as { ok?: boolean }).ok); -} - -// --------------------------------------------------------------------------- -// create-organization — capy org --web → "Create new organization +" -// --------------------------------------------------------------------------- - -/** - * The availability verdict, as a code. - * - * `checkOrgName` answers a question and the CLI turns the answer into a - * sentence; this carries the answer. `unreachable` is its own case because the - * CLI swallows a failed check and treats the name as free, which is a decision - * the page has to be able to describe rather than a silence. - */ -export type OrgNameVerdict = 'available' | 'taken' | 'unreachable'; - -export interface WebCreateOrgParams { - /** - * SECRET. The recovery phrase the organization's master key is derived from. - * Rendered into this page and nowhere else; never returned from here. - */ - phrase: string; - /** The zero-trust warning block, verbatim from the CLI. Display only. */ - bodyLines: string[]; - learnMoreUrl?: string; - /** Prefill for the field — a name a previous attempt already produced. */ - name?: string; - /** A refusal the CLI made before this page was served. */ - nameError?: 'RACE_409'; - /** - * The CLI's cap on an organization name. Required rather than defaulted: the - * command owns the number, and a default here would be a second copy of it - * free to drift from the validator that actually refuses. - */ - maxNameLength: number; - /** Server-side availability check. Returns a verdict, never a sentence. */ - checkName?: (name: string) => Promise; - /** - * Ask for the name and stop there. - * - * The 409 retry: the phrase has already been shown and written down, and the - * only thing the server refused was the name. Showing the grid a second time - * would contradict the page's own "this is the only time it is shown", and - * the terminal does not do it either — its retry loop re-prompts the name and - * nothing else. - */ - nameOnly?: boolean; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** What the browser decided. `cancelled` means no organization may be created. */ -export interface WebCreateOrgResult { - name: string; - cancelled: boolean; -} - -interface CreateOrgState { - name: string; - nameError?: CreateOrganizationData['nameError']; -} - -/** - * Why this step cannot be answered headlessly. Phrase-only, unlike local - * setup: creating an organization never asks for a passphrase. - */ -const NO_HEADLESS_PHRASE: CreateOrganizationData['nonTty'] = { - command: '', - why: 'The recovery phrase is shown once and must be written down by the person who will need it. A run with nowhere to show it has nowhere to show it, so this step refuses rather than generating a master key nobody read.', -}; - -export function buildCreateOrganizationData( - p: WebCreateOrgParams & { state?: Partial }, - nonce: string, -): CreateOrganizationData { - const name = (p.state?.name ?? p.name ?? '').trim(); - const nameError = p.state?.nameError ?? p.nameError; - // Two views, not four. `creating` and `created` are the terminal's to report: - // the organization is created after this window closes, so a page claiming - // either would be claiming an outcome the CLI has not reached. - const view = name && !nameError && !p.nameOnly ? 'phrase' : 'name'; - - return { - nonce, - stops: createOrgPlan({ - name: view === 'name' ? undefined : name, - // A name-only run is a retry: the phrase stop is behind it, and the rail - // says so rather than promising a reveal that is not coming. - confirmed: p.nameOnly === true, - }), - view, - name, - // Only ever the verdict that produced a refusal. `available` and - // `unreachable` are checked on submit and acted on immediately, so no page - // is ever served carrying them. - nameStatus: nameError === 'TAKEN' ? 'taken' : undefined, - nameError, - maxNameLength: p.maxNameLength, - // The words reach the page on the one step that displays them, and reach - // nothing else, ever. - phraseWords: view === 'phrase' ? p.phrase.split(/\s+/).filter(Boolean) : undefined, - bodyLines: p.bodyLines.map(stripAnsi), - learnMoreUrl: p.learnMoreUrl, - nonTty: NO_HEADLESS_PHRASE, - }; -} - -/** - * Name a new organization and show the recovery phrase its master key comes - * from, in one wizard. - * - * Replaces two separate loopback windows — a text prompt and a phrase display — - * that shared no rail, so the user met a write-it-down step they had not been - * told was coming, in a second page that looked like a different command. - * - * The phrase is a parameter rather than generated here because the CLI holds - * one phrase across a 409 retry: the name is claimed while you are reading the - * words, and the same words must key whatever name you pick next. - */ -export async function createOrganizationInBrowser( - p: WebCreateOrgParams, -): Promise { - const max = p.maxNameLength; - const state: CreateOrgState = { name: (p.name ?? '').trim(), nameError: p.nameError }; - let nonce = ''; - - const render = (): string => - renderScreen('create-organization', buildCreateOrganizationData({ ...p, state }, nonce)); - - const out = await runBrowserWizard( - { - title: 'New organization', - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Confirmed — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { name: '', cancelled: true } }; - } - - // The name step is where a run stands until a name has survived the - // availability check; `nameError` puts it back there. - const onName = !state.name || state.nameError !== undefined; - - if (onName) { - const name = typeof payload.name === 'string' ? payload.name.trim() : ''; - // The screen holds its button on both of these, so either arriving is - // a submit the screen could not have produced. - if (!name) return { error: 'Organization name cannot be empty' }; - if (name.length > max) { - return { error: `Organization name must be ${max} characters or fewer` }; - } - - const verdict = p.checkName ? await p.checkName(name) : 'unreachable'; - if (verdict === 'taken') { - // A real refusal rather than a malformed submit: the page could not - // have known. It comes back as a fresh page carrying the name and - // the code, which is what the field's own "already taken" copy is - // built to render. - state.name = name; - state.nameError = 'TAKEN'; - return { screen: { html: render(), standalone: true } }; - } - // `unreachable` proceeds, matching the CLI: `validateOrgName` swallows - // a failed check and treats the name as free. The collision then - // reappears as a 409, which is what `RACE_409` exists for. - state.name = name; - state.nameError = undefined; - if (p.nameOnly) return { done: true, result: { name, cancelled: false } }; - return { screen: { html: render(), standalone: true } }; - } - - // The phrase step. See the header: consent travels, words do not. - if ('phrase' in payload || 'phraseWords' in payload) { - return { error: 'The recovery phrase is not something this step sends back.' }; - } - if (payload.confirmed !== true) { - return { error: 'Confirm you have written down the recovery phrase first.' }; - } - return { done: true, result: { name: state.name, cancelled: false } }; - }, - ); - return out as WebCreateOrgResult; -} - -// --------------------------------------------------------------------------- -// local-passphrase-unlock — the passphrase that opens the local key -// --------------------------------------------------------------------------- - -/** What an unlock attempt did, as a shape rather than as an exception message. */ -export type UnlockAttempt = { ok: true; masterKeyHex: string } | { ok: false }; - -export interface WebPassphraseUnlockParams { - /** Which command is waiting on the passphrase. */ - triggeredBy?: UnlockTrigger; - /** The literal command line, for `capy run -- `. */ - triggerCommand?: string; - projectName?: string; - /** Whether the key was locked on purpose or by the idle clock. */ - lockedBy?: 'command' | 'idle'; - /** Idle auto-lock, in milliseconds. */ - idleTimeoutMs?: number; - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; -} - -export function buildPassphraseUnlockData( - p: WebPassphraseUnlockParams, - nonce: string, -): LocalPassphraseUnlockData { - return { - nonce, - view: 'unlock', - triggeredBy: p.triggeredBy, - triggerCommand: p.triggerCommand, - projectName: p.projectName, - lockedBy: p.lockedBy, - idleTimeoutMs: p.idleTimeoutMs, - nonTty: { - command: '', - why: 'A passphrase read from argv or the environment defeats the lock it is protecting: it outlives the run in shell history and in whatever captured the invocation. This step needs a person.', - }, - }; -} - -/** - * Ask for the local passphrase in the browser and hand back the unwrapped - * master key. - * - * NOT YET CALLED IN PRODUCTION, and the missing hop is not in this parcel. - * The prompt this replaces is `unlockLocalKey` (src/core/localUnlock.ts), which - * takes no options and reaches inquirer directly: - * - * const masterKeyHex = decryptLocalMasterKeyHex(passphrase); - * - * Lighting this up means giving that function a `web` option and passing it - * from the five commands that call it — `capy`, `push`, `status`, `edit`, - * `run`. Two of those files belong to the init-sync parcel, one to secrets, and - * `run`/`push` have no `--web` flag at all because `src/index.ts` declares the - * global and only `byoc`/`add`/`capy` read it back. So the wiring is the same - * argv-threading job as `capy org --web`, spread across three other agents' - * files; the screen, its payload builder and its browser test are ready for it. - * - * `attempt` does the unwrapping inside this process and answers with a shape, - * not an exception: `decryptLocalMasterKeyHex` throws the same - * PERMISSION_DENIED for a wrong passphrase and for a keystore that does not - * exist, so telling them apart here would mean reading its sentence. - * - * Resolves null when the user cancelled or closed the page. The passphrase - * itself stays in this closure — only the key the caller asked for comes back, - * which is exactly what `decryptLocalMasterKeyHex` already returns. - */ -export async function unlockPassphraseInBrowser( - attempt: (passphrase: string) => UnlockAttempt, - p: WebPassphraseUnlockParams = {}, -): Promise { - const out = await runBrowserWizard( - { - title: 'Local passphrase', - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Unlocked — back to your terminal.', - renderFirst: (nonce) => - renderScreen('local-passphrase-unlock', buildPassphraseUnlockData(p, nonce)), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') return { done: true, result: { key: null } }; - - const passphrase = typeof payload.passphrase === 'string' ? payload.passphrase : ''; - // The screen holds its button on an empty field, so this is a submit it - // could not have produced — and deriving a wrapping key from "" is not a - // guess worth making about somebody's keystore. - if (passphrase === '') return { error: 'Enter your local passphrase.' }; - - const result = attempt(passphrase); - if (!result.ok) { - // Inline, so the field comes back rather than the command dying. The - // terminal path has no retry at all: a wrong passphrase is a typed - // error and the command exits. - return { error: 'Incorrect passphrase.' }; - } - return { done: true, result: { key: result.masterKeyHex } }; - }, - ); - const key = (out as { key?: unknown }).key; - return typeof key === 'string' ? key : null; -} diff --git a/src/ui/projectQuestions.ts b/src/ui/projectQuestions.ts new file mode 100644 index 00000000..4988f393 --- /dev/null +++ b/src/ui/projectQuestions.ts @@ -0,0 +1,52 @@ +/** + * The inquirer project pickers' questions, in one place. + * + * Every one is a type-to-filter single-select (see `searchPrompts` for the + * match rule and for how the "New project" row behaves under a filter). The + * messages are the ones the pickers have always shown, word for word. + */ +import { searchableSelectQuestion } from './searchPrompts'; + +export interface ProjectRef { + readonly id: string; + readonly name: string; +} + +/** `capy` (init): "New project" first and pre-selected, then the org's existing projects. */ +export function initProjectQuestion(existing: readonly ProjectRef[], createNewProjectValue: string): any { + return searchableSelectQuestion({ + name: 'projectChoice', + message: 'Which project do you want to use?', + choices: [ + { name: 'New project', value: createNewProjectValue }, + ...existing.map((p) => ({ name: p.name, value: p.id })), + ], + default: createNewProjectValue, + }); +} + +/** `capy org` switch: the projects of the org just chosen. */ +export function orgProjectQuestion(orgProjects: readonly ProjectRef[]): any { + return searchableSelectQuestion({ + name: 'projectId', + message: 'Select project:', + choices: orgProjects.map((p) => ({ name: p.name, value: p.id })), + }); +} + +/** Dokploy discovery: "New project ()" first and pre-selected, then existing projects. */ +export function discoveryProjectQuestion( + existing: readonly ProjectRef[], + defaultName: string, + newProjectValue: string, +): any { + return searchableSelectQuestion({ + name: 'projectChoice', + message: 'Which project should this folder use?', + choices: [ + { name: `New project (${defaultName})`, value: newProjectValue }, + ...existing.map((p) => ({ name: p.name, value: p.id })), + ], + default: newProjectValue, + }); +} diff --git a/src/ui/promptStyle.ts b/src/ui/promptStyle.ts index 6dd5600e..4b6d7290 100644 --- a/src/ui/promptStyle.ts +++ b/src/ui/promptStyle.ts @@ -40,6 +40,23 @@ export const CHECKBOX_INSTRUCTIONS = DIM(' to proceed') + '\n'; +// COPY-FLAG: new user-facing string — the existing line plus a `/` filter key, minimal/neutral wording. +/** `CHECKBOX_INSTRUCTIONS` for the searchable checkbox, which adds `/` to start filtering. */ +export const SEARCHABLE_CHECKBOX_INSTRUCTIONS = + '\n\n' + + DIM('Press ') + + KEY('space') + + DIM(' to select, ') + + KEY('a') + + DIM(' to toggle all, ') + + KEY('i') + + DIM(' to invert, ') + + KEY('/') + + DIM(' to filter, ') + + KEY('enter') + + DIM(' to proceed') + + '\n'; + /** * Theme override for `inquirer.prompt({ type: 'checkbox', ... })`. * diff --git a/src/ui/recoveryScreens.ts b/src/ui/recoveryScreens.ts deleted file mode 100644 index d66f1c7d..00000000 --- a/src/ui/recoveryScreens.ts +++ /dev/null @@ -1,515 +0,0 @@ -// The recovery flows, served as compiled screens. -// -// `capy recover` and `capy end-recover` both handle material equivalent to a -// recovery phrase, and each one moves it in a different direction. Getting -// that direction wrong is the whole risk in this file, so each section -// states its own: -// -// recover-master-key INBOUND. The 24 words are TYPED. They cross the -// loopback because the CLI has to check them — that is -// the opposite of onboarding, where a GENERATED phrase -// must never travel back. Nothing about them is -// rendered, echoed, logged or put in a payload: there -// is no field here for the phrase, for the master key, -// or for a fingerprint of either. -// end-recover-cleanup NEITHER. Every file it names IS plaintext secret -// material. The payload carries FILENAMES; a preview of -// what is inside them would undo the whole command. -// -// `capy transport` used to have a third section here (`transport-machine`, -// the old invite-shaped redeem-code page) — removed for CAP-684's basic -// pairing, which replaced that whole flow with Keep's own `/transport` page -// and no longer needs a CLI-served browser screen for it. The `transport- -// machine` compiled screen asset itself (src/ui/screens/contract.ts / -// generated.ts) is generated from the monorepo's packages/ui + packages/ -// fixtures — deleting it from there is out of scope for a capy-cli-only -// change, so it's dead but harmless until that pipeline drops it too. -// -// Everything else follows `syncConflictScreen.ts`: build the payload in an -// exported `buildXData(params, nonce)` so a test can assert its shape without -// standing a server up, serve it with `standalone: true` because a compiled -// screen is a whole document, refuse a malformed submit inline rather than -// applying a guess, and carry the CLI's own wording verbatim. -import { runBrowserWizard, type WizardDecision } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { recoverPlan } from '../core/recoverPlan'; -import type { - DecryptedFile, - EndRecoverCleanupData, - OracleGap, - RecoverMasterKeyData, - RecoverOrg, - RecoverPhraseError, - RecoverView, -} from './screens/contract'; - -/** - * Strip terminal colour codes on the way into a payload. - * - * Applied to strings the CLI also PRINTS — an organization name it bolds, a - * humanised age it dims — because a payload is not a terminal and an escape - * renders as a literal `[90m` in the browser. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -// --------------------------------------------------------------------------- -// recover-master-key -// --------------------------------------------------------------------------- - -/** - * What the trial decryption said about a phrase the user typed. - * - * A code rather than prose, minted where the condition is first known. The - * screen owns the wording — `capy recover` and `capy decrypt` say different - * things about the same failure — and it already holds all three sentences - * verbatim, so shipping the text would be shipping a second copy of it. - */ -export type PhraseVerdict = - | { code: 'MATCH'; kdfVersion: 1 | 2 } - | { code: 'EMPTY' } - | { code: 'INVALID' } - | { code: 'NO_MATCH' } - | { code: 'NO_ORACLE'; gap: OracleGap }; - -/** Whether the wrapped key reached the disk, and where. */ -export type WriteOutcome = - | { ok: true; keyPath: string } - | { ok: false; message: string }; - -/** - * What the CLI does at each stop. - * - * Injected rather than imported so that no key derivation, no trial - * decryption and no wrapping happens in this module. `--web` changes where a - * question is drawn; the crypto stays in `recoverCommand`, running the same - * code the terminal path runs. The phrase is handed to these and is not - * retained here beyond the submit that carries it. - */ -export interface RecoverOps { - /** - * Re-scope the session to the chosen organization, so the KMS wrap call - * lands on that org's endpoint. False is a refusal the user sees at the org - * stop rather than a crash after the browser closed. - */ - scopeToOrg(orgId: string): Promise; - /** Trial-decrypt one of the organization's own values with this phrase. */ - verifyPhrase(orgId: string, phrase: string): Promise; - /** - * Derive, wrap and save. `kdfVersion` is the one the trial proved; omitted - * on the unverified fork, where the caller falls back to the current - * version — the same fallback the terminal path takes. - */ - writeKey(orgId: string, phrase: string, kdfVersion?: 1 | 2): Promise; -} - -/** - * Everything the page needs, and nothing that could carry key material. - * - * Separate from `WebRecoverParams` so a test can assert the payload's shape - * without supplying a working set of crypto operations. - */ -export interface RecoverScreenState { - /** The CLI prints `Signed in as .`; the page puts it in the header. */ - userEmail?: string; - /** Every organization this session can reach. One is still a list. */ - orgs: RecoverOrg[]; - /** How many words this run expects. The CLI's number, never the page's. */ - wordCount: number; - /** The organization chosen so far. Undefined means the org stop is open. */ - orgId?: string; - /** The overwrite gate has been agreed to. */ - overwriteAgreed?: boolean; - /** - * Set when the trial decryption could not run, with the reason. The run - * then stands at the write stop being asked to write anyway — a fork the - * terminal takes silently, on the user's behalf. - */ - oracleGap?: OracleGap; - /** Why the previous phrase was refused. */ - phraseError?: RecoverPhraseError; -} - -export interface WebRecoverParams extends RecoverScreenState { - ops: RecoverOps; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** What the browser did. `cancelled` means nothing was written. */ -export interface WebRecoverResult { - orgId: string; - orgName: string; - /** Which version the trial proved. Null when nothing could check the phrase. */ - kdfVersion: 1 | 2 | null; - keyPath: string | null; - cancelled: boolean; -} - -/** - * Which stop this run is standing on. - * - * Derived from the answers rather than set by the caller, so the screen that - * gets served and the rail drawn beside it cannot disagree about where the - * traveller is. `done` never appears: the compiled screen's result view draws - * no control, so serving it would leave the wizard waiting for a submit that - * can never arrive. The outcome is printed to the terminal instead, exactly as - * the terminal path prints it. - */ -export function recoverScreenView(s: RecoverScreenState): RecoverView { - if (!s.orgId) return 'organization'; - const org = s.orgs.find((o) => o.id === s.orgId); - if (org?.hasKeyOnThisDevice && !s.overwriteAgreed) return 'overwrite'; - if (s.oracleGap) return 'unverified'; - return 'phrase'; -} - -/** - * How each stop is answered without a browser. - * - * Per view, because the answer differs by stop and one of them has no answer - * at all. `capy recover` takes no flags today, so nothing here promises one: - * the escape from every stop is a terminal, and for the phrase stop that is - * not a limitation to be fixed later. A recovery phrase passed in argv is in - * the shell history and in every process listing on the machine. - */ -function nonTtyEscape(view: RecoverView): RecoverMasterKeyData['nonTty'] { - if (view === 'organization') { - return { - command: 'capy recover', - why: 'Which organization the phrase belongs to is never inherited from this directory — silently taking keep.lock’s organization is the bug that wrapped the wrong master key for the wrong org — and there is no flag that answers it.', - }; - } - if (view === 'overwrite') { - return { - command: 'capy recover', - why: 'Continuing overwrites the wrapped key this device already holds. If your current key still works you do not need to recover, so this is never assumed.', - }; - } - if (view === 'unverified') { - return { - command: 'capy recover', - why: 'Nothing here could check the phrase, so writing the key means guessing the KDF version. For an organization created before version 2 that guess produces a key which decrypts nothing, which is why it is a decision rather than a fallback.', - }; - } - return { - command: 'capy recover', - why: 'A recovery phrase is typed, never passed: in argv it is in the shell history and in every process listing on the machine. There is no non-interactive form of this step and there should not be one.', - }; -} - -export function buildRecoverData(s: RecoverScreenState, nonce: string): RecoverMasterKeyData { - const view = recoverScreenView(s); - - // Stripped ONCE, at the edge, before anything downstream can hold a copy. - // Doing it per-field is how the rail ended up as the one surface still - // carrying escapes: `orgs[].name` and `orgName` were cleaned and the same - // name went into `recoverPlan` raw, so the station that reads back your - // answer rendered a literal `[1m` beside the clean one in the body. - const userEmail = s.userEmail === undefined ? undefined : stripAnsi(s.userEmail); - const orgs = s.orgs.map((o) => ({ - id: o.id, - name: stripAnsi(o.name), - hasKeyOnThisDevice: o.hasKeyOnThisDevice, - })); - const org = orgs.find((o) => o.id === s.orgId); - - return { - nonce, - view, - // The route is the plan's array, not one this file assembles for the page. - // `signedIn` is true because `recover` authenticates before it opens - // anything: by the time a screen exists, that stop is history. - stops: recoverPlan({ - signedIn: true, - userEmail, - orgName: org?.name, - hasKeyOnThisDevice: org?.hasKeyOnThisDevice, - overwriteAgreed: s.overwriteAgreed, - // The only state that stands at the write stop with a page open. - phraseEntered: s.oracleGap !== undefined, - wordCount: s.wordCount, - }), - userEmail, - orgs, - orgName: org?.name, - orgId: org?.id, - wordCount: s.wordCount, - phraseError: s.phraseError, - oracleGap: s.oracleGap, - nonTty: nonTtyEscape(view), - }; -} - -/** - * Serve `capy recover`'s questions and do the work between them. - * - * The verification has to happen inside this loop rather than after it: a - * phrase that matches nothing must come back as the phrase stop again, and a - * phrase nothing could check must come back as a decision. Collecting all the - * answers first and verifying afterwards would turn both into an exit code - * after the browser had already closed. - */ -export async function recoverInBrowser(p: WebRecoverParams): Promise { - // The nonce is minted inside `runBrowserWizard` and reaches a caller only - // through `renderFirst`. Later standalone steps are rendered with that same - // token rather than each flow minting its own. - let nonce = ''; - - let state: RecoverScreenState = { - userEmail: p.userEmail, - orgs: p.orgs, - wordCount: p.wordCount, - orgId: p.orgId, - overwriteAgreed: p.overwriteAgreed, - oracleGap: p.oracleGap, - phraseError: p.phraseError, - }; - - /** - * The phrase this run is standing on. - * - * Held ONLY between a phrase submit that nothing could verify and the answer - * to "write it anyway", because that fork is two round trips and the second - * one carries no phrase. It is a local in this frame, it is never rendered, - * never logged and never put in a payload, and it is cleared the moment the - * key is written or the run ends. - */ - let pending = ''; - - const render = (): string => renderScreen('recover-master-key', buildRecoverData(state, nonce)); - const advance = (): WizardDecision => ({ screen: { html: render(), standalone: true } }); - - const out = await runBrowserWizard( - { - title: 'Recover master key', - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Recovered — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - pending = ''; - return { - done: true, - result: { orgId: '', orgName: '', kdfVersion: null, keyPath: null, cancelled: true }, - }; - } - - const view = recoverScreenView(state); - - if (view === 'organization') { - const id = typeof payload.organizationId === 'string' ? payload.organizationId : ''; - const org = p.orgs.find((o) => o.id === id); - // The page offers only the organizations this session can reach, so - // anything else did not come from it — and wrapping a phrase's master - // key for an organization it was never issued for is precisely the - // failure `recover` exists to undo. - if (!org) return { error: 'That organization is not one this session can reach.' }; - if (!(await p.ops.scopeToOrg(org.id))) { - // The CLI's own sentence for this, minus the terminal's bolding — - // which is stripped rather than assumed absent, because this string - // is rendered into a page and an escape shows up there as `[1m`. - return { - error: `Failed to scope session to ${stripAnsi(org.name)}. Run capy and select this org, then retry.`, - }; - } - state = { ...state, orgId: org.id }; - return advance(); - } - - if (view === 'overwrite') { - // The screen sends `true` or holds its button. Anything else is a - // malformed submit, and guessing would destroy the key this device - // already holds. - if (payload.overwrite !== true) { - return { error: 'That is not an answer the overwrite step can produce.' }; - } - state = { ...state, overwriteAgreed: true }; - return advance(); - } - - const orgId = state.orgId; - if (!orgId) return { error: 'There is nothing left to answer on this run.' }; - - const settle = (outcome: WriteOutcome, kdfVersion: 1 | 2 | null): WizardDecision => { - // A write that failed leaves the run exactly where it was — including - // the phrase, so pressing the button again is a retry rather than a - // dead end. Nothing was written, which is what the message says. - if (!outcome.ok) return { error: outcome.message }; - pending = ''; - return { - done: true, - result: { - orgId, - orgName: p.orgs.find((o) => o.id === orgId)?.name ?? '', - kdfVersion, - keyPath: outcome.keyPath, - cancelled: false, - }, - }; - }; - - if (view === 'unverified') { - if (payload.writeUnverified !== true) { - return { error: 'That is not an answer this step can produce.' }; - } - if (!pending) { - // The phrase this stop was about is gone — a fresh server, or a - // submit replayed against a run that already finished with it. Ask - // for it again rather than write a key derived from nothing. - state = { ...state, oracleGap: undefined, phraseError: 'EMPTY' }; - return advance(); - } - return settle(await p.ops.writeKey(orgId, pending), null); - } - - // The phrase stop. `.trim()` and no more: the phrase IS the PBKDF2 - // password, so collapsing runs of whitespace here would derive a - // different master key than the terminal derives from the same words. - const phrase = typeof payload.phrase === 'string' ? payload.phrase.trim() : ''; - const verdict = await p.ops.verifyPhrase(orgId, phrase); - - if (verdict.code === 'EMPTY' || verdict.code === 'INVALID' || verdict.code === 'NO_MATCH') { - // Re-ask rather than refuse in place. The reload clears the word - // boxes, which is what a rejected phrase should leave behind, and the - // reason travels as a code so one failure is never worded two ways. - state = { ...state, phraseError: verdict.code }; - return advance(); - } - - if (verdict.code === 'NO_ORACLE') { - pending = phrase; - state = { ...state, phraseError: undefined, oracleGap: verdict.gap }; - return advance(); - } - - return settle(await p.ops.writeKey(orgId, phrase, verdict.kdfVersion), verdict.kdfVersion); - }, - ); - return out as WebRecoverResult; -} - -// --------------------------------------------------------------------------- -// end-recover-cleanup -// --------------------------------------------------------------------------- - -export interface WebEndRecoverParams { - /** - * The session this sweep is closing. REQUIRED, and that is the whole of the - * guarantee: `capy end-recover` returns early with no session and removes - * nothing, so a page served without one would let a click unlink files the - * terminal form would never have touched — and every row on it arrives - * ticked, which makes that the DEFAULT answer rather than an opt-in. - * - * `--web` moves where a question is drawn. It does not move what a command - * deletes, and the shape of this parameter is what stops the next edit from - * making it. The screen still renders a sessionless state (see - * `EndRecoverCleanupData.session`); nothing in this CLI produces one. - */ - session: { orgName: string; startedAt: string }; - /** The directory being swept. It is only ever this one. */ - cwd: string; - /** Files matching `.env.*.decrypted` here. Names only — never contents. */ - files: DecryptedFile[]; - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** What the browser agreed to. `cancelled` means nothing may be removed. */ -export interface WebEndRecoverResult { - endSession: boolean; - /** Files to delete, in the order the page listed them. */ - remove: string[]; - cancelled: boolean; -} - -export function buildEndRecoverData(p: WebEndRecoverParams, nonce: string): EndRecoverCleanupData { - return { - nonce, - view: 'review', - session: { orgName: stripAnsi(p.session.orgName), startedAt: stripAnsi(p.session.startedAt) }, - cwd: stripAnsi(p.cwd), - // Names, ages and sizes. Every one of these files is readable plaintext - // and not a byte of their contents belongs in a payload. - files: p.files.map((f) => ({ - name: f.name, - age: f.age === undefined ? undefined : stripAnsi(f.age), - size: f.size === undefined ? undefined : stripAnsi(f.size), - })), - nonTty: { - command: 'capy end-recover', - why: 'The terminal form takes no input at all: it ends the session and deletes every .env.*.decrypted file in this directory, with no preview and no confirmation. Choosing which to keep is what the browser adds.', - }, - }; -} - -/** - * Serve the sweep preview and wait for a decision. - * - * The submitted list is resolved against the files this run actually found, - * never trusted: the answer to this page is a set of unlink calls. - */ -export async function endRecoverInBrowser(p: WebEndRecoverParams): Promise { - // Nothing is served without a session — refused BEFORE the socket exists, - // so there is no page for a browser to reach and no window to tick. The - // type says the same thing; this is the check a JavaScript caller gets, and - // it is here rather than only in the command because this is the function - // that opens the port. - if (!p.session) { - throw new Error( - 'end-recover has no session to close, so there is nothing to serve: `capy end-recover` removes nothing in this state.', - ); - } - - const out = await runBrowserWizard( - { - title: 'End recovery session', - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Swept — back to your terminal.', - renderFirst: (nonce) => renderScreen('end-recover-cleanup', buildEndRecoverData(p, nonce)), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { endSession: false, remove: [], cancelled: true } }; - } - - // Whether the session is cleared is this run's fact, not the page's - // answer: the screen computes the flag from the payload it was served, - // and this page is only ever served with a session, so anything but - // `true` means the submit did not come from that screen. Checked rather - // than obeyed — the value used below is the CLI's. - if (payload.endSession !== true) { - return { error: 'That is not an answer this step can produce.' }; - } - - const raw = payload.remove; - if (!Array.isArray(raw)) return { error: 'That is not an answer this step can produce.' }; - const offered = new Set(p.files.map((f) => f.name)); - if (!raw.every((n) => typeof n === 'string' && offered.has(n))) { - return { error: 'That is not a file this sweep offered.' }; - } - - // Rebuilt from the CLI's own list so the order is the one the page - // showed and a duplicate cannot turn into a second unlink. - const remove = p.files.map((f) => f.name).filter((n) => raw.includes(n)); - return { done: true, result: { endSession: true, remove, cancelled: false } }; - }, - ); - return out as WebEndRecoverResult; -} diff --git a/src/ui/rotateScreens.ts b/src/ui/rotateScreens.ts deleted file mode 100644 index b9a8978f..00000000 --- a/src/ui/rotateScreens.ts +++ /dev/null @@ -1,325 +0,0 @@ -// `capy rotate`, served as compiled screens. -// -// This is the one command that already drew a route: `renderRotationPlan` -// prints ● ○ ◌ joined by │ and ┊ and then asks `Proceed?`. What it could not -// do is show that route to anyone who is not sitting at a TTY — and `Proceed?` -// is gated on `isTTY`, so the only approval the whole rotate → push → deploy -// chain has silently disappears on a piped run. An agent invoking `capy rotate -// --all` today rotates every live credential in the project with nothing -// asked. Under `--web` the gate exists for every caller. -// -// Three screens: -// -// rotate-plan-confirm the variable, the integration, and the one Y/N -// rotate-progress what the run actually did, stop by stop -// connect-live-gate reused from connect, because it is one condition -// -// Three separate serves rather than one page with client-side steps, because -// the CLI owns the state machine: it cannot know whether the picked variable -// needs the integration step until the answer comes back. -// -// The rail is `rotationPlan`'s. `renderRotationPlan` renders the same array, so -// the diagram in the terminal and the rail in the browser cannot drift. -// -// RENDERS NO KEY MATERIAL. A credential's fingerprint is the redacted `abc…xyz` -// form keep.lock already stores; the provider's pairing code is a one-time -// device code, not a credential, and is already on the terminal and in the -// provider's own browser tab. -import { runBrowserWizard } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { serveEndingPage } from './endingPage'; -import { stripAnsi } from './connectScreens'; -import type { - RotateAdvisory, - RotateCandidate, - RotateDeployResult, - RotateIntegration, - RotateKeyResult, - RotatePairing, - RotatePlanConfirmData, - RotatePlanStop, - RotateRunOutcome, - RotateRunStep, - RotateRunStop, - RotateProgressData, - RotateStep, -} from './screens/contract'; - -interface ServeOptions { - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -// --------------------------------------------------------------------------- -// rotate-plan-confirm -// --------------------------------------------------------------------------- - -export interface WebRotatePlanParams extends ServeOptions { - /** Which of the three questions this serve is asking. */ - step: RotateStep; - projectName: string; - branch: string; - /** A `capy-dev` binary: live keys are refused, direct deploys are dropped. */ - devMode: boolean; - all: boolean; - noPush: boolean; - /** The whole declared route, from `rotationPlan`. */ - stops: RotatePlanStop[]; - /** The variable step's rows, in the order `listAllVarsOnBranch` sorted them. */ - candidates?: RotateCandidate[]; - /** The integration step's rows — `listProviders()`, verbatim. */ - integrations?: RotateIntegration[]; - /** The variable the integration and plan steps are about. */ - varName?: string; - /** What the plan step would rotate. */ - targets?: RotateCandidate[]; - deployTargetCount?: number; - advisories?: RotateAdvisory[]; -} - -/** How each of the three questions is answered without a browser. */ -function nonTtyEscape(step: RotateStep, varName?: string): RotatePlanConfirmData['nonTty'] { - if (step === 'variable') { - return { - command: 'capy rotate ', - why: 'Rotating invalidates the credential in use now, so which one is never picked for you.', - }; - } - if (step === 'integration') { - return { - command: `capy rotate ${varName ?? ''} --provider stripe`, - why: 'Connecting an unmanaged variable replaces whatever is in it with a key the integration issues, so the integration is never guessed — not even when only one is registered.', - }; - } - return { - command: 'capy rotate --yes', - why: '--yes is the whole chain approved unattended: rotate, push and deploy. It is the only gate the chain has, which is why a piped run must state it rather than inherit it.', - }; -} - -export function buildRotatePlanData( - p: WebRotatePlanParams, - nonce: string, -): RotatePlanConfirmData { - return { - nonce, - step: p.step, - projectName: stripAnsi(p.projectName), - branch: p.branch, - devMode: p.devMode, - all: p.all, - noPush: p.noPush, - stops: p.stops, - ...(p.candidates ? { candidates: p.candidates } : {}), - ...(p.integrations - ? { integrations: p.integrations.map((i) => ({ ...i, description: stripAnsi(i.description) })) } - : {}), - ...(p.varName ? { varName: p.varName } : {}), - ...(p.targets ? { targets: p.targets } : {}), - ...(typeof p.deployTargetCount === 'number' - ? { deployTargetCount: p.deployTargetCount } - : {}), - ...(p.advisories && p.advisories.length > 0 - ? { advisories: p.advisories.map((a) => ({ ...a, detail: stripAnsi(a.detail) })) } - : {}), - nonTty: nonTtyEscape(p.step, p.varName), - }; -} - -/** Serve one step of the rotate plan and hand the reducer's verdict back. */ -async function serveRotateStep( - p: WebRotatePlanParams, - title: string, - doneMessage: string, - reduce: (payload: Record) => { done: true; result: unknown } | { error: string }, -): Promise { - return runBrowserWizard( - { - title, - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage, - renderFirst: (nonce) => renderScreen('rotate-plan-confirm', buildRotatePlanData(p, nonce)), - }, - async (_step, payload) => reduce(payload), - ); -} - -export interface WebRotateVariableResult { - variable: string; - cancelled: boolean; -} - -/** - * Which credential to rotate. - * - * The submitted name is resolved against the rows the CLI offered rather than - * trusted: what follows is a rotation that invalidates whatever is in that - * variable, and a name the screen could not have shown did not come from the - * screen. - */ -export async function askRotateVariableInBrowser( - p: WebRotatePlanParams, -): Promise { - const out = await serveRotateStep( - { ...p, step: 'variable' }, - `Rotate — ${p.projectName}`, - 'Picked — back to your terminal.', - (payload) => { - if (payload.__action === 'cancel') return { done: true, result: { variable: '', cancelled: true } }; - const variable = typeof payload.variable === 'string' ? payload.variable : ''; - if (!(p.candidates ?? []).some((c) => c.name === variable)) { - return { error: 'That variable is not on this branch.' }; - } - return { done: true, result: { variable, cancelled: false } }; - }, - ); - return out as WebRotateVariableResult; -} - -export interface WebRotateIntegrationResult { - provider: string; - cancelled: boolean; -} - -/** - * Which integration should own an unmanaged variable. - * - * Never pre-selected, however few are registered: off a TTY the CLI - * auto-picks the single provider with no output at all, for a variable the - * user never associated with it — and the run that follows replaces the value - * in that variable with a key the integration issues. Picking the integration - * is therefore a decision about a credential, not a filing choice. - */ -export async function askRotateIntegrationInBrowser( - p: WebRotatePlanParams, -): Promise { - const out = await serveRotateStep( - { ...p, step: 'integration' }, - `Connect ${p.varName ?? 'a variable'} — ${p.projectName}`, - 'Picked — back to your terminal.', - (payload) => { - if (payload.__action === 'cancel') return { done: true, result: { provider: '', cancelled: true } }; - const provider = typeof payload.provider === 'string' ? payload.provider : ''; - if (!(p.integrations ?? []).some((i) => i.name === provider)) { - return { error: 'That integration is not registered in this build.' }; - } - return { done: true, result: { provider, cancelled: false } }; - }, - ); - return out as WebRotateIntegrationResult; -} - -/** - * The single Y/N that authorises the whole chain. - * - * `!opts.skipPrompts && isTTY` drops this in the terminal the moment stdin is - * piped, which is every agent-driven run — so the one approval gate the - * destructive half of this command has vanishes exactly where nobody is - * watching. Here it is asked of every caller, and closing the window is a - * refusal rather than consent. - */ -export async function confirmRotatePlanInBrowser(p: WebRotatePlanParams): Promise { - const out = await serveRotateStep( - { ...p, step: 'plan' }, - `Rotation plan — ${p.projectName}`, - 'Approved — back to your terminal.', - (payload) => { - if (payload.__action === 'cancel') return { done: true, result: false }; - // The plan step's button sends exactly this. An approval for the whole - // rotate → push → deploy chain is not something to infer from a submit - // that did not say so. - if (payload.proceed !== true) { - return { error: 'That is not an answer the plan step can produce.' }; - } - return { done: true, result: true }; - }, - ); - return out === true; -} - -// --------------------------------------------------------------------------- -// rotate-progress -// --------------------------------------------------------------------------- - -export interface WebRotateProgressParams extends ServeOptions { - outcome: RotateRunOutcome; - projectName: string; - branch: string; - all: boolean; - noPush: boolean; - devMode: boolean; - /** The declared route, with the stops travelled marked done. */ - stops: RotateRunStop[]; - /** The chain, stop by stop. */ - steps: RotateRunStep[]; - /** One row per credential the run touched. */ - keys: RotateKeyResult[]; - deploy?: RotateDeployResult; - pairing?: RotatePairing; -} - -export function buildRotateProgressData(p: WebRotateProgressParams): RotateProgressData { - return { - outcome: p.outcome, - projectName: stripAnsi(p.projectName), - branch: p.branch, - all: p.all, - noPush: p.noPush, - devMode: p.devMode, - stops: p.stops, - steps: p.steps.map((s) => ({ - ...s, - ...(s.detail ? { detail: stripAnsi(s.detail) } : {}), - })), - keys: p.keys.map((k) => ({ - ...k, - // The provider's own words, printed by the CLI in red before they got - // here. A payload is not a terminal. - ...(k.detail ? { detail: stripAnsi(k.detail) } : {}), - ...(k.output ? { output: stripAnsi(k.output) } : {}), - ...(k.partialWrite ? { partialWrite: stripAnsi(k.partialWrite) } : {}), - })), - ...(p.deploy ? { deploy: p.deploy } : {}), - ...(p.pairing ? { pairing: p.pairing } : {}), - nonTty: { - command: 'capy rotate --yes', - why: 'Nothing on this page is a decision — it reports a run that already happened. The flag is how the same run is repeated without a browser.', - }, - }; -} - -/** - * Show what the rotation did, and do not ask for anything. - * - * A display-only ending: `serveEndingPage` serves it under `connect-src - * 'none'`, so a page that renders a rotation's outcome cannot open a socket to - * anywhere, including us. - * - * IT DOES NOT RETURN UNTIL THE BROWSER HAS THE PAGE. Every caller of this - * function is at the end of a run and several of them exit the process on the - * next line — and an exit closes the loopback server that is serving this. The - * `deploy-failed` page in particular reports keys that are live in Capy while - * every running system still holds the old ones, which is the single worst - * state this command can leave and the one a user must not be left guessing - * about. The wait is bounded by `timeoutMs`. - */ -export async function showRotateProgressInBrowser( - p: WebRotateProgressParams, -): Promise { - const { url } = await serveEndingPage('rotate-progress', buildRotateProgressData(p), { - ...(p.open === undefined ? {} : { open: p.open }), - ...(p.onListen ? { onListen: p.onListen } : {}), - ...(p.timeoutMs === undefined ? {} : { timeoutMs: p.timeoutMs }), - lead: 'What this rotation did, in your browser:', - }); - return url; -} diff --git a/src/ui/screens/serve.ts b/src/ui/screens/serve.ts index 796e31a9..bfa7f9ac 100644 --- a/src/ui/screens/serve.ts +++ b/src/ui/screens/serve.ts @@ -9,18 +9,10 @@ const DATA_PLACEHOLDER = '/*__CAPY_DATA__*/ null'; /** * How wide a window this document wants, travelling with the document. * - * The window is opened by `browserWizard`, which holds HTML and has never been - * told which screen it is — `WizardScreen` carries markup, not a name. The - * alternative was a `wide` flag on twenty-odd call sites, each free to forget - * it. Stamping it here means the one place that knows the screen's NAME is the - * one place that answers the question, and a screen cannot be served at the - * wrong size without someone editing this line. + * Stamped here because this is the one place that knows the screen's NAME. */ const WIDE_WINDOW_MARKER = ''; -/** Does this rendered document want the wide window? */ -export const wantsWideWindow = (html: string): boolean => html.includes(WIDE_WINDOW_MARKER); - /** * Content-Security-Policy — the browser header that limits what a page may * load, run and connect to — for locally served screens. diff --git a/src/ui/searchMatch.ts b/src/ui/searchMatch.ts new file mode 100644 index 00000000..57b83518 --- /dev/null +++ b/src/ui/searchMatch.ts @@ -0,0 +1,29 @@ +/** + * The one matching rule for every "type to filter" box in the CLI. + * + * It started life inside `capy secrets` search (CAP-678): the query is trimmed + * of surrounding whitespace and lowercased, and a candidate matches when its + * lowercased text contains that query as a substring. An empty (or + * whitespace-only) query matches everything. Pickers and the secrets screen + * share this so "does it match?" has exactly one answer. + */ + +/** The trimmed, lowercased form of a typed query — compute once per filter pass. */ +export function normalizeQuery(query: string): string { + return query.trim().toLowerCase(); +} + +/** Whether `text` matches `qLower` (already passed through `normalizeQuery`). */ +export function textMatches(text: string, qLower: string): boolean { + return text.toLowerCase().includes(qLower); +} + +/** The items whose `textOf` matches `query`; every item when the query is blank. Order is kept. */ +export function filterByQuery( + items: readonly T[], + query: string, + textOf: (item: T) => string, +): readonly T[] { + const qLower = normalizeQuery(query); + return qLower === '' ? items : items.filter((item) => textMatches(textOf(item), qLower)); +} diff --git a/src/ui/searchPrompts.ts b/src/ui/searchPrompts.ts new file mode 100644 index 00000000..b00f21a8 --- /dev/null +++ b/src/ui/searchPrompts.ts @@ -0,0 +1,53 @@ +/** + * Type-to-filter project pickers for the inquirer-based prompts. + * + * Single-select pickers use inquirer's own `search` prompt; this module only + * supplies its `source` (the shared match rule from `searchMatch`) so the + * rule lives in one place. Multi-select lives in `searchableCheckbox`. + * + * "Special" rows (the "New project" row, Vercel's "None of these" row) are + * ordinary rows: they are matched on their own label like every other row, + * and with no filter typed they sit exactly where the list put them. They are + * deliberately NOT pinned under a filter — a pinned row would become the + * highlighted one when nothing else matched, and Enter on a mistyped filter + * would then create a project. Clearing the filter brings them back. + */ +import { filterByQuery } from './searchMatch'; + +export interface PickChoice { + readonly name: string; + readonly value: V; +} + +/** `matched` with the default choice (if present) moved to the front; order otherwise unchanged. */ +function defaultFirst(matched: readonly PickChoice[], defaultValue: V | undefined): readonly PickChoice[] { + const at = defaultValue === undefined ? -1 : matched.findIndex((c) => c.value === defaultValue); + return at <= 0 ? matched : [matched[at], ...matched.filter((_, i) => i !== at)]; +} + +/** What a search prompt shows for `term`: the matching choices, default first. `term` is `undefined` or '' for "no filter". */ +export function searchableChoices( + choices: readonly PickChoice[], + term: string | undefined, + defaultValue?: V, +): readonly PickChoice[] { + return defaultFirst(filterByQuery(choices, term ?? '', (c) => c.name), defaultValue); +} + +/** + * The question to hand `inquirer.prompt([...])` for a searchable single-select. + * The answer is stored under `name`, as with the `list` prompt it replaces. + */ +export function searchableSelectQuestion(opts: { + readonly name: string; + readonly message: string; + readonly choices: readonly PickChoice[]; + readonly default?: V; +}): any { + return { + type: 'search', + name: opts.name, + message: opts.message, + source: (term: string | undefined) => searchableChoices(opts.choices, term, opts.default), + }; +} diff --git a/src/ui/searchableCheckbox.ts b/src/ui/searchableCheckbox.ts new file mode 100644 index 00000000..a02e8e92 --- /dev/null +++ b/src/ui/searchableCheckbox.ts @@ -0,0 +1,241 @@ +/** + * A checkbox prompt you can filter. + * + * inquirer's own checkbox binds `a` (toggle all), `i` (invert), the digits and + * space, so letters cannot simply type into a filter. This prompt keeps every + * one of those keys working exactly as before and adds an explicit search + * mode: + * + * - Outside search mode: up/down move, space toggles, `a` toggles all, `i` + * inverts, a digit toggles that row, Enter submits — unchanged. `/` + * enters search mode. + * - In search mode: printable characters type into the filter, Backspace + * edits it, up/down move within the matches, space still toggles, Enter + * still submits. Esc leaves search mode and clears the filter (the cursor + * stays on the row it was on). `a`, `i` and digits are filter text here. + * + * Ticks live on the underlying list, not on what is visible: rows hidden by + * the filter keep their tick and are still returned on submit. + * + * The key handling is a pure function (`stepCheckboxKey`) so it can be tested + * without a terminal; the prompt itself is a thin `@inquirer/core` shell. + */ +import { + createPrompt, + useState, + useKeypress, + usePrefix, + usePagination, + makeTheme, + isUpKey, + isDownKey, + isSpaceKey, + isEnterKey, + isBackspaceKey, + isNumberKey, +} from '@inquirer/core'; +import { filterByQuery } from './searchMatch'; + +const DIM = (s: string) => `\x1b[90m${s}\x1b[0m`; +const ACCENT = (s: string) => `\x1b[36m${s}\x1b[0m`; + +export interface CheckboxChoice { + readonly name: string; + readonly value: V; + readonly checked?: boolean; +} + +/** The slice of a readline keypress event this prompt looks at. */ +export interface CheckboxKey { + readonly name: string; + readonly ctrl: boolean; + readonly sequence?: string; + readonly meta?: boolean; +} + +export interface CheckboxState { + /** Indices (into the full choice list) that are ticked, ascending. */ + readonly checked: readonly number[]; + readonly searching: boolean; + readonly query: string; + /** Position within the VISIBLE rows. */ + readonly active: number; +} + +export type CheckboxStep = + | { readonly kind: 'state'; readonly state: CheckboxState } + | { readonly kind: 'submit' }; + +/** Indices (into the full list) of the rows the filter lets through. */ +export function visibleIndices(names: readonly string[], query: string): readonly number[] { + return filterByQuery( + names.map((name, index) => ({ name, index })), + query, + (row) => row.name, + ).map((row) => row.index); +} + +export function initialCheckboxState(choices: readonly { readonly checked?: boolean }[]): CheckboxState { + return { + checked: choices.flatMap((c, i) => (c.checked ? [i] : [])), + searching: false, + query: '', + active: 0, + }; +} + +/** `checked` with `index` flipped; stays ascending. `count` is the full choice count. */ +const toggled = (checked: readonly number[], index: number, count: number): readonly number[] => + Array.from({ length: count }, (_, i) => i).filter((i) => (i === index ? !checked.includes(i) : checked.includes(i))); + +function isPrintable(key: CheckboxKey): boolean { + const seq = key.sequence ?? ''; + return seq.length === 1 && !key.ctrl && !key.meta && seq >= ' ' && seq !== '\x7f'; +} + +const same = (state: CheckboxState): CheckboxStep => ({ kind: 'state', state }); + +/** Keys that work the same in and out of search mode. `null` when the key is not one of them. */ +function stepShared(state: CheckboxState, visible: readonly number[], count: number, key: CheckboxKey): CheckboxStep | null { + if (isEnterKey(key)) return { kind: 'submit' }; + if (isUpKey(key) || isDownKey(key)) { + const offset = isUpKey(key) ? -1 : 1; + return visible.length === 0 + ? same(state) + : same({ ...state, active: (state.active + offset + visible.length) % visible.length }); + } + if (isSpaceKey(key)) { + const target = visible[state.active]; + return same(target === undefined ? state : { ...state, checked: toggled(state.checked, target, count) }); + } + return null; +} + +function stepSearching(state: CheckboxState, names: readonly string[], key: CheckboxKey): CheckboxStep { + const visible = visibleIndices(names, state.query); + const shared = stepShared(state, visible, names.length, key); + if (shared) return shared; + if (key.name === 'escape') { + // Leave search mode; keep the cursor on the row it was on. + return same({ ...state, searching: false, query: '', active: visible[state.active] ?? 0 }); + } + if (isBackspaceKey(key)) return same({ ...state, query: state.query.slice(0, -1), active: 0 }); + if (isPrintable(key)) return same({ ...state, query: state.query + (key.sequence ?? ''), active: 0 }); + return same(state); +} + +function stepBrowsing(state: CheckboxState, names: readonly string[], key: CheckboxKey): CheckboxStep { + const visible = visibleIndices(names, ''); + const shared = stepShared(state, visible, names.length, key); + if (shared) return shared; + if (key.sequence === '/') return same({ ...state, searching: true, query: '' }); + if (key.name === 'a' && !key.ctrl) { + const all = names.map((_, i) => i); + return same({ ...state, checked: state.checked.length < names.length ? all : [] }); + } + if (key.name === 'i' && !key.ctrl) { + return same({ ...state, checked: names.flatMap((_, i) => (state.checked.includes(i) ? [] : [i])) }); + } + if (isNumberKey(key)) { + const target = Number(key.name) - 1; + return target >= 0 && target < names.length + ? same({ ...state, active: target, checked: toggled(state.checked, target, names.length) }) + : same(state); + } + return same(state); +} + +/** One keypress applied to the prompt state. Pure. */ +export function stepCheckboxKey( + state: CheckboxState, + names: readonly string[], + key: CheckboxKey, +): CheckboxStep { + return state.searching ? stepSearching(state, names, key) : stepBrowsing(state, names, key); +} + +export interface SearchableCheckboxConfig { + readonly message: string; + readonly choices: readonly CheckboxChoice[]; + /** The help text under the list (same shape as inquirer checkbox's `instructions`). */ + readonly instructions?: string; + readonly theme?: { readonly icon?: { readonly checked?: string; readonly unchecked?: string } }; + readonly pageSize?: number; + /** `true` to accept, or a string to refuse with that message. */ + readonly validate?: (values: readonly V[]) => boolean | string | Promise; +} + +interface PromptState { + readonly box: CheckboxState; + readonly error: string | undefined; +} + +export const searchableCheckbox = createPrompt>( + (config, done) => { + const { choices, pageSize = 7, validate = () => true } = config; + const names = choices.map((c) => c.name); + const theme = makeTheme({ icon: { checked: '◉', unchecked: '◯', cursor: '❯' } } as any, config.theme as any) as any; + const icon = { + checked: config.theme?.icon?.checked ?? '◉', + unchecked: config.theme?.icon?.unchecked ?? '◯', + cursor: '❯', + }; + const [status, setStatus] = useState<'idle' | 'done'>('idle'); + const prefix = usePrefix({ status, theme }); + const [promptState, setPromptState] = useState({ + box: initialCheckboxState(choices), + error: undefined, + }); + const { box, error } = promptState; + + useKeypress(async (key, rl) => { + rl.clearLine(0); + const step = stepCheckboxKey(box, names, key as CheckboxKey); + if (step.kind === 'state') { + setPromptState({ box: step.state, error: undefined }); + return; + } + const values = box.checked.map((i) => choices[i].value); + const verdict = await validate(values); + if (verdict === true) { + setStatus('done'); + done(values); + return; + } + setPromptState({ box, error: typeof verdict === 'string' && verdict ? verdict : 'You must select a valid value' }); + }); + + // Hooks run on every render, in the same order — before any early return. + const visible = visibleIndices(names, box.query); + const page = usePagination({ + items: visible, + active: box.active, + pageSize, + loop: true, + renderItem({ item, isActive }) { + const mark = box.checked.includes(item) ? icon.checked : icon.unchecked; + const line = `${isActive ? icon.cursor : ' '}${mark} ${names[item]}`; + return isActive ? theme.style.highlight(line) : line; + }, + }); + + const message = theme.style.message(config.message, status); + if (status === 'done') { + const answer = theme.style.answer(box.checked.map((i) => names[i]).join(', ')); + return [prefix, message, answer].filter(Boolean).join(' '); + } + const searchBar = box.searching ? `${DIM('search:')} ${ACCENT(box.query)}${DIM('▏')}` : ''; + + return [ + [prefix, message].filter(Boolean).join(' '), + searchBar, + page, + ' ', + error ? theme.style.error(error) : '', + config.instructions ?? '', + ] + .filter(Boolean) + .join('\n') + .trimEnd(); + }, +) as unknown as (config: SearchableCheckboxConfig, context?: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream }) => Promise; diff --git a/src/ui/secretIntakeScreen.ts b/src/ui/secretIntakeScreen.ts deleted file mode 100644 index ee54a3c3..00000000 --- a/src/ui/secretIntakeScreen.ts +++ /dev/null @@ -1,190 +0,0 @@ -// `capy add --web`, served as the compiled `secret-intake` screen. -// -// Replaces intakePage.ts, which hand-wrote its HTML. What the hand-written -// page could not do is the reason it is gone: it was the one screen in the -// product whose entire job is naming and collecting variables, and it checked -// neither. `my key` was accepted as a variable name; two rows could claim the -// same name and the second silently won; a row with a name and no value was -// dropped without a word; a value pasted with a trailing space went to the -// server exactly as pasted; and the value box was a plain textarea, so the -// credential this page exists to keep away from the agent sat in the DOM in -// the clear for any screenshare or screenshot. -// -// The promise the old page carried is kept to the word: the values are typed -// into a page the CLI serves on loopback and encrypted in-process — they never -// pass back through the terminal or the model. This transport prints and logs -// nothing but variable NAMES. -import { runBrowserWizard, BrowserRefusal } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { VENDOR_LOGOS } from './vendorLogos'; -import type { IntakeVar, SecretIntakeData } from './screens/contract'; - -/** The CLI's variable-name rule, shared with `addCommand`. */ -const VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; - -/** - * A model-supplied "where to find this" link is rendered as a clickable - * anchor, so only http(s) survives — never `javascript:`, never `data:`. The - * screen re-checks it as well; this is the first of the two layers. - */ -export const safeHttpUrl = (u: string | undefined): string | undefined => { - const t = u?.trim(); - return t && /^https?:\/\//i.test(t) ? t : undefined; -}; - -// Common dashboard/console subdomains stripped so we key off the vendor's -// registrable domain (dashboard.stripe.com → stripe.com). -const VENDOR_SUBDOMAIN_RE = /^(www|dashboard|console|app|api|manage|admin|portal|my|account|secure)\./i; - -/** Variable-name token → registrable domain, e.g. `stripe` → `stripe.com`. */ -const DOMAIN_BY_TOKEN = new Map(); -for (const domain of Object.keys(VENDOR_LOGOS)) { - const token = domain.split('.')[0]; - if (!DOMAIN_BY_TOKEN.has(token)) DOMAIN_BY_TOKEN.set(token, domain); -} - -/** - * Which vendor THIS variable belongs to, or nothing. - * - * Two signals, strongest first: the per-variable help link's registrable - * domain, then a token in the variable's own name that matches a known vendor - * (`STRIPE_SECRET_KEY` → stripe.com, which is how `capy add STRIPE_SECRET_KEY` - * from a bare terminal still gets its mark). Ambiguity resolves to nothing - * rather than to a guess: a wrong logo beside a credential box is a claim - * about where the value should come from. - */ -export function vendorDomainFor(v: { name: string; helpUrl?: string }): string | undefined { - const url = safeHttpUrl(v.helpUrl); - if (url) { - let host: string; - try { - host = new URL(url).hostname; - } catch { - return undefined; - } - host = host.replace(VENDOR_SUBDOMAIN_RE, ''); - const labels = host.split('.'); - return (labels.length > 2 ? labels.slice(-2).join('.') : host).toLowerCase(); - } - - const matched = new Set(); - for (const token of v.name.toLowerCase().split(/[^a-z0-9]+/)) { - const domain = DOMAIN_BY_TOKEN.get(token); - if (domain) matched.add(domain); - } - return matched.size === 1 ? [...matched][0] : undefined; -} - -export interface WebIntakeParams { - /** Suggested variables (name + optional per-variable help link). */ - vars: Array<{ name: string; helpUrl?: string }>; - /** Short note shown above the form. Display only. */ - reason?: string; - open: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -export interface SecretPair { - name: string; - value: string; -} - -export function buildSecretIntakeData(p: WebIntakeParams, nonce: string): SecretIntakeData { - const vars: IntakeVar[] = p.vars.map((v) => { - const spec: IntakeVar = { name: v.name }; - const helpUrl = safeHttpUrl(v.helpUrl); - if (helpUrl) spec.helpUrl = helpUrl; - // Bundled inline SVG, resolved from the domain. This page NEVER makes an - // external request — see the no-network rule in ./vendorLogos. - const domain = vendorDomainFor(v); - const logo = domain ? VENDOR_LOGOS[domain] : undefined; - if (logo) spec.logo = logo; - return spec; - }); - - const data: SecretIntakeData = { - nonce, - vars, - nonTty: { - command: 'capy add --non-tty', - why: 'The answer here is the credential itself, so no flag can carry it: a value on a command line lands in shell history, the process table and every log that captures it. Headless, this step exits 3 (needs input) and hands off to a terminal — there is nothing to pass.', - }, - }; - if (p.reason) data.reason = p.reason; - return data; -} - -/** - * Validate + normalize the submitted `{name,value}[]`. - * - * Names only — values pass through untouched, because a value is whatever the - * user pasted and this is not the place to decide it is wrong. - */ -export function parseVars(input: unknown): SecretPair[] | null { - if (!Array.isArray(input)) return null; - const out: SecretPair[] = []; - for (const item of input) { - if (!item || typeof item !== 'object') return null; - const rec = item as Record; - const name = typeof rec.name === 'string' ? rec.name.trim() : ''; - const value = rec.value; - if (!VAR_RE.test(name) || typeof value !== 'string') return null; - out.push({ name, value }); - } - return out.length > 0 ? out : null; -} - -/** - * Open the intake form and run `onSubmit` with the pairs the user entered. - * - * The save runs INSIDE the request, so the browser learns whether it really - * succeeded: a throw becomes a 500 with the reason and the form stays live and - * retryable, exactly as the hand-written page behaved. Values are handled - * in-process — never printed, logged, or returned. - * - * Resolves whether or not anything was entered: `onSubmit` having run is the - * only signal that it was, and the caller has to check it. The form is the - * whole flow and carries the overwrite warning, so refusing it means leaving — - * which is exactly what refusing the terminal's confirm meant, and it must not - * come back as a save of nothing. - */ -export function runWebIntake( - params: WebIntakeParams, - onSubmit: (pairs: SecretPair[]) => Promise, -): Promise { - return runBrowserWizard( - { - title: 'Add secrets', - firstScreen: { html: '', standalone: true }, - open: params.open, - onListen: params.onListen, - timeoutMs: params.timeoutMs, - renderFirst: (nonce) => renderScreen('secret-intake', buildSecretIntakeData(params, nonce)), - // The intake screen has no Cancel control, so closing the window is the - // only refusal there is — and it is the answer to the overwrite warning - // this form carries. It has to end the run, not be waited out. - closeIsRefusal: { result: undefined }, - }, - async (_step, payload) => { - const pairs = parseVars(payload.vars); - // The screen refuses an illegal name while it is typed and holds its - // button, so this can only arrive from something that is not the screen. - // Encrypting a variable nobody could have named is the failure it stops. - if (!pairs) return { error: 'each variable needs a valid NAME and a value' }; - await onSubmit(pairs); - return { done: true, result: undefined }; - }, - ).then( - () => undefined, - (err: unknown) => { - // A form nobody filled in saved nothing, which is the same fact as a - // window that was closed. The caller reads that off `onSubmit` having - // run, so both arrive there the same way instead of one of them being a - // thrown "Timed out waiting for the browser (5 minutes)." - if (err instanceof BrowserRefusal && err.reason === 'timeout') return undefined; - throw err; - }, - ); -} diff --git a/src/ui/secretTableScreen.ts b/src/ui/secretTableScreen.ts deleted file mode 100644 index b6a8a07f..00000000 --- a/src/ui/secretTableScreen.ts +++ /dev/null @@ -1,630 +0,0 @@ -// `capy edit`, served as compiled screens. -// -// Replaces the 793-line alternate-screen TUI in editScreen.ts for `--web`. The -// terminal path is untouched and still the only thing a person at a real -// terminal gets; what changes here is the RENDERING of the same questions. -// -// Why this flow was worth converting rather than left alone: the TUI has no -// TTY guard at all. Run `capy edit` headlessly and it writes an entire ANSI -// screen into the agent's captured stdout and then blocks forever on a stdin -// that never delivers a key. There is no `--json`, no MCP tool and no browser -// path, so editing one secret is currently something only a human sitting at a -// terminal can do. -// -// THESE SCREENS RENDER REAL SECRET VALUES — the only ones in the product that -// do. Three rules hold everywhere below and are tested: -// -// 1. No plaintext is ever in a payload. `buildSecretTableData` and -// `buildSecretValueEditorData` receive the decrypted rows because that is -// where the CLI holds them, and neither copies a value out. Every reveal -// is its own round trip, so the served HTML — the thing a screenshot, a -// "save page as" or a devtools copy captures — never holds more than the -// one value the user asked for. -// 2. `formatSnippet` returns anything six characters or shorter VERBATIM -// (statusCommand.ts:115), so the terminal's column labelled as masked -// prints short secrets whole. Here the payload carries NO snippet for -// those values and says why — see `snippetIsWholeValue`. -// 3. Nothing here logs, prints or serialises a value. It leaves the process -// only in the body of one loopback response the user asked for, and comes -// back only in the body of the POST that saves it. -import { runBrowserWizard, BrowserRefusal, type WebRefusal } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { - reclassifyRow, - renderInlineValue, - updatedLabelForRow, - type EditRow, -} from './editScreen'; -import { formatSnippet } from '../commands/statusCommand'; -import { secretEditPlan } from '../core/secretEditPlan'; -import type { - SecretTableData, - SecretTableRemoteGap, - SecretTableRow, - SecretValueEditorData, -} from './screens/contract'; - -/** - * Strip terminal colour codes on the way into a payload. - * - * Applied to the strings the CLI also PRINTS — the project name it puts in - * headers, the age label it formats — because a payload is not a terminal and - * an escape renders as a literal `[90m` in a browser. Never applied to a - * variable NAME or to a value: both have to round-trip byte for byte. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -/** - * A value that cannot be stored, in the kit's own words. - * - * The rule is the CLI's: `sanitizePastedText` (editScreen.ts:92) keeps LF and - * TAB and drops every other control character, so a byte that would not - * survive a paste in the terminal must not survive a submit from the browser - * either. The screen refuses these while they are typed and holds its button, - * so one arriving over the wire means the submit did not come from the screen. - * - * The message never quotes the value. It is announced by `role="alert"` and - * lands in every screenshot of the page, which is the one thing these screens - * exist to prevent. - */ -const CONTROL_CHAR = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/; - -/** A connector owns this value; `capy rotate` writes it. */ -export interface ManagedBy { - provider: string; - expiresInDays?: number; -} - -/** One variable, as `capy edit` knows it. Carries plaintext; a payload does not. */ -export interface WebSecretRow extends EditRow { - /** Edited in this session and not yet committed. */ - dirty?: boolean; - managedBy?: ManagedBy; -} - -export interface WebSecretTableParams { - projectName: string; - branch: string; - /** `local` has no organization and no server: the commit does not push. */ - mode: 'server' | 'local'; - rows: WebSecretRow[]; - /** - * Why there is no remote side, when there is none. The terminal renders a - * failed fetch and a project nobody has ever pushed identically — `{n} ? / - * remote unavailable` — so an offline run is indistinguishable from a fresh - * project. Minted where the condition is known so they are not. - */ - remoteGap?: SecretTableRemoteGap; - /** The remote blob came from the on-disk keep cache rather than the service. */ - remoteFromCache?: boolean; - /** - * Local ciphertext that would not decrypt. The terminal drops these in an - * empty `catch` and then deletes their pins on the next commit. - */ - undecryptableKeys?: string[]; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** The CLI's status words, in the screen's vocabulary. Same five conditions. */ -function tableStatus(status: EditRow['status']): SecretTableRow['status'] { - return status === 'in sync' ? 'in-sync' : status; -} - -/** Which of the three value states this row is in, without saying what it holds. */ -function valueState(row: WebSecretRow): SecretTableRow['value'] { - const value = row.localValue ?? row.remoteValue; - if (value === undefined) return 'absent'; - return value === '' ? 'empty' : 'set'; -} - -/** - * Recency ranks for the UPDATED column. - * - * The column's text is already humanised, and `3 days ago` sorts between `1 - * hour` and `5 minutes` as a string — so the screen is handed an order rather - * than left to re-parse the prose it was given. The contract asks for two - * things and both are load-bearing: - * - * - monotonic in recency across the whole table, newest at 0, and rows that - * READ the same share a rank. Ranking by the label rather than by the raw - * timestamp is what makes the second half true: two rows three days apart - * to the second both say "3 days ago", and a table that ordered them by - * milliseconds would draw an order the reader cannot see. - * - a row with no timestamp gets an explicit LAST rank rather than being left - * out. Left optional it falls to a sentinel, and a sentinel is a number - * nobody checks. - * - * Local mode has no server timestamps at all, only `uncommitted` and - * `committed`. An uncommitted change is by definition more recent than the - * last commit, so those two are the whole order. - */ -function updatedRanks(rows: WebSecretRow[], localMode: boolean): Map { - const ranks = new Map(); - - if (localMode) { - for (const row of rows) ranks.set(row.key, row.status === 'in sync' ? 1 : 0); - return ranks; - } - - const stamped = rows - .filter((r) => r.changedAt) - .sort((a, b) => new Date(b.changedAt!).getTime() - new Date(a.changedAt!).getTime()); - - let rank = -1; - let previousLabel: string | null = null; - for (const row of stamped) { - if (row.updatedLabel !== previousLabel) { - rank += 1; - previousLabel = row.updatedLabel; - } - ranks.set(row.key, rank); - } - - // Everything with no stamp reads "—" and belongs behind everything that has - // one, on one shared rank rather than in an arbitrary order. - const last = rank + 1; - for (const row of rows) if (!ranks.has(row.key)) ranks.set(row.key, last); - return ranks; -} - -export function buildSecretTableData( - p: WebSecretTableParams, - nonce: string, - view: SecretTableData['view'] = 'table', -): SecretTableData { - const ranks = updatedRanks(p.rows, p.mode === 'local'); - const changeCount = p.rows.filter((r) => r.dirty).length; - // Stripped ONCE, so every place the name appears in this payload — the - // header, the rail's destination — carries the same bytes. Two call sites - // stripping the same string is one call site away from a `[90m` rendering - // literally in a browser. - const projectName = stripAnsi(p.projectName); - - const rows: SecretTableRow[] = p.rows.map((row) => { - const value = row.localValue ?? row.remoteValue; - const state = valueState(row); - const cell: SecretTableRow = { - key: row.key, - value: state, - status: tableStatus(row.status), - updated: stripAnsi(row.updatedLabel), - updatedRank: ranks.get(row.key) ?? 0, - }; - // Metadata, not content: a length is the only way to notice a paste that - // lost its tail, and the mask never varies with it, so the column itself - // gives nothing away. - if (value !== undefined) cell.length = value.length; - if (value !== undefined && value.includes('\n')) cell.lines = value.split('\n').length; - if (row.dirty) cell.dirty = true; - if (row.managedBy) cell.managedBy = row.managedBy; - return cell; - }); - - const data: SecretTableData = { - nonce, - projectName, - branch: p.branch, - mode: p.mode, - stops: secretEditPlan({ - variableCount: p.rows.length, - changeCount, - localMode: p.mode === 'local', - destination: `${projectName}/${p.branch}`, - at: view === 'confirm-commit' ? 'review' : view === 'table' ? 'edit' : 'result', - discarded: view === 'cancelled', - }), - rows, - view, - nonTty: { - command: 'capy list --json', - why: 'Lists every variable and its status without opening anything. Values are never included — reading one is a step that refuses off a terminal.', - }, - nonTtyReveal: { - command: 'capy edit --non-tty', - why: 'Exits 3 and prints nothing. A value is only ever put in front of a person, so no flag can ask for one.', - }, - }; - - if (p.remoteGap) data.remoteGap = p.remoteGap; - if (p.remoteFromCache) data.remoteFromCache = true; - if (p.undecryptableKeys?.length) data.undecryptableKeys = p.undecryptableKeys; - - // The audit's worst finding, computed rather than passed in so it cannot go - // stale: `saveLocalEdits` builds the whole `.env` from the local plaintext - // alone and then deletes the branch entry of every variable not in it - // (editCommand.ts:238,262-268), so anything pinned or remote but absent - // locally loses its entry on ANY commit — one unrelated edit drops a - // teammate's variable, silently. Derived from the rows the table is being - // drawn from, so queueing an edit for such a key takes it off this list in - // the same render that marks the row dirty. - const dropped = p.rows.filter((r) => r.localValue === undefined).map((r) => r.key); - if (dropped.length > 0) data.droppedOnCommitKeys = dropped; - return data; -} - -// --------------------------------------------------------------------------- -// secret-value-editor -// --------------------------------------------------------------------------- - -export interface WebSecretValueParams { - projectName: string; - branch: string; - mode: 'server' | 'local'; - row: WebSecretRow; - /** Whether the remote blob was read at all this run. */ - remoteAvailable: boolean; - /** Why there is no remote side, when there is none. */ - remoteUnavailable?: SecretTableRemoteGap; - /** Uncommitted edits across the whole session, this one included. */ - pendingCount: number; - managedBy?: { provider: string; expiresLabel?: string }; - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; -} - -export function buildSecretValueEditorData( - p: WebSecretValueParams, - nonce: string, -): SecretValueEditorData { - const value = p.row.localValue ?? p.row.remoteValue; - const hasValue = value !== undefined; - // `formatSnippet` returns anything six characters or shorter VERBATIM, so - // for those there is no masked form to send and the payload carries none. - // Naming the leak is not containing it: a snippet that IS the value is the - // secret sitting in the served HTML before anybody asked to see it. - const snippetIsWholeValue = hasValue && value!.length <= 6; - - const data: SecretValueEditorData = { - nonce, - projectName: stripAnsi(p.projectName), - branch: p.branch, - mode: p.mode, - key: p.row.key, - status: tableStatus(p.row.status), - snippetIsWholeValue, - hasLocalValue: p.row.localValue !== undefined, - hasRemoteValue: p.row.remoteValue !== undefined, - isEmptyString: value === '', - multiline: hasValue && value!.includes('\n'), - valueLength: hasValue ? value!.length : 0, - updatedLabel: stripAnsi(p.row.updatedLabel), - remoteAvailable: p.remoteAvailable, - dirty: p.row.dirty === true, - pendingCount: p.pendingCount, - view: 'inspect', - nonTty: { - command: 'capy edit --non-tty', - why: 'A secret value can never come from a flag — it would land in shell history, the process table and every log that captures a command line. There is no argument that answers this step: headless, it exits 3 (needs input) and hands off to a terminal.', - }, - }; - - // The CLI's own masked form, and only while it really is masked. A newline - // inside a snippet is rendered the way the terminal renders one, so the two - // surfaces show one value one way. - if (hasValue && !snippetIsWholeValue) { - data.snippet = renderInlineValue(formatSnippet(value!)); - } - if (p.remoteUnavailable) data.remoteUnavailable = p.remoteUnavailable; - if (p.managedBy) data.managedBy = p.managedBy; - return data; -} - -// --------------------------------------------------------------------------- -// serving -// --------------------------------------------------------------------------- - -/** - * What the table asked the CLI to do next. - * - * `cancel` carries WHICH refusal it was, because the three end the run - * differently in the terminal and only one of them is a control being clicked. - * `closed` and `timeout` also mean there is no page left to serve anything - * to — re-rendering the table into a browser that has gone away is how the - * five-minute hang was reached in the first place. - */ -export type SecretTableOutcome = - | { action: 'edit'; key: string } - | { action: 'commit' } - | { action: 'cancel'; reason: WebRefusal }; - -/** What the value editor decided. `cancel` queues nothing. */ -export type SecretValueOutcome = - | { action: 'save'; key: string; value: string } - | { action: 'cancel'; reason: WebRefusal }; - -/** - * A step nobody answered, as this flow's own refusal. - * - * `capy edit` has no failure mode called "the browser did not say anything": - * a run that wrote nothing is a run that wrote nothing, and it ends the way - * the terminal's `q` ends — a line saying so, and exit 0. A real fault (a - * socket that would not bind) is not a `BrowserRefusal` and still throws. - */ -async function refusalIsCancel(run: Promise): Promise { - try { - return await run; - } catch (err) { - if (err instanceof BrowserRefusal && err.reason === 'timeout') { - return { action: 'cancel', reason: 'timeout' }; - } - throw err; - } -} - -/** The plaintext for one variable, by the terminal's own rule (editScreen.ts:719). */ -const plaintextOf = (row: WebSecretRow | undefined, side: 'current' | 'remote'): string | undefined => - side === 'remote' ? row?.remoteValue : (row?.localValue ?? row?.remoteValue); - -/** - * Serve the variable table and wait for what it wants next. - * - * `reveal` is answered inline — the flow has not moved, and a reveal that - * finished the wizard would tear the page down to show one value. - * - * `onCommit` runs INSIDE the request, the way the intake's save always has, so - * the browser reflects what really happened. Resolving first and pushing after - * would render "Committed — 4 changes are now pushed to staging" and then fail - * the push into a terminal nobody is looking at; a throw here comes back as a - * refusal and the screen's own line reads "… — nothing was changed." - */ -export async function serveSecretTable( - p: WebSecretTableParams, - onCommit?: () => Promise, -): Promise { - const byKey = new Map(p.rows.map((r) => [r.key, r])); - - const out = await refusalIsCancel(runBrowserWizard( - { - title: `Variables — ${p.projectName}/${p.branch}`, - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Answered — back to your terminal.', - renderFirst: (nonce) => renderScreen('secret-table', buildSecretTableData(p, nonce)), - // The table has no Cancel of its own until a row is dirty — `Review N - // changes` is the only control that leads to one — so on the state every - // run OPENS in, closing the window is the only way out a person has. - // Wiring it here is what makes that an ending instead of a five-minute - // wait and a thrown error. It discards: a closed window commits nothing. - closeIsRefusal: { result: { action: 'cancel', reason: 'closed' } satisfies SecretTableOutcome }, - }, - async (_step, payload) => { - const action = typeof payload.__action === 'string' ? payload.__action : ''; - - if (action === 'reveal') { - const key = typeof payload.key === 'string' ? payload.key : ''; - const row = byKey.get(key); - // The table only draws rows it was given, so a key outside them did - // not come from the table — and handing a plaintext to a request the - // screen could not have made is the whole failure this prevents. - if (!row) return { error: 'That variable is not on this table.' }; - const value = plaintextOf(row, 'current'); - if (value === undefined) return { error: 'That variable has no value to show.' }; - return { body: { value } }; - } - - if (action === 'edit') { - const key = typeof payload.key === 'string' ? payload.key : ''; - const row = byKey.get(key); - if (!row) return { error: 'That variable is not on this table.' }; - // The screen disables its edit control on a connector-owned row, so - // this can only arrive from something that is not the screen. Letting - // it through would queue an edit the next rotation silently overwrites. - if (row.managedBy) { - return { error: `${row.managedBy.provider} writes this value — use capy rotate ${row.key}.` }; - } - return { done: true, result: { action: 'edit', key } }; - } - - if (action === 'commit') { - // A throw propagates as a 500 carrying the reason, which is exactly the - // shape the screen reads: it stays on the review stop with the edits - // still queued. - if (onCommit) await onCommit(); - return { done: true, result: { action: 'commit' } }; - } - if (action === 'cancel') { - return { done: true, result: { action: 'cancel', reason: 'declined' } }; - } - - return { error: 'That is not an action this screen offers.' }; - }, - )); - return out as SecretTableOutcome; -} - -/** - * Serve one variable's editor and wait for the buffer. - * - * Reveal arrives on `/reveal` here rather than on `/submit`: reading a value is - * a question, not an answer, and the page stays exactly where it was. - */ -export async function serveSecretValueEditor(p: WebSecretValueParams): Promise { - const reveal = async ( - _step: number, - payload: Record, - ): Promise<{ body: Record } | { error: string }> => { - const key = typeof payload.key === 'string' ? payload.key : ''; - const side = payload.side === 'remote' ? 'remote' : payload.side === 'current' ? 'current' : null; - // This editor is opened for ONE variable and the screen posts its own key, - // so anything else is a request to read a secret nobody opened. - if (key !== p.row.key) return { error: 'That variable is not open in this editor.' }; - if (side === null) return { error: 'That is not a side this editor can read.' }; - const value = plaintextOf(p.row, side); - if (value === undefined) return { error: 'There is no value on that side to read.' }; - return { body: { value } }; - }; - - const out = await refusalIsCancel(runBrowserWizard( - { - title: `${p.row.key} — ${p.projectName}/${p.branch}`, - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Saved — back to your terminal.', - renderFirst: (nonce) => renderScreen('secret-value-editor', buildSecretValueEditorData(p, nonce)), - routes: { '/reveal': reveal }, - // This editor DOES have a Cancel, and it means "back to the table". - // Closing the window is the other thing: there is no table to go back - // to, so it ends the run rather than queueing nothing and looping. - closeIsRefusal: { result: { action: 'cancel', reason: 'closed' } satisfies SecretValueOutcome }, - }, - async (_step, payload) => { - const action = typeof payload.__action === 'string' ? payload.__action : ''; - if (action === 'cancel') return { done: true, result: { action: 'cancel', reason: 'declined' } }; - if (action !== 'save') return { error: 'That is not an action this screen offers.' }; - - if (payload.key !== p.row.key) { - return { error: 'That variable is not open in this editor.' }; - } - if (typeof payload.value !== 'string') { - // The buffer is a string on the screen and nowhere else, so this is a - // malformed submit rather than a value the user typed. - return { error: 'That is not a value this editor can save.' }; - } - if (CONTROL_CHAR.test(payload.value)) { - return { - error: - 'Something in this value cannot be stored — it contains a control character. Copy it again, or delete the stray character.', - }; - } - return { done: true, result: { action: 'save', key: p.row.key, value: payload.value } }; - }, - )); - return out as SecretValueOutcome; -} - -// --------------------------------------------------------------------------- -// the loop -// --------------------------------------------------------------------------- - -export interface WebEditContext { - /** - * Commit (and in server mode, push) the given edits. The same callback the - * TUI is handed, resolving to the server-assigned `changed_at` per variable. - */ - saveLocalEdits: (edits: Record) => Promise>; -} - -export interface WebEditParams extends WebSecretTableParams { - remoteAvailable: boolean; -} - -/** - * `capy edit --web`, whole. - * - * One page per question, because the table screen cannot become the editor on - * its own: a loopback server cannot push a navigation, and — unlike every - * `Wizard`-based screen — `secret-table` does not reload when the CLI answers - * with `{ next: true }`. It says "Opening in the value editor…" and - * waits, so the CLI does exactly that: it opens the editor. See the note in - * the parcel report; the fix belongs in packages/ui, not here. - */ -export async function runSecretEditorInBrowser( - p: WebEditParams, - ctx: WebEditContext, -): Promise { - // Edits queued in this session, exactly as the TUI queues them: nothing - // reaches .env, keep.lock or the server until the commit stop. - const pending = new Map(); - let rows = p.rows; - - /** - * Say what happened, on every way out. - * - * The run ends in the terminal whichever ending it reached, and the three - * refusals are not the same fact: one is a control that was clicked, one is - * a window that was closed, and one is a browser that never came back. A - * run that printed nothing is the failure this replaces — the CLI used to - * exit with `Timed out waiting for the browser (5 minutes).` and no mention - * of the edits it was holding. - */ - const reportRefusal = (reason: WebRefusal): void => { - const held = - pending.size > 0 ? ` Discarded ${pending.size} change(s).` : ''; - if (reason === 'timeout') { - console.log(`\n The browser never answered.${held} Nothing was written.\n`); - } else if (reason === 'closed') { - console.log(`\n Browser closed.${held} Nothing was written.\n`); - } else { - console.log(`\n Cancelled.${held} Nothing was written.\n`); - } - }; - - for (;;) { - let committed = 0; - const outcome = await serveSecretTable({ ...p, rows }, async () => { - if (pending.size === 0) return; - // The TUI's own line, verbatim. It happens before the write here too, - // because the write is what the browser is waiting on. - const verb = p.mode === 'local' ? 'Committing' : 'Committing & pushing'; - console.log(` ${verb} ${pending.size} change(s)…`); - await ctx.saveLocalEdits(Object.fromEntries(pending)); - committed = pending.size; - pending.clear(); - }); - - if (outcome.action === 'cancel') { - reportRefusal(outcome.reason); - return; - } - - if (outcome.action === 'commit') { - console.log(` Committed ${committed} change(s)`); - return; - } - - const row = rows.find((r) => r.key === outcome.key); - if (!row) return; - const edited = await serveSecretValueEditor({ - projectName: p.projectName, - branch: p.branch, - mode: p.mode, - row, - remoteAvailable: p.remoteAvailable, - remoteUnavailable: p.remoteGap, - pendingCount: pending.size + (row.dirty ? 0 : 1), - open: p.open, - // Forwarded so a test can watch this loop open its second, third and - // fourth server. Without it the only way to reach the editor's URL was - // to intercept `console.log`, which is why the loop itself — the thing - // `capy edit --web` actually runs — had no browser test at all. - onListen: p.onListen, - timeoutMs: p.timeoutMs, - }); - - // The editor's own Cancel means "back to the table". A closed window and a - // silent one mean there is no browser left, so re-serving the table would - // open a page nobody can see and wait five minutes for it. - if (edited.action === 'cancel' && edited.reason !== 'declined') { - reportRefusal(edited.reason); - return; - } - - if (edited.action === 'save') { - pending.set(edited.key, edited.value); - // The table redraws from the queued value: its mask, its length and its - // status all describe what the commit would write, not what is on disk. - rows = rows.map((r) => { - if (r.key !== edited.key) return r; - const next: WebSecretRow = { ...r, localValue: edited.value, dirty: true }; - next.status = reclassifyRow(next, { - localMode: p.mode === 'local', - remoteAvailable: p.remoteAvailable, - }); - next.updatedLabel = updatedLabelForRow(next, { localMode: p.mode === 'local' }); - return next; - }); - } - } -} diff --git a/src/ui/secretsScreen.ts b/src/ui/secretsScreen.ts index 4458eeee..288a5843 100644 --- a/src/ui/secretsScreen.ts +++ b/src/ui/secretsScreen.ts @@ -20,6 +20,7 @@ import { formatRelativeTime } from './relativeTime'; import { renderInlineValue } from './editScreen'; import { hashValue } from '../commands/statusCommand'; import { ACCENT } from './colors'; +import { normalizeQuery, textMatches } from './searchMatch'; // ── ANSI (mirrors EditScreen's palette/look-and-feel) ─────────────────────── @@ -237,22 +238,22 @@ function locationConnectorMatches(location: SecretIndexLocation, qLower: string) const provider = location.connector?.provider ?? location.service?.provider; const name = location.service?.name; const candidates = [provider, name].filter((s): s is string => Boolean(s)); - return candidates.some((s) => s.toLowerCase().includes(qLower)); + return candidates.some((s) => textMatches(s, qLower)); } /** Whether ANY of `location`'s outbound deploy targets (absent/`[]` on a server that predates CAP-676) matches `qLower` — a substring of that target's provider or of its target name. */ function locationTargetMatches(location: SecretIndexLocation, qLower: string): boolean { return (location.targets ?? []).some( - (t) => t.provider.toLowerCase().includes(qLower) || t.target.toLowerCase().includes(qLower), + (t) => textMatches(t.provider, qLower) || textMatches(t.target, qLower), ); } /** `qLower` is the trimmed, lowercased query; `rawHash`/`trimmedHash` are the query's hash(es) — computed once by the caller, never here. */ function rowMatchReasons(row: SecretIndexRow, qLower: string, rawHash: string, trimmedHash: string | null): readonly MatchReason[] { const isValueMatch = row.value_hash === rawHash || (trimmedHash !== null && row.value_hash === trimmedHash); - const isNameMatch = row.name.toLowerCase().includes(qLower); - const isProjectMatch = row.locations.some((l) => l.project_name.toLowerCase().includes(qLower)); - const isBranchMatch = row.locations.some((l) => l.branch.toLowerCase().includes(qLower)); + const isNameMatch = textMatches(row.name, qLower); + const isProjectMatch = row.locations.some((l) => textMatches(l.project_name, qLower)); + const isBranchMatch = row.locations.some((l) => textMatches(l.branch, qLower)); const isConnectorMatch = row.locations.some((l) => locationConnectorMatches(l, qLower)); const isTargetMatch = row.locations.some((l) => locationTargetMatches(l, qLower)); return ( @@ -279,7 +280,7 @@ function rowMatchReasons(row: SecretIndexRow, qLower: string, rawHash: string, t */ function computeFilteredRows(rows: readonly SecretIndexRow[], query: string): readonly MatchedRow[] { const trimmed = query.trim(); - const qLower = trimmed.toLowerCase(); + const qLower = normalizeQuery(query); if (!qLower) return rows.map((row) => ({ row, reasons: [] as const })); // Computed ONCE per filter pass — never inside the per-row map below. diff --git a/src/ui/selectWeb.ts b/src/ui/selectWeb.ts deleted file mode 100644 index efed6bfa..00000000 --- a/src/ui/selectWeb.ts +++ /dev/null @@ -1,442 +0,0 @@ -// Browser-rendered replacements for the interactive TTY prompts of the org and -// project pickers. Under `--web` the process has no interactive terminal (e.g. -// it's driven through the MCP), so an inquirer prompt would hang forever with -// nothing on screen. -// -// `capy org`'s three questions — which organization, which project, and what to -// call the first one — are served by the compiled `switch-organization` screen -// (`switchOrganizationInBrowser`, below). It ships one fact the terminal does -// not have: whether this device holds each organization's encryption key. The -// CLI re-scopes the session and prints `Organization: {name}` BEFORE it checks, -// so today you are congratulated on a switch and then handed an error about a -// key you cannot produce; the screen refuses the row instead. -// -// The two generic helpers under it — `selectInBrowser` and -// `promptTextInBrowser` — still hand-write their HTML. They are `capy`'s -// first-run pickers (branch, project, encrypt-and-push), which belong to the -// `init-wizard` screen and to whoever owns `capyCommand.ts`; converting them -// from here would put two agents in one file. They are left exactly as they -// were so that flow keeps working until then. -// -// No secret material is involved — only names/ids already known to the client. -import { runBrowserWizard, type WizardScreen } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import { ORG_CREATE_STOP_IDS, orgSwitchPlan } from '../core/onboardingPlan'; -import type { OrgProjectRow, OrgRow, SwitchOrganizationData } from './screens/contract'; - -export interface SelectOption { - id: string; - name: string; - /** Small muted subtitle under the option label. */ - note?: string; - /** Small accent badge after the label (e.g. "← current"). */ - badge?: string; -} - -export interface SelectWebOptions { - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; -} - -const esc = (s: string): string => - s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - -/** - * Strip terminal colour codes on the way into a payload. - * - * The refusals this file hands back are sentences the CLI wrote for a terminal - * — `firstProjectRefusal` bolds the organization name with `\x1b[1m…\x1b[0m` - * so it stands out in a scrollback — and a payload is not a terminal. Left in, - * the page renders `Binding it to a project in [1mnorthwind[0m would make - * those values unreadable.` Applied at the boundary rather than at the source - * because the source is shared: the TTY path raises the same sentence as a - * CapyError, where the bold is the point. - */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -/** A CSS colour that follows the OS theme: light value first, dark value second. */ -const ld = (light: string, dark: string): string => `light-dark(${light},${dark})`; -const MONO = 'ui-monospace,SFMono-Regular,Menlo,monospace'; -// Tailwind `neutral` palette (matches the rest of Capy's web pages), not `gray`. -const FG = ld('#171717', '#fafafa'); // neutral-900 / neutral-50 -const MUTED = ld('#737373', '#a3a3a3'); // neutral-500 / neutral-400 -const LINE = ld('#000', '#fff'); -const ROW = ld('#e5e5e5', '#404040'); // faint divider — neutral-200 / neutral-700 -const ACCENT = ld('#0d9488', '#2dd4bf'); // teal-600 / teal-400 - -const BTN = `width:100%;background:${LINE};color:${ld('#fff', '#000')};border:none;padding:12px 16px;font-size:15px;font-weight:600;cursor:pointer;margin-top:8px;`; -const NOTE = `color:${MUTED};font-size:13px;margin:8px 0 0;`; -const wrap = (inner: string): string => `
${inner}
`; - -function optionRow(o: SelectOption, checked: boolean, first: boolean): string { - const badge = o.badge ? ` ${esc(o.badge)}` : ''; - const note = o.note ? `
${esc(o.note)}` : ''; - const border = first ? `border-top:1px solid ${ROW};` : ''; - return ` - `; -} - -export function selectScreen(intro: string, options: SelectOption[], defaultId?: string): WizardScreen { - const hasDefault = defaultId != null && options.some(o => o.id === defaultId); - const rows = options - .map((o, i) => optionRow(o, hasDefault ? o.id === defaultId : i === 0, i === 0)) - .join(''); - return { - html: wrap(` -

${esc(intro)}

-
- ${rows} - -
`), - }; -} - -/** - * Render a single-choice list in the browser and resolve with the chosen option - * id. Resolves null if the user closed/cancelled the page. - */ -export async function selectInBrowser( - params: { title: string; intro: string; options: SelectOption[]; defaultId?: string }, - opts: SelectWebOptions = {}, -): Promise { - const valid = new Set(params.options.map(o => o.id)); - const result = await runBrowserWizard( - { - title: params.title, - firstScreen: selectScreen(params.intro, params.options, params.defaultId), - open: opts.open ?? true, - onListen: opts.onListen, - timeoutMs: opts.timeoutMs, - doneMessage: 'Selection received — back to your terminal.', - }, - async (_step, payload) => { - const choice = typeof payload.choice === 'string' ? payload.choice : ''; - if (!valid.has(choice)) return { error: 'Please choose an option.' }; - return { done: true, result: choice }; - }, - ); - return typeof result === 'string' ? result : null; -} - -export function textScreen(intro: string, label: string, defaultValue?: string): WizardScreen { - const field = `width:100%;box-sizing:border-box;font-family:${MONO};font-size:15px;padding:9px 2px;border:none;border-bottom:1px solid ${LINE};color:${FG};background:transparent;margin-bottom:14px;`; - return { - html: wrap(` -

${esc(intro)}

-
- - -
`), - }; -} - -/** - * Render a single free-text input in the browser and resolve with the entered - * (trimmed) value. `validate` returns true when valid, or an error string shown - * inline so the user can retry on the same screen. Resolves null if cancelled. - */ -export async function promptTextInBrowser( - params: { - title: string; - intro: string; - label: string; - defaultValue?: string; - /** Sync or async — org-name entry checks availability against the server. */ - validate?: (input: string) => true | string | Promise; - }, - opts: SelectWebOptions = {}, -): Promise { - const result = await runBrowserWizard( - { - title: params.title, - firstScreen: textScreen(params.intro, params.label, params.defaultValue), - open: opts.open ?? true, - onListen: opts.onListen, - timeoutMs: opts.timeoutMs, - doneMessage: 'Received — back to your terminal.', - }, - async (_step, payload) => { - const value = typeof payload.value === 'string' ? payload.value.trim() : ''; - const verdict = params.validate ? await params.validate(value) : (value ? true : 'This field is required.'); - // The validator belongs to the caller and may well have formatted its - // refusal for a terminal. - if (verdict !== true) return { error: stripAnsi(verdict) }; - return { done: true, result: value }; - }, - ); - return typeof result === 'string' ? result : null; -} - -// --------------------------------------------------------------------------- -// switch-organization — capy org --web -// --------------------------------------------------------------------------- - -/** Which of the two stops the picker is standing on. */ -type SwitchOrgView = 'org' | 'project' | 'first-project'; - -/** What the screen needs to draw, independent of which question it is asking. */ -export interface OrgScreenFacts { - /** Email of the signed-in user, so a wrong-account switch is obvious. */ - signedInAs?: string; - /** The org the working directory is pinned to, when there is one. */ - currentOrgId?: string; - /** - * Every org this user belongs to, each carrying whether THIS DEVICE holds - * its encryption key. That flag is the point: without it the run announces a - * successful switch and then fails on a key the user cannot produce. - */ - orgs: OrgRow[]; - /** Whether the cwd already has a keep.lock pinning it to some project. */ - hasKeepLock: boolean; - /** Suggested name for the first project, prefilled by the CLI. */ - defaultProjectName?: string; - /** The branch a first project is bootstrapped with. Hardcoded `development`. */ - firstBranchName?: string; -} - -export interface WebSwitchOrgParams extends OrgScreenFacts { - /** - * The work between the two questions: re-scope the session to `orgId`, check - * this device can decrypt for it, and list its projects. - * - * Answers with a shape rather than throwing, so a switch that fails is a - * refusal the user can answer by picking a different row — not a dead page - * waiting on a command that has already given up. - */ - onOrgChosen: ( - orgId: string, - ) => Promise<{ ok: true; projects: OrgProjectRow[] } | { ok: false; reason: string }>; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** - * What the browser settled. - * - * `create` resolves out rather than continuing here: naming an organization and - * writing down its recovery phrase is the `create-organization` screen's job, - * and the CLI has real work to do — creating the org, re-scoping the session — - * before there is a project list to ask about. - */ -export type WebSwitchOrgResult = - | { action: 'create'; cancelled: false } - | { action: 'select-project'; orgId: string; projectId: string; cancelled: false } - | { action: 'create-project'; orgId: string; projectName: string; cancelled: false } - | { action: 'cancel'; cancelled: true }; - -export interface SwitchOrgState { - /** The org chosen so far. */ - orgId?: string; - /** Projects in it, once listed. An empty array is a real state. */ - projects?: OrgProjectRow[]; -} - -function switchOrgView(s: SwitchOrgState): SwitchOrgView { - if (!s.orgId || s.projects === undefined) return 'org'; - return s.projects.length === 0 ? 'first-project' : 'project'; -} - -export function buildSwitchOrganizationData( - p: OrgScreenFacts & { - state?: SwitchOrgState; - /** - * This window opened AFTER the create route was walked — the organization - * was named, its recovery phrase was written down, and it exists. - * - * Without it the rail on the very next screen strikes those three stops - * through as "not needed", which is the route describing a different run - * than the one that just happened. - */ - created?: boolean; - }, - nonce: string, -): SwitchOrganizationData { - const s = p.state ?? {}; - const view = switchOrgView(s); - const chosen = p.orgs.find(o => o.id === s.orgId); - - return { - nonce, - mode: 'switch', - signedInAs: p.signedInAs, - currentOrgId: p.currentOrgId, - orgs: p.orgs, - allowCreate: true, - stops: orgSwitchPlan({ orgName: chosen?.name, created: p.created }), - createStopIds: ORG_CREATE_STOP_IDS, - projects: view === 'project' ? s.projects : undefined, - view, - subjectOrgName: chosen?.name, - // The truth about this directory, not about this route. CAP-316 has the - // screen skip the rail when a keep.lock exists because a switch is then - // one decision — but this CLI still asks for a project either way, so the - // flag says what is on disk and the plan above says what will be asked. - hasKeepLock: p.hasKeepLock, - defaultProjectName: p.defaultProjectName, - firstBranchName: p.firstBranchName, - nonTty: { - command: 'capy org', - why: 'Switching organization changes which secrets this session can read, and creating one generates a recovery phrase that is shown once. Neither is chosen for you.', - }, - }; -} - -/** - * Serve `capy org`'s questions and return what was chosen. - * - * Two stops in one window: the organization, then the project — the second - * arrives by reloading the same address, because a compiled screen is a whole - * document and cannot be spliced into the page that asked the first question. - */ -export async function switchOrganizationInBrowser( - p: WebSwitchOrgParams, -): Promise { - const state: SwitchOrgState = {}; - let nonce = ''; - - const render = (): string => - renderScreen('switch-organization', buildSwitchOrganizationData({ ...p, state }, nonce)); - - const out = await runBrowserWizard( - { - title: 'Switch organization', - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Chosen — back to your terminal.', - renderFirst: (n) => { - nonce = n; - return render(); - }, - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { action: 'cancel', cancelled: true } }; - } - - const view = switchOrgView(state); - - if (view === 'org') { - if (payload.__action === 'create') { - return { done: true, result: { action: 'create', cancelled: false } }; - } - if (payload.__action !== 'switch') { - return { error: 'That is not an action this screen offers.' }; - } - const orgId = typeof payload.orgId === 'string' ? payload.orgId : ''; - const org = p.orgs.find(o => o.id === orgId); - // Three refusals the screen already makes for itself — it holds its - // button on all three — so any of them arriving means the submit did - // not come from the screen. - if (!org) return { error: 'That organization is not one you belong to.' }; - if (org.id === p.currentOrgId) { - return { error: `This directory is already on ${org.name}.` }; - } - if (!org.hasLocalKey) { - return { - error: `This machine has no encryption key for ${org.name}, so nothing there would decrypt.`, - }; - } - - const outcome = await p.onOrgChosen(orgId); - // The reason is the CLI's own sentence, written for a scrollback: the - // first-project refusal bolds the organization name and the auth layer - // hands back whatever the service said. It reaches a browser here, so - // the escapes come off before it does. - if (!outcome.ok) return { error: stripAnsi(outcome.reason) }; - state.orgId = orgId; - state.projects = outcome.projects; - return { screen: { html: render(), standalone: true } }; - } - - if (view === 'project') { - if (payload.__action !== 'select-project') { - return { error: 'That is not an action this screen offers.' }; - } - const projectId = typeof payload.projectId === 'string' ? payload.projectId : ''; - if (!state.projects!.some(pr => pr.id === projectId)) { - return { error: 'That project is not in this organization.' }; - } - return { - done: true, - result: { action: 'select-project', orgId: state.orgId!, projectId, cancelled: false }, - }; - } - - // first-project: the org has none, so switching to it means creating one. - if (payload.__action !== 'create-project') { - return { error: 'That is not an action this screen offers.' }; - } - const name = typeof payload.name === 'string' ? payload.name.trim() : ''; - if (!name) return { error: 'Project name cannot be empty' }; - return { - done: true, - result: { - action: 'create-project', - orgId: state.orgId!, - projectName: name, - cancelled: false, - }, - }; - }, - ); - return out as WebSwitchOrgResult; -} - -/** - * Ask only for the first project's name, on the same screen. - * - * The create-a-new-organization route arrives here having already answered the - * organization question — it created the answer — so re-serving the picker - * would ask which of one. Resolves null when the user cancelled, which is the - * browser's version of answering no to the terminal's `Create the first project - * in X here?`. - */ -export async function nameFirstProjectInBrowser( - p: OrgScreenFacts & { - /** The org the project will belong to. Already chosen; already created. */ - orgId: string; - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; - }, -): Promise { - const state: SwitchOrgState = { orgId: p.orgId, projects: [] }; - const out = await runBrowserWizard( - { - title: 'First project', - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Named — back to your terminal.', - renderFirst: (nonce) => - renderScreen( - 'switch-organization', - // `created`, because this window is only ever served after the create - // route has been walked — the rail behind this question is history, - // not a branch this run declined. - buildSwitchOrganizationData({ ...p, state, created: true }, nonce), - ), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') return { done: true, result: { name: null } }; - if (payload.__action !== 'create-project') { - return { error: 'That is not an action this screen offers.' }; - } - const name = typeof payload.name === 'string' ? payload.name.trim() : ''; - if (!name) return { error: 'Project name cannot be empty' }; - return { done: true, result: { name } }; - }, - ); - const name = (out as { name?: unknown }).name; - return typeof name === 'string' ? name : null; -} diff --git a/src/ui/syncConflictScreen.ts b/src/ui/syncConflictScreen.ts deleted file mode 100644 index 8ed1a7d0..00000000 --- a/src/ui/syncConflictScreen.ts +++ /dev/null @@ -1,194 +0,0 @@ -// The sync conflict resolver, served as a compiled screen. -// -// Replaces conflictWeb.ts, which hand-wrote its HTML and — more importantly — -// showed only HALF the question. The terminal asks two levels: a whole-run -// action ("retrieve all remote", "commit and push all local", "individually -// resolve"), ordered so the recommended answer sits first, and then the -// per-variable table only if you chose to resolve individually. The old -// browser path discarded the first level entirely and hard-coded individual -// resolution, so a user who wanted "take theirs" answered once per variable -// and never saw the ordering that carried the CLI's recommendation. -// -// This serves the compiled `sync-conflict` screen, which has both levels, and -// returns the action alongside the per-variable choices so the caller can -// apply either. -// -// Only SNIPPETS reach the page (`sk_...001`, first3…last3) — never a full -// secret value — matching the terminal table. The user's selection travels the -// loopback only; this transport never prints or logs it. -import { runBrowserWizard } from './browserWizard'; -import { renderScreen } from './screens/serve'; -import type { ConflictAction, ConflictRow, SyncConflictData } from './screens/contract'; -import type { ResolveRow, ResolveResult } from './resolveTable'; - -/** What the browser decided: the whole-run action, plus rows if individual. */ -export interface WebConflictResult { - /** `retrieve_remote` | `retrieve_pinned` | `commit_local` | `individual`. */ - action: string; - choices: ResolveResult['choices']; - cancelled: boolean; -} - -export interface WebConflictParams { - rows: ResolveRow[]; - /** - * Which pinned values cannot be reconstructed, by variable name. - * - * The terminal marks these by putting the ANSI-italic literal `unresolvable` - * in the value column and then testing for that string later. A variable - * whose snippet happened to read "unresolvable" would defeat it, and the - * escape codes leak terminal formatting into a payload the browser has to - * strip. The screen takes a boolean instead, which no value can spoof. - */ - unresolvable: Set; - showLocal: boolean; - showRemote: boolean; - localMode: boolean; - isOnboarding: boolean; - isBehind: boolean; - remoteState: 'ok' | 'empty' | 'unreachable'; - /** The CLI's own menu, carried verbatim so both surfaces read the same. */ - actions: ConflictAction[]; - projectName: string; - branch: string; - /** Open the browser automatically (false in tests; the URL is still printed). */ - open?: boolean; - /** Test hook: receives the loopback URL once listening. */ - onListen?: (url: string) => void; - timeoutMs?: number; -} - -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -/** What kind of difference this row is, in the CLI's own vocabulary. */ -function diffType(row: ResolveRow): ConflictRow['type'] { - if (row.pinned === null) return 'new'; - if (row.local === null && row.remote === null) return 'deleted'; - return 'changed'; -} - -/** - * The route this run will travel, declared before the browser opens. - * - * Built here rather than in the screen for the reason §8 gives: the same array - * is what a headless caller would parse, so a rail the browser draws from its - * own state would describe a different run than `--json` does. `review` is - * always where the CLI serves; the later stops are upcoming until the user - * moves, and the screen says only where it is standing. - */ -function buildStops(willOfferIndividual: boolean): SyncConflictData['stops'] { - return [ - { - id: 'review', - label: 'Review', - state: 'current', - detail: 'what differs, and where each value came from', - }, - { - id: 'choose', - label: 'Choose an action', - state: 'upcoming', - detail: 'for the whole run, or resolve variable by variable', - }, - { - id: 'resolve', - label: 'Resolve', - // A run that cannot offer individual resolution never visits this stop, - // and saying so up front is the point of drawing the whole route. - state: willOfferIndividual ? 'upcoming' : 'skipped', - detail: 'pick a source for each variable', - }, - { - id: 'apply', - label: 'Apply', - state: 'upcoming', - detail: 'rewrite .env and pin what you chose', - }, - ]; -} - -export function buildConflictData(p: WebConflictParams, nonce: string): SyncConflictData { - const rows: ConflictRow[] = p.rows.map((r) => ({ - variable: r.variable, - type: diffType(r), - // Snippets arrive carrying the terminal's own colour codes. The page sets - // its own type; the escapes would render as literal `[3m` in the browser. - pinned: r.pinned === null ? null : stripAnsi(r.pinned), - pinnedUnresolvable: p.unresolvable.has(r.variable), - local: r.local === null ? null : stripAnsi(r.local), - remote: r.remote === null ? null : stripAnsi(r.remote), - })); - - return { - nonce, - projectName: p.projectName, - branch: p.branch, - localMode: p.localMode, - showLocal: p.showLocal, - showRemote: p.showRemote, - isOnboarding: p.isOnboarding, - isBehind: p.isBehind, - remoteState: p.remoteState, - rows, - actions: p.actions, - stops: buildStops(p.actions.some((a) => a.value === 'individual')), - step: 'review', - }; -} - -/** A value the per-variable table may return for a row. */ -const SOURCES = new Set(['pinned', 'local', 'remote', 'delete']); - -export async function resolveConflictInBrowser(p: WebConflictParams): Promise { - const out = await runBrowserWizard( - { - title: `Resolve ${p.rows.length} conflict${p.rows.length !== 1 ? 's' : ''} — ${p.projectName}/${p.branch}`, - // Rendered per-request so the nonce the page echoes is the one this - // server minted. `standalone` because a compiled screen is a whole - // document and cannot be dropped into the wizard shell. - firstScreen: { html: '', standalone: true }, - open: p.open ?? true, - onListen: p.onListen, - timeoutMs: p.timeoutMs, - doneMessage: 'Resolved — back to your terminal.', - renderFirst: (nonce) => renderScreen('sync-conflict', buildConflictData(p, nonce)), - }, - async (_step, payload) => { - if (payload.__action === 'cancel') { - return { done: true, result: { action: 'skip', choices: {}, cancelled: true } }; - } - - const action = typeof payload.action === 'string' ? payload.action : ''; - if (!p.actions.some((a) => a.value === action)) { - // The page offers only what this run's menu contains, so anything else - // is a malformed submit rather than a user mistake — refuse it inline - // instead of applying a guess to somebody's secrets. - return { error: 'That action is not available for this run.' }; - } - if (action === 'skip') { - return { done: true, result: { action, choices: {}, cancelled: true } }; - } - - const choices: ResolveResult['choices'] = {}; - if (action === 'individual') { - const raw = payload.choices; - const picked = raw && typeof raw === 'object' ? (raw as Record) : {}; - for (const row of p.rows) { - const c = picked[row.variable]; - if (typeof c === 'string' && SOURCES.has(c)) { - choices[row.variable] = c as ResolveResult['choices'][string]; - } - } - // Individual resolution that answered nothing is not a resolution. The - // screen holds its button until every row has a source, so an empty - // set means the submit did not come from that screen. - if (Object.keys(choices).length !== p.rows.length) { - return { error: 'Some variables were left unanswered.' }; - } - } - - return { done: true, result: { action, choices, cancelled: false } }; - }, - ); - return out as WebConflictResult; -} diff --git a/src/ui/syncScreens.ts b/src/ui/syncScreens.ts deleted file mode 100644 index 4c48955f..00000000 --- a/src/ui/syncScreens.ts +++ /dev/null @@ -1,226 +0,0 @@ -// The two REPORTS of the sync pair, served as compiled screens: -// `capy status` (sync-status) and the end of a `capy` run (sync-result). -// -// Neither asks anything. They post nothing, carry no nonce, and are served -// under the strict screen policy — `connect-src 'none'` — so a page that only -// reports cannot open a socket at all. That is why they use `ScreenServer` -// rather than `runBrowserWizard`: a wizard exists to collect an answer, and -// there is no answer here to collect. -// -// WHAT WAS WRONG. `capy status` writes its report to a TTY with ANSI bold and -// then calls `process.exit(0)`, and `capy` finishes a sync with three console -// lines. Under `--web` — which is agent-driven, so there is often no terminal -// anyone is watching — the report went to a stream nobody reads. Now the same -// facts render in the browser, with `--json` carried VERBATIM in the payload -// rather than rebuilt, so what a person copies out of the page and what a -// script parses cannot describe different states. -// -// WHAT CROSSES. No value, in either payload. The structured rows carry a -// variable NAME, a type and which side moved — `buildSyncStatusData` drops the -// pinned/local/remote hashes `compareSecrets` compared on, and -// `buildSyncResultData` never had them. -// -// The `json` field is the exception and is deliberate: it is `capy status -// --json` VERBATIM, so what a person copies off the page and what a script -// parses cannot describe different states. That object's `diffs` carry the -// 16-hex sha256 prefixes — the same ones the command prints to stdout, on the -// same machine, for the same person. So the page is not hash-free; it is -// exactly as hash-free as `--json` is, which is the property this field is for. -// A stricter page would mean two different reports called by one name, and the -// browser test asserts the shape rather than assuming it. -import { ScreenServer } from './screens/serve'; -import type { - DiffType, - ExpiringCredential, - RemoteFailure, - RemoteState, - ScreenName, - StatusDiff, - SyncResultChange, - SyncResultData, - SyncStatusData, -} from './screens/contract'; - -/** Strip terminal colour codes: the CLI bolds these names on its way to a TTY. */ -const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); - -// --------------------------------------------------------------------------- -// sync-status -// --------------------------------------------------------------------------- - -/** One row of `compareSecrets`, as `statusCommand` computes it. */ -export interface StatusDiffInput { - variable: string; - type: DiffType; - /** Value HASHES, never values. Used only to work out which side moved. */ - pinned?: string; - local?: string; - remote?: string; -} - -export interface WebSyncStatusParams { - projectName: string; - /** Null when the CLI could not tell which branch this directory is on. */ - branch: string | null; - totalSecrets: number; - localMatchesPinned: boolean; - remoteMatchesPinned: boolean; - /** Whether the remote answered with anything at all. */ - hasRemote: boolean; - /** Set when the remote could not be read. A code, never the message. */ - remoteFailure?: RemoteFailure; - diffs: StatusDiffInput[]; - /** Connector credentials the CLI warns about after every run. */ - expiring: ExpiringCredential[]; - /** - * The `capy status --json` payload, verbatim — the same object, stringified. - * - * Carries that object's `diffs`, and therefore its 16-hex sha256 prefixes. - * They are not values and they are not new to this surface: `--json` prints - * them to stdout on the same machine. Rebuilding a smaller object here would - * make the page and the flag two different reports under one name, which is - * the failure this field exists to prevent. - */ - json: string; - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; -} - -/** - * Which side of the comparison moved. - * - * The terminal decides this per row to pick its `(changed locally)` / - * `(changed on remote)` suffix, and then throws the decision away into prose. - * The same three cases, as a value. - */ -function diffSide(d: StatusDiffInput): StatusDiff['side'] { - const localMoved = d.local !== d.pinned; - const remoteMoved = d.remote !== d.pinned; - if (localMoved && remoteMoved) return 'both'; - return localMoved ? 'local' : 'remote'; -} - -/** - * What the remote had to say, as a state rather than as three booleans the - * screen would have to reassemble. - * - * `not_checked` is deliberately never produced here: with no variables at all - * the CLI skips the fetch entirely and cannot tell that case apart from a - * remote that answered and was empty, so claiming it would be an invention. - */ -function remoteState(p: WebSyncStatusParams): RemoteState { - if (p.remoteFailure) return 'failed'; - if (!p.hasRemote) return 'empty'; - return p.remoteMatchesPinned ? 'up_to_date' : 'has_changes'; -} - -export function buildSyncStatusData(p: WebSyncStatusParams): SyncStatusData { - const data: SyncStatusData = { - projectName: stripAnsi(p.projectName), - branch: p.branch === null ? null : stripAnsi(p.branch), - totalSecrets: p.totalSecrets, - localMatchesPinned: p.localMatchesPinned, - remoteState: remoteState(p), - diffs: p.diffs.map((d) => ({ variable: d.variable, type: d.type, side: diffSide(d) })), - expiring: p.expiring, - json: p.json, - // The CLI's own last line, and the reason it differs: a caller who cannot - // read the remote is told to redeem rather than to sync, because syncing - // will fail the same way again. - nextCommand: - p.diffs.length === 0 - ? undefined - : p.remoteFailure === 'access_denied' - ? 'capy redeem [invite-code]' - : 'capy', - nonTty: { - command: 'capy status --json', - why: 'The report is the same either way; --json is the copy a script reads.', - }, - }; - if (p.remoteFailure) data.remoteFailure = p.remoteFailure; - return data; -} - -/** - * Serve one report and stop. - * - * Returns as soon as the page is being served, not when it is read: the - * listening socket holds the process open by itself, so the CLI hands the URL - * over and the run ends when the browser has the page (or when the server's - * own timeout closes it, if nobody comes). - */ -export async function showSyncStatusInBrowser(p: WebSyncStatusParams): Promise { - const server = new ScreenServer('sync-status', buildSyncStatusData(p), { timeoutMs: p.timeoutMs }); - const url = await server.start(); - console.log(''); - console.log(' Your status report is in your browser:'); - console.log(` ${url}`); - console.log(''); - p.onListen?.(url); - if (p.open ?? true) await openUrl(url, 'sync-status'); - return url; -} - -// --------------------------------------------------------------------------- -// sync-result -// --------------------------------------------------------------------------- - -export interface WebSyncResultParams { - projectName: string; - branch: string | null; - outcome: SyncResultData['outcome']; - pulled: SyncResultChange[]; - pushed: SyncResultChange[]; - /** - * Whether the working `.env` changed on disk. Not derived from `pulled`: - * `capy` re-encrypts and rewrites the whole file even when nothing differed, - * and it rewrites nothing at all when the run was skipped. - */ - envRewritten: boolean; - open?: boolean; - onListen?: (url: string) => void; - timeoutMs?: number; -} - -export function buildSyncResultData(p: WebSyncResultParams): SyncResultData { - return { - projectName: stripAnsi(p.projectName), - branch: p.branch === null ? null : stripAnsi(p.branch), - outcome: p.outcome, - pulled: p.pulled, - pushed: p.pushed, - envRewritten: p.envRewritten, - nonTty: { - command: 'capy status --json', - why: 'The run has already happened; --json is how the same state is read back without a browser.', - }, - }; -} - -/** Serve the end-of-run report. Same one-shot posture as the status page. */ -export async function showSyncResultInBrowser(p: WebSyncResultParams): Promise { - const server = new ScreenServer('sync-result', buildSyncResultData(p), { timeoutMs: p.timeoutMs }); - const url = await server.start(); - console.log(''); - console.log(' What this run did is in your browser:'); - console.log(` ${url}`); - console.log(''); - p.onListen?.(url); - if (p.open ?? true) await openUrl(url, 'sync-result'); - return url; -} - -/** - * Best-effort browser open; the printed URL is the fallback. - * - * Takes the screen name rather than a boolean because both sync reports are - * listings — a window sized for a question would wrap the drift table that is - * the entire content of the page. - */ -async function openUrl(url: string, screen: ScreenName): Promise { - const { openScreen } = await import('./openScreen'); - const { SCREEN_WIDE } = await import('./screens/generated'); - await openScreen(url, { kind: 'dialog', wide: SCREEN_WIDE[screen] }); -} diff --git a/src/ui/vendorLogos.ts b/src/ui/vendorLogos.ts deleted file mode 100644 index d3ccf7ad..00000000 --- a/src/ui/vendorLogos.ts +++ /dev/null @@ -1,54 +0,0 @@ -// AUTO-GENERATED from ~/Dev/capy-site/public/logos + react-icons/si — do not edit by hand. -// -// Bundled, inline vendor logos keyed by registrable domain, matching the value -// detectVendorDomain() derives from a variable's helpUrl (e.g. console.groq.com -// -> groq.com). These are the ONLY source of vendor marks on the intake page. -// -// HARD RULE: the intake window is a secrets surface and MUST NOT make any -// external request. Logos are inlined here; never reference a remote URL. See -// the no-network rule in the capy-mcp project. -export const VENDOR_LOGOS: Record = { - "ai21.com": "\n\n", - "algolia.com": "", - "amplitude.com": "", - "anthropic.com": "", - "auth0.com": "", - "azure.com": "Microsoft Azure", - "clerk.com": "", - "cloudflare.com": "", - "cohere.com": "\n\n\n\n\n\n\n\n\n\n\n\n", - "datadoghq.com": "", - "digitalocean.com": "", - "discord.com": "", - "fireworks.ai": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n", - "github.com": "", - "gitlab.com": "", - "groq.com": "", - "heroku.com": "", - "huggingface.co": "", - "mistral.ai": "Mistral AI", - "mongodb.com": "", - "neon.tech": "", - "netlify.com": "", - "notion.so": "", - "openai.com": "", - "pinecone.io": "", - "planetscale.com": "", - "postmarkapp.com": "", - "railway.app": "", - "redis.io": "", - "render.com": "", - "replicate.com": "Replicate", - "resend.com": "", - "segment.com": "", - "sendgrid.com": "", - "sentry.io": "", - "slack.com": "", - "stripe.com": "", - "supabase.com": "", - "twilio.com": "", - "upstash.com": "", - "vercel.com": "", - "weaviate.cloud": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n", - "weaviate.io": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n", -}; diff --git a/src/ui/webMode.ts b/src/ui/webMode.ts deleted file mode 100644 index e950dfb8..00000000 --- a/src/ui/webMode.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Whether this process was invoked with `--web`. - * - * Process-global, and legitimately so: `--web` is declared once on the root - * program, so it is a property of the invocation rather than of any command. - * A `preAction` hook records it before any handler runs, which is why nothing - * downstream has to be handed it. - * - * WHY IT EXISTS AT ALL. `displayErrorAndExit` is reached from eighteen call - * sites, most of them inside a `catch` several layers below the handler that - * parsed the flag — a key resolver, a service client, a crypto path. Threading - * a boolean down all of those to answer one question ("is anybody reading a - * terminal right now?") would touch every signature between here and there, - * and would be forgotten on the nineteenth. - * - * NOT a substitute for the flag itself. Anything that takes `--web` as a - * PARAMETER — every command that decides whether to open a browser for a - * question — keeps reading `command.optsWithGlobals().web`, because that is a - * decision the command makes about its own flow. This is only for the code - * that has no way to ask. - */ -let webMode = false; - -/** Called once, from the root program's `preAction` hook. */ -export function setWebMode(on: boolean): void { - webMode = on; -} - -/** True when this run should render to a browser rather than a terminal. */ -export function isWebMode(): boolean { - return webMode; -} diff --git a/tests/commands/addCommand.test.ts b/tests/commands/addCommand.test.ts deleted file mode 100644 index 55cda581..00000000 --- a/tests/commands/addCommand.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * What is left of `capy add` once the intake moved. - * - * `parseVars`, `runWebIntake` and the loopback contract are now - * `tests/ui/secretIntakeScreen.test.ts` — they went with the code, which moved - * to `ui/secretIntakeScreen.ts` along with the compiled screen it serves. This - * file keeps the one thing that is genuinely argv's: turning repeatable - * `--help-url NAME=URL` flags into per-variable links. - */ -import { describe, test, expect } from 'bun:test'; -import { parseHelpUrls, overwriteNotice } from '../../src/commands/addCommand'; - -describe('parseHelpUrls (repeatable --help-url NAME=URL)', () => { - test('maps valid http(s) pairs by name', () => { - expect( - parseHelpUrls(['STRIPE_SECRET_KEY=https://dashboard.stripe.com/apikeys', 'OPENAI_API_KEY=http://example.com/k']), - ).toEqual({ - STRIPE_SECRET_KEY: 'https://dashboard.stripe.com/apikeys', - OPENAI_API_KEY: 'http://example.com/k', - }); - }); - - test('drops non-http(s) URLs and malformed/invalid-name pairs', () => { - expect(parseHelpUrls(['A=javascript:alert(1)', 'B=ftp://x', 'no-equals', '=https://x', '1BAD=https://x'])).toEqual( - {}, - ); - }); - - test('returns {} for undefined', () => { - expect(parseHelpUrls(undefined)).toEqual({}); - }); -}); - -describe('overwriteNotice', () => { - test('carries the terminal confirm word for word', () => { - // `--web` used to skip the confirm entirely — it is gated on `!opts.web` — - // so a browser intake overwrote existing values without a word anywhere. - // Two wordings for one thing is a bug, so this is the CLI's own sentence. - expect(overwriteNotice(['A', 'B'])).toBe('A, B already exist(s). Overwrite?'); - }); - - test('says nothing when nothing would be overwritten', () => { - expect(overwriteNotice([])).toBeUndefined(); - }); -}); diff --git a/tests/commands/capyCommand.test.ts b/tests/commands/capyCommand.test.ts index 01f6d339..cc80625f 100644 --- a/tests/commands/capyCommand.test.ts +++ b/tests/commands/capyCommand.test.ts @@ -55,17 +55,6 @@ mock.module('inquirer', () => ({ Separator: class Separator { constructor() {} }, }, })); -// The two browser REPORTS. Mocked so a `--web` run in here can be asked what -// it decided to serve without any test binding a socket — and, more to the -// point, without a no-op sync leaving a listening server behind it. -const shownSyncResults: unknown[] = []; -mock.module('../../src/ui/syncScreens', () => ({ - showSyncResultInBrowser: mock(async (p: unknown) => { - shownSyncResults.push(p); - return 'http://127.0.0.1:1/s/not-served'; - }), - showSyncStatusInBrowser: mock(async () => 'http://127.0.0.1:1/s/not-served'), -})); mock.module('../../src/ui/spinner', () => ({ default: (text: string) => ({ start: () => ({ @@ -898,25 +887,6 @@ describe('CapyCommand', () => { consoleSpy.mockRestore(); }); - test('a run with nothing to do serves no browser report and holds no socket', async () => { - // `capy --web` in a synced directory is the common case, and it asks - // nothing: the report page it used to serve had to be opened by `open()`, - // which fails quietly on the headless and remote hosts where `--web` - // actually runs — leaving the listening socket to hold the process for - // its whole 120-second timeout, on every single run, to render a page - // that says nothing happened. - const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); - shownSyncResults.length = 0; - - const webCommand = new CapyCommand({ web: true }); - await (webCommand as any).syncProject(mockProjectState); - - expect(consoleSpy).toHaveBeenCalledWith('Everything is up to date!'); - expect(shownSyncResults).toEqual([]); - - consoleSpy.mockRestore(); - }); - test('should handle authentication failure during sync', async () => { mockAuthService.authenticateSilent.mockResolvedValue({ success: false }); mockAuthService.authenticate.mockResolvedValue({ @@ -1527,6 +1497,48 @@ describe('CapyCommand', () => { }); }); + describe('resolveProjectChoice — type-to-filter project picker (CAP-700)', () => { + const NEW = '__new__'; + const projects = [ + { id: 'p1', name: 'billing-api', organization_id: 'org-1' }, + { id: 'p2', name: 'web-frontend', organization_id: 'org-1' }, + ]; + + /** Runs the picker with a recorded `inquirer.prompt` that answers with `answer`. */ + async function runPicker(answer: string) { + const inquirer = (await import('inquirer')).default; + const origPrompt = inquirer.prompt; + const promptSpy = mock(async (_questions: any) => ({ projectChoice: answer })); + (inquirer as any).prompt = promptSpy; + try { + const chosen = await (capyCommand as any).resolveProjectChoice(projects, NEW); + const asked = promptSpy.mock.calls.flatMap((call: any[]) => call[0] as any[]); + return { chosen, asked }; + } finally { + (inquirer as any).prompt = origPrompt; + } + } + + test('asks a searchable question with the same message and the default kept on "New project"', async () => { + const { asked } = await runPicker('p2'); + expect(asked).toHaveLength(1); + expect(asked[0].type).toBe('search'); + expect(asked[0].message).toBe('Which project do you want to use?'); + expect(asked[0].source('').map((c: any) => c.name)).toEqual(['New project', 'billing-api', 'web-frontend']); + }); + + test('typing filters, and "New project" stays reachable by its own label', async () => { + const { asked } = await runPicker('p2'); + expect(asked[0].source('web').map((c: any) => c.value)).toEqual(['p2']); + expect(asked[0].source('new').map((c: any) => c.value)).toEqual([NEW]); + }); + + test('returns whatever the prompt resolved', async () => { + expect((await runPicker('p1')).chosen).toBe('p1'); + expect((await runPicker(NEW)).chosen).toBe(NEW); + }); + }); + describe('error handling', () => { test('should use displayErrorAndExit for errors in execute', async () => { // The execute method now uses displayErrorAndExit from errorScreen module diff --git a/tests/commands/checkoutGuard.test.ts b/tests/commands/checkoutGuard.test.ts index 8c2392b2..6b1943d1 100644 --- a/tests/commands/checkoutGuard.test.ts +++ b/tests/commands/checkoutGuard.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { countVariablesPerBranch, findDirtyBranchIssue, findUncommittedEnvChange } from '../../src/commands/checkoutCommand'; +import { findDirtyBranchIssue, findUncommittedEnvChange } from '../../src/commands/checkoutCommand'; import { hashValue } from '../../src/commands/statusCommand'; import { KeepFile } from '../../src/types/index'; import { SyncEngine } from '../../src/sync/syncEngine'; @@ -145,37 +145,3 @@ describe('findDirtyBranchIssue', () => { expect(findDirtyBranchIssue(pm, fm, 'k')).toEqual({ code: 'UNCOMMITTED_CHANGES', branch: 'staging', varName: 'A' }); }); }); - -describe('countVariablesPerBranch (branch list counts)', () => { - test('counts each branch\'s pinned variables, and no other branch\'s', () => { - const variables: KeepFile['variables'] = { - API_KEY: [ - { resource_id: 'r1', branch: 'development', value_hash: 'h' }, - { resource_id: 'r2', branch: 'production', value_hash: 'h' }, - ], - DB_URL: [{ resource_id: 'r3', branch: 'development', value_hash: 'h' }], - }; - expect(countVariablesPerBranch({ variables } as KeepFile)).toEqual({ - development: 2, - production: 1, - }); - }); - - test('one variable pinned twice on a branch is still one variable', () => { - // The screen prints "14 variables", not "14 pins", so the count is per - // variable — otherwise a duplicate entry inflates what the branch holds. - const variables: KeepFile['variables'] = { - API_KEY: [ - { resource_id: 'r1', branch: 'development', value_hash: 'h' }, - { resource_id: 'r2', branch: 'development', value_hash: 'h' }, - ], - }; - expect(countVariablesPerBranch({ variables } as KeepFile)).toEqual({ development: 1 }); - }); - - test('no keep.lock is no counts, never zeroes', () => { - // A branch absent from the map renders without a count; a zero would claim - // this directory knows the branch is empty. - expect(countVariablesPerBranch(null)).toEqual({}); - }); -}); diff --git a/tests/commands/connectDokploy.test.ts b/tests/commands/connectDokploy.test.ts index 82cd6e5f..466610a1 100644 --- a/tests/commands/connectDokploy.test.ts +++ b/tests/commands/connectDokploy.test.ts @@ -1339,22 +1339,6 @@ describe('dokploy import — system store token resolution', () => { return { getConnectorSecret, promptFn }; } - test('--web: a real TTY does not let the store prompt — refused, zero prompt calls', async () => { - const { getConnectorSecret, promptFn } = fakeSystemStore(null); - const connector = createDokployConnector({ fetch: unreachableFetch, env: {}, getConnectorSecret }); - const outcome = await withTTY(() => - connector.import!(ctxWith(), { - ...BASE_OPTS, - nonTty: false, - web: true, - baseUrl: 'https://d.example.com', - application: 'app_1', - }), - ); - expect(outcome.ok).toBe(false); - expect(promptFn).not.toHaveBeenCalled(); - }); - test('--json: a real TTY does not let the store prompt — refused, zero prompt calls', async () => { const { getConnectorSecret, promptFn } = fakeSystemStore(null); const connector = createDokployConnector({ fetch: unreachableFetch, env: {}, getConnectorSecret }); @@ -1371,7 +1355,7 @@ describe('dokploy import — system store token resolution', () => { expect(promptFn).not.toHaveBeenCalled(); }); - test('neither --web nor --json: a real TTY DOES let the store prompt (sanity check for the two tests above)', async () => { + test('without --json: a real TTY DOES let the store prompt (sanity check for the test above)', async () => { const { getConnectorSecret, promptFn } = fakeSystemStore(null); const connector = createDokployConnector({ fetch: fakeFetch({ env: 'A=1', calls: [] }), diff --git a/tests/commands/connectorPlans.test.ts b/tests/commands/connectorPlans.test.ts index 6c0a9ac5..b1ecbbbc 100644 --- a/tests/commands/connectorPlans.test.ts +++ b/tests/commands/connectorPlans.test.ts @@ -1,156 +1,13 @@ /** - * The two routes, as pure functions. + * The rotation route, as a pure function. * - * These exist for the reason `branchCreatePlan.test.ts` exists: the rail a - * person reads, the diagram `renderRotationPlan` prints and the array a - * headless caller parses are claimed to be the same object, and a claim nobody - * pins is a claim that drifts. Everything asserted here is a claim about a run - * — which stops it has, which it will never reach, and what settled the ones it - * is not going to ask about. + * The diagram `renderRotationPlan` prints is claimed to be the array + * `rotationPlan` returns, and a claim nobody pins is a claim that drifts. + * Everything asserted here is a claim about a run — which stops it has, which + * it will never reach, and what settled the ones it is not going to ask about. */ import { describe, test, expect } from 'bun:test'; -import { connectPlan, rotationPlan, cap } from '../../src/commands/connectors/plans'; -import { pushOutcomeFor } from '../../src/commands/connectCommand'; - -describe('connectPlan', () => { - const BASE = { provider: 'stripe', branch: 'development', requiresTool: 'stripe', requiresAuth: true, push: true }; - - test('declares the whole route in the order connect() travels it', () => { - const stops = connectPlan({ ...BASE, standing: 'var' }); - expect(stops.map((s) => s.id)).toEqual(['cli', 'var', 'mode', 'auth', 'account', 'push']); - // No `refresh`. It re-ran `stripe login`, which rewrites the user's - // pairing — a credential operation, and `connect` does not do those. It - // earned its place when connect wrote the key into `.env`; nothing is - // written now, so `capy rotate` owns the near-expiry decision. - expect(stops.some((s) => s.id === 'refresh')).toBe(false); - }); - - test('the precheck stop is on the rail even though it is never a question', () => { - // A failed precheck exits before anything can be asked, so it arrives as a - // wall rather than a step — but the route really did run through it. - const stops = connectPlan({ ...BASE, standing: 'var' }); - const cli = stops.find((s) => s.id === 'cli')!; - expect(cli.state).toBe('done'); - expect(cli.label).toBe('Stripe CLI'); - expect(cli.answer).toBe('found'); - }); - - test('a connector with no local dependency has no CLI stop at all', () => { - const stops = connectPlan({ provider: 'acme', branch: 'main', push: true }); - expect(stops.some((s) => s.id === 'cli')).toBe(false); - // …and one that never signs in has no sign-in stop either, rather than one - // claiming a pairing happened. - expect(stops.some((s) => s.id === 'auth')).toBe(false); - }); - - test('only the stop being asked is current', () => { - const stops = connectPlan({ ...BASE, standing: 'mode', varName: 'STRIPE_SECRET_KEY' }); - expect(stops.filter((s) => s.state === 'current').map((s) => s.id)).toEqual(['mode']); - }); - - test('a flag-answered stop is done and names the flag, never skipped', () => { - const stops = connectPlan({ - ...BASE, - standing: 'mode', - varName: 'STRIPE_SECRET_KEY', - varFromFlag: true, - account: 'acct_1234', - accountFromFlag: true, - push: false, - pushFromFlag: true, - }); - const byId = Object.fromEntries(stops.map((s) => [s.id, s])); - expect(byId.var.state).toBe('done'); - expect(byId.var.answer).toBe('STRIPE_SECRET_KEY'); - expect(byId.var.flag).toBe('--var'); - expect(byId.account.flag).toBe('--account'); - expect(byId.push.flag).toBe('--no-push'); - expect(byId.push.answer).toBe('local only'); - }); - - test('test mode names no flag, because --test does not exist', () => { - // Naming one would be the rail telling the reader to retype an argument - // the command would reject. - const asked = connectPlan({ ...BASE, standing: null, mode: 'test' }); - expect(asked.find((s) => s.id === 'mode')!.flag).toBeUndefined(); - const flagged = connectPlan({ ...BASE, standing: null, mode: 'live', modeFromFlag: true }); - expect(flagged.find((s) => s.id === 'mode')!.flag).toBe('--live'); - }); - - test('a stop the run cannot reach is skipped rather than dropped', () => { - const stops = connectPlan({ - ...BASE, - standing: 'account', - alreadySignedIn: true, - }); - const byId = Object.fromEntries(stops.map((s) => [s.id, s])); - expect(byId.auth.state).toBe('skipped'); - expect(byId.auth.answer).toBe('already paired'); - // Still six stops: a skipped station is a fact about the route, and an - // already-paired run really did travel past the sign-in. - expect(stops).toHaveLength(6); - }); - - test('the one hand-off is marked manual, so its track draws dotted', () => { - // Sign-in is the only stop left that Capy cannot travel for you: the - // provider's own browser pairing. `refresh` was the other, and removing it - // means the route now has exactly one thing it hands back to the user. - const stops = connectPlan({ ...BASE, standing: 'var' }); - expect(stops.find((s) => s.id === 'auth')!.manual).toBe(true); - expect(stops.find((s) => s.id === 'account')!.manual).toBeUndefined(); - expect(stops.filter((s) => s.manual)).toHaveLength(1); - }); - - test('push is the terminus and never a question', () => { - const stops = connectPlan({ ...BASE, standing: 'push' as never }); - const push = stops[stops.length - 1]; - expect(push.id).toBe('push'); - // Even when the run is "standing" there, it is not asked — nothing offers - // it, `--no-push` settles it, and a rail that made it current would be - // promising a question that never comes. - expect(push.state).toBe('upcoming'); - expect(push.detail).toContain('development'); - }); - - test('carries no key material', () => { - const json = JSON.stringify(connectPlan({ ...BASE, standing: null, varName: 'K', mode: 'live', account: 'acct_1' })); - expect(json).not.toContain('sk_'); - expect(json).not.toContain('rk_'); - }); - - test('a finished run says what became of the push, rather than promising it', () => { - // The result page draws this rail. `upcoming` beside a body reading "The - // push did not land" is the rail contradicting the page it sits on — and - // the rail is the half that looks authoritative. - const failed = connectPlan({ ...BASE, standing: null, varName: 'K', pushOutcome: 'failed' }); - const push = failed[failed.length - 1]; - expect(push.state).toBe('current'); - expect(push.blank).toBe(true); - expect(push.detail).toBe('did not reach Capy (branch: development)'); - expect(push.answer).toBeUndefined(); - - const landed = connectPlan({ ...BASE, standing: null, varName: 'K', pushOutcome: 'landed' }); - const done = landed[landed.length - 1]; - expect(done.state).toBe('done'); - expect(done.answer).toBe('pushed to development'); - - // A run that ended before the push ever ran leaves the stop where it was. - const notReached = connectPlan({ ...BASE, standing: null, varName: 'K', pushOutcome: 'not-reached' }); - expect(notReached[notReached.length - 1].state).toBe('upcoming'); - }); -}); - -describe('pushOutcomeFor', () => { - test('maps every ending, off the outcome enum rather than its prose', () => { - expect(pushOutcomeFor('pushed')).toBe('landed'); - expect(pushOutcomeFor('push-failed')).toBe('failed'); - // Three endings that never attempted a push: `--no-push`, a local write - // that failed, and a live gate that was declined. - expect(pushOutcomeFor('local-only')).toBe('not-reached'); - expect(pushOutcomeFor('write-failed')).toBe('not-reached'); - expect(pushOutcomeFor('cancelled')).toBe('not-reached'); - }); -}); +import { rotationPlan, cap } from '../../src/commands/connectors/plans'; describe('rotationPlan', () => { const BASE = { branch: 'development', providers: ['stripe'], authProviders: ['stripe'] }; diff --git a/tests/commands/connectors/stripeDescribe.test.ts b/tests/commands/connectors/stripeDescribe.test.ts deleted file mode 100644 index e3f76ae8..00000000 --- a/tests/commands/connectors/stripeDescribe.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * What the Stripe connector tells the browser about the machine it is on. - * - * Every one of these is a pure read of state the command already has, and - * every one of them is a place a secret could leak into a payload if it were - * written carelessly. The tests that matter most here are the negative ones. - */ -import { describe, test, expect } from 'bun:test'; -import { - currentVarState, - describeIncomingKey, - noRunnableMode, - parseStripeConfig, - stripeModeOptions, - stripeVarSlots, - toStripeAccounts, -} from '../../../src/commands/connectors/stripe'; -import type { ResolvedContext } from '../../../src/commands/connectors/shared'; -import type { KeepFile } from '../../../src/types/index'; - -const CONFIG = ` -[default] -account_id = 'acct_1234' -display_name = 'Mikes Market' -test_mode_api_key = 'rk_test_51HabcdefgHIJKLMNOP' -test_mode_key_expires_at = '2099-01-01' - -[second] -account_id = 'acct_5678' -live_mode_api_key = 'rk_live_51HzyxwvutSRQPONML' -`; - -const SECTIONS = parseStripeConfig(CONFIG); - -function ctxWith( - localPlaintext: Record, - variables: KeepFile['variables'] = {}, -): ResolvedContext { - const keep: KeepFile = { - version: '3.0', - org_id: 'o', - project_id: 'p', - project_name: 'mikes-market', - variables, - }; - return { keep, branch: 'development', localPlaintext } as unknown as ResolvedContext; -} - -describe('toStripeAccounts', () => { - test('carries the section name, the account id and what each can serve', () => { - const accounts = toStripeAccounts(SECTIONS); - expect(accounts.map((a) => a.id)).toEqual(['default', 'second']); - expect(accounts[0].accountId).toBe('acct_1234'); - expect(accounts[0].displayName).toBe('Mikes Market'); - expect(accounts[0].hasTestKey).toBe(true); - expect(accounts[0].hasLiveKey).toBe(false); - expect(accounts[1].hasLiveKey).toBe(true); - }); - - test('carries no key value', () => { - const json = JSON.stringify(toStripeAccounts(SECTIONS)); - expect(json).not.toContain('rk_test_51HabcdefgHIJKLMNOP'); - expect(json).not.toContain('rk_live_51HzyxwvutSRQPONML'); - }); -}); - -describe('stripeModeOptions', () => { - test('says which modes this machine can actually serve', () => { - // The terminal offers both and finds out one question later, in - // `readKeyFromSection`, that there is no key — an exit, too late. - const modes = stripeModeOptions(SECTIONS, 'acct_1234', false); - const test_ = modes.find((m) => m.id === 'test')!; - const live = modes.find((m) => m.id === 'live')!; - expect(test_.available).toBe(true); - expect(test_.keyPrefix).toBe('rk_test_'); - expect(live.available).toBe(false); - expect(live.blockedBy).toBe('NO_KEY'); - }); - - test('a prefix is eight characters and no more', () => { - for (const m of stripeModeOptions(SECTIONS, 'acct_1234', false)) { - if (m.keyPrefix) expect(m.keyPrefix.length).toBe(8); - } - expect(JSON.stringify(stripeModeOptions(SECTIONS, 'acct_1234', false))).not.toContain( - 'rk_test_51HabcdefgHIJKLMNOP', - ); - }); - - test('capy-dev refuses live mode beside the option, not two screens later', () => { - const live = stripeModeOptions(SECTIONS, 'acct_5678', true).find((m) => m.id === 'live')!; - expect(live.available).toBe(false); - expect(live.blockedBy).toBe('DEV_MODE'); - }); - - test('an unresolved account offers what any section could serve, never a false no', () => { - // Several accounts and none named: availability is genuinely unknown, and - // a definite `false` would be a claim the CLI cannot make here. - const modes = stripeModeOptions(SECTIONS, undefined, false); - expect(modes.find((m) => m.id === 'test')!.available).toBe(true); - expect(modes.find((m) => m.id === 'live')!.available).toBe(true); - expect(modes.every((m) => m.keyPrefix === undefined)).toBe(true); - }); - - test('nothing paired yet offers both, because nothing has been read', () => { - const modes = stripeModeOptions([], undefined, false); - expect(modes.every((m) => m.available)).toBe(true); - }); - - test('a value too short to redact gets no prefix at all', () => { - // Eight characters of a nine-character value is a redaction; eight - // characters of an eight-character value is the value. `safeSnippet` - // already withholds a fingerprint on the same rule and this is the other - // half of it. Unreachable with a real Stripe key, which is exactly why it - // has to be written down rather than left to the shape of the input. - const short = parseStripeConfig(` -[default] -account_id = 'acct_short' -test_mode_api_key = 'rk_test1' -`); - const test_ = stripeModeOptions(short, 'acct_short', false).find((m) => m.id === 'test')!; - expect(test_.available).toBe(true); - expect(test_.keyPrefix).toBeUndefined(); - expect(JSON.stringify(test_)).not.toContain('rk_test1'); - expect(describeIncomingKey(short, 'test', 'acct_short').keyPrefix).toBe(''); - }); -}); - -describe('noRunnableMode', () => { - test('says nothing while a mode can still run', () => { - expect(noRunnableMode(stripeModeOptions(SECTIONS, 'acct_1234', false))).toBeUndefined(); - }); - - test('a live-only config under capy-dev is a wall, not a question', () => { - // The state the mode question cannot ask its way out of: live is refused - // by the dev firewall, test has no key, the screen disables both rows and - // the reducer refuses either. Asking here parks the run on a dead page - // until the wizard times out. - const liveOnly = parseStripeConfig(` -[default] -account_id = 'acct_5678' -live_mode_api_key = 'rk_live_51HzyxwvutSRQPONML' -`); - const wall = noRunnableMode(stripeModeOptions(liveOnly, 'acct_5678', true))!; - expect(wall.code).toBe('DEV_MODE_NO_TEST_KEY'); - expect(wall.remedy).toBe('stripe login'); - }); - - test('a section with no key at all is the other wall', () => { - const keyless = parseStripeConfig(` -[default] -account_id = 'acct_none' -`); - const wall = noRunnableMode(stripeModeOptions(keyless, 'acct_none', false))!; - expect(wall.code).toBe('NO_STRIPE_KEY'); - }); -}); - -describe('stripeVarSlots', () => { - test('puts the Stripe-shaped names first, the way rankStripeVars does', () => { - const ctx = ctxWith({ DATABASE_URL: 'postgres://x', STRIPE_SECRET_KEY: 'rk_test_abc12345' }); - const slots = stripeVarSlots(ctx); - expect(slots.map((s) => s.name)).toEqual(['STRIPE_SECRET_KEY', 'DATABASE_URL']); - expect(slots[0].looksRelated).toBe(true); - expect(slots[1].looksRelated).toBe(false); - }); - - test('marks a slot a connector already owns', () => { - const ctx = ctxWith( - { STRIPE_SECRET_KEY: 'rk_test_abc12345' }, - { - STRIPE_SECRET_KEY: [ - { - resource_id: 'r', - branch: 'development', - value_hash: 'h', - connector: { provider: 'stripe', source: 'cli', mode: 'test', created_at: 1 }, - }, - ], - }, - ); - expect(stripeVarSlots(ctx)[0].managedBy).toBe('stripe'); - }); - - test('carries no value', () => { - const ctx = ctxWith({ STRIPE_SECRET_KEY: 'rk_test_abc12345', DATABASE_URL: 'postgres://x' }); - const json = JSON.stringify(stripeVarSlots(ctx)); - expect(json).not.toContain('rk_test_abc12345'); - expect(json).not.toContain('postgres://x'); - }); -}); - -describe('currentVarState', () => { - test('reduces the value in the slot to a fingerprint', () => { - const ctx = ctxWith({ STRIPE_SECRET_KEY: 'rk_test_abcdefghijklmnop' }); - const state = currentVarState(ctx, 'STRIPE_SECRET_KEY'); - expect(state.fingerprint).toBe('rk_…nop'); - expect(JSON.stringify(state)).not.toContain('rk_test_abcdefghijklmnop'); - }); - - test('a value too short to redact gets NO fingerprint at all', () => { - // `fingerprint()` returns anything seven characters or shorter verbatim, - // which is right for a terminal the user is already looking at and a leak - // in a payload. The screen renders an absent fingerprint as "not recorded". - const ctx = ctxWith({ API_KEY: 'hunter2' }); - const state = currentVarState(ctx, 'API_KEY'); - expect(state.fingerprint).toBeUndefined(); - expect(JSON.stringify(state)).not.toContain('hunter2'); - }); - - test('says whether teammates are holding this value', () => { - // An entry seeded before any push carries an empty value_hash; anything - // else came back from the service, which means the branch has it. - const pushed = ctxWith({ K: 'rk_test_abcdefghij' }, { - K: [{ resource_id: 'r', branch: 'development', value_hash: 'h' }], - }); - expect(currentVarState(pushed, 'K').pushed).toBe(true); - - const local = ctxWith({ K: 'rk_test_abcdefghij' }, { - K: [{ resource_id: '', branch: 'development', value_hash: '' }], - }); - expect(currentVarState(local, 'K').pushed).toBe(false); - }); - - test('surfaces the connector that set it, and when', () => { - const ctx = ctxWith({ K: 'rk_live_abcdefghij' }, { - K: [ - { - resource_id: 'r', - branch: 'development', - value_hash: 'h', - connector: { - provider: 'stripe', - source: 'cli', - mode: 'live', - created_at: Math.floor(Date.now() / 1000) - 3 * 86400, - }, - }, - ], - }); - const state = currentVarState(ctx, 'K'); - expect(state.managedBy).toBe('stripe'); - expect(state.mode).toBe('live'); - expect(state.age).toBe('3 days ago'); - }); - - test('a hand-entered value claims no connector', () => { - const ctx = ctxWith({ K: 'rk_test_abcdefghij' }); - const state = currentVarState(ctx, 'K'); - expect(state.managedBy).toBeUndefined(); - expect(state.mode).toBeUndefined(); - }); -}); - -describe('describeIncomingKey', () => { - test('describes the key an unambiguous account would hand over', () => { - const incoming = describeIncomingKey(SECTIONS, 'test', 'acct_1234'); - expect(incoming.keyPrefix).toBe('rk_test_'); - expect(incoming.mode).toBe('test'); - expect(incoming.accountId).toBe('acct_1234'); - expect(incoming.fingerprint).toBe('rk_…NOP'); - expect(JSON.stringify(incoming)).not.toContain('rk_test_51HabcdefgHIJKLMNOP'); - }); - - test('claims nothing about a key the run has not resolved yet', () => { - // `confirmOverwrite` runs before the account is settled and before any - // `stripe login`, so with several accounts paired and none named there is - // no key to describe. Empty rather than invented — see the note on - // `describeIncomingKey` about the contract this reveals. - const incoming = describeIncomingKey(SECTIONS, 'live', undefined); - expect(incoming.keyPrefix).toBe(''); - expect(incoming.fingerprint).toBe(''); - expect(incoming.mode).toBe('live'); - }); -}); diff --git a/tests/commands/deployDokployPickerTokenEnv.test.ts b/tests/commands/deployDokployPickerTokenEnv.test.ts index 07dd1dcd..0ac2bbaf 100644 --- a/tests/commands/deployDokployPickerTokenEnv.test.ts +++ b/tests/commands/deployDokployPickerTokenEnv.test.ts @@ -46,7 +46,7 @@ mock.module('../../src/system/systemStore', () => ({ getDirectionalConnectorSecret: mock(async () => null), })); -import { resolveAdapterOptions, settingsDefaults } from '../../src/commands/deployCommand'; +import { resolveAdapterOptions } from '../../src/commands/deployCommand'; import { getAdapter } from '../../src/deploy/registry'; afterEach(() => { @@ -439,49 +439,4 @@ describe('capy deploy picker — dokploy URL input (CAP-657) + tokenEnv (CAP-664 }); }); }); - - // ── validator fix-first: settingsDefaults (the WEB surface's copy of the - // terminal picker's own defaults) is UNCHANGED by CAP-657 — Dokploy setup - // stays terminal-only (TERMINAL_ONLY_SETUP), so this function never drives - // the URL question at all; it must still include composeId/kind so a - // re-edit never disagrees between the two surfaces about what a Compose - // target starts from. ── - describe('settingsDefaults — dokploy (web surface parity, unchanged by CAP-657)', () => { - test('a brand-new target defaults kind to compose, with both ids blank', () => { - expect(settingsDefaults('dokploy', '/tmp', {}, {})).toEqual({ - baseUrl: '', - applicationId: '', - composeId: '', - kind: 'compose', - }); - }); - - test('an existing Compose target defaults kind to compose and carries composeId', () => { - expect(settingsDefaults('dokploy', '/tmp', { baseUrl: 'https://x', composeId: 'compose_old' }, {})).toEqual({ - baseUrl: 'https://x', - applicationId: '', - composeId: 'compose_old', - kind: 'compose', - }); - }); - - test('an existing Application target defaults kind to application and carries applicationId', () => { - expect(settingsDefaults('dokploy', '/tmp', { baseUrl: 'https://x', applicationId: 'app_old' }, {})).toEqual({ - baseUrl: 'https://x', - applicationId: 'app_old', - composeId: '', - kind: 'application', - }); - }); - - test('tokenEnv is carried through only when an existing target already has one', () => { - expect(settingsDefaults('dokploy', '/tmp', { applicationId: 'app_old', tokenEnv: 'MY_TOKEN' }, {})).toEqual({ - baseUrl: '', - applicationId: 'app_old', - composeId: '', - kind: 'application', - tokenEnv: 'MY_TOKEN', - }); - }); - }); }); diff --git a/tests/commands/deployDokploySystemStoreToken.test.ts b/tests/commands/deployDokploySystemStoreToken.test.ts index d3f6e8cb..b65ee961 100644 --- a/tests/commands/deployDokploySystemStoreToken.test.ts +++ b/tests/commands/deployDokploySystemStoreToken.test.ts @@ -174,7 +174,7 @@ async function withEnv(vars: Readonly>, fn /** * Fakes a real TTY on stdin — `bun test` never has one — so a test can - * prove `--web`/`--yes`/`--dry-run` suppress the store's prompt EVEN THOUGH + * prove `--yes`/`--dry-run` suppress the store's prompt EVEN THOUGH * a real terminal is present, not merely because there is no terminal to * begin with. Same technique `tests/commands/connectDokploy.test.ts` uses. */ @@ -485,27 +485,6 @@ describe('capy deploy dokploy — org system store token resolution (CAP-664)', } }, 30_000); - test('--web: a real TTY does not let the store prompt — refused, coded, zero prompt calls', async () => { - setUp({}); - const { getConnectorSecret, promptFn } = fakeSystemStore(null); - // CAP-679 follow-up: deploy resolves through `getDirectionalConnectorSecret` - // now, not the bare `getConnectorSecret` — same call shape modulo the - // extra fallback-name argument, which every mock here ignores. - mock.module('../../src/system/systemStore', () => ({ getDirectionalConnectorSecret: getConnectorSecret })); - - try { - const { code } = await withTTY(() => - withEnv({ DOKPLOY_API_KEY: undefined }, async () => ({ - code: await deployCommand('dokploy-ci', { yes: true, web: true }, ROOT), - })), - ); - expect(code).toBe(1); - expect(promptFn).not.toHaveBeenCalled(); - } finally { - tearDown(); - } - }, 30_000); - test('--yes: a real TTY does not let the store prompt — refused, coded, zero prompt calls', async () => { setUp({}); const { getConnectorSecret, promptFn } = fakeSystemStore(null); diff --git a/tests/commands/deployWebFlow.test.ts b/tests/commands/deployWebFlow.test.ts deleted file mode 100644 index 1870508e..00000000 --- a/tests/commands/deployWebFlow.test.ts +++ /dev/null @@ -1,466 +0,0 @@ -/** - * `capy deploy --web` at the COMMAND level: the layer that assembles the - * params, reads the answer back and acts on it. - * - * Everything under `src/ui/deployScreens.ts` had tests; the wiring in - * `deployCommand.ts` and `deployTokenCommand.ts` had none, so nothing checked - * that `deployList`'s web branch really removes the row the page named, that a - * DECLINE is not read as consent, or that the picker hands the browser the - * variables of the directory it was told about. Those are the decisions with - * consequences on disk. - * - * These drive the CLI the way the browser does and nothing else: the loopback - * URL is taken off the line the command PRINTS, the page is fetched over HTTP, - * the nonce is read out of the payload inlined into that page — exactly what a - * compiled screen does on load — and the answer is POSTed to `/submit`. No - * module mocking, so these run in the batch and the transport under test is - * the real one. - */ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { - deployCommand, - deployList, - deployRemove, - describeDeployRoute, - ensureDeployTarget, -} from '../../src/commands/deployCommand'; -import { - resolveTokenPrefix, - type DeployTokenListRow, -} from '../../src/commands/deployTokenCommand'; -import { deployPlan, unansweredDeployStops } from '../../src/core/deployPlan'; - -const ROOT = join(tmpdir(), `capy-deploy-web-${process.pid}-${Date.now()}`); - -beforeEach(() => { - rmSync(ROOT, { recursive: true, force: true }); - mkdirSync(join(ROOT, '.capy'), { recursive: true }); -}); - -afterEach(() => { - rmSync(ROOT, { recursive: true, force: true }); -}); - -const TARGETS = { - version: '1', - targets: { - 'cf-worker-production': { - name: 'cf-worker-production', - kind: 'cf-worker', - branch: 'production', - vars: ['DATABASE_URL'], - options: { workerName: 'api', workerDir: '.' }, - mode: 'direct', - }, - legacy: { - name: 'legacy', - kind: 'cf-worker', - branch: 'development', - vars: ['DATABASE_URL', 'STRIPE_SECRET_KEY'], - options: {}, - }, - }, -}; - -function writeTargets(): void { - writeFileSync(join(ROOT, '.capy', 'deploy.json'), JSON.stringify(TARGETS, null, 2)); -} - -function savedNames(): string[] { - const p = join(ROOT, '.capy', 'deploy.json'); - if (!existsSync(p)) return []; - return Object.keys(JSON.parse(readFileSync(p, 'utf-8')).targets ?? {}); -} - -function writeKeep(): void { - const variables: Record = {}; - for (const v of ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'VITE_API_URL']) { - variables[v] = ['development', 'production'].map((b) => ({ - resource_id: `r-${b}`, - branch: b, - value_hash: 'h', - })); - } - writeFileSync( - join(ROOT, 'keep.lock'), - JSON.stringify({ version: '3.0', org_id: 'o', project_id: 'p', variables }), - ); -} - -/** - * Capture what the command prints, and hand back the loopback URL from it. - * - * The URL is not returned by any of these functions — it is PRINTED, because - * that line is the whole handoff to the human. Taking it from stdout is - * therefore the same thing an agent does with it. - */ -function captureConsole(): { url: () => string; lines: string[]; restore: () => void } { - const lines: string[] = []; - const realLog = console.log; - const realError = console.error; - console.log = (...a: unknown[]) => void lines.push(a.map(String).join(' ')); - console.error = (...a: unknown[]) => void lines.push(a.map(String).join(' ')); - return { - lines, - url: () => { - const hit = lines.map((l) => l.match(/http:\/\/127\.0\.0\.1:\d+\/\S*/)).find(Boolean); - return hit ? hit[0] : ''; - }, - restore: () => { - console.log = realLog; - console.error = realError; - }, - }; -} - -async function waitFor(get: () => string): Promise { - for (let i = 0; i < 400 && !get(); i++) await new Promise((r) => setTimeout(r, 10)); - const v = get(); - if (!v) throw new Error('the command never printed a loopback URL'); - return v; -} - -/** The nonce a compiled screen reads out of its own inlined payload. */ -async function nonceFrom(url: string): Promise { - const html = await (await fetch(url)).text(); - const m = html.match(/window\.__CAPY_DATA__\s*=\s*(\{[\s\S]*?\});/); - if (!m) throw new Error('no payload inlined into the served page'); - return JSON.parse(m[1]).nonce; -} - -/** Answer the page, the way the screen's own `submitToCli` does. */ -async function answer(url: string, payload: Record): Promise { - const u = new URL(url); - const nonce = await nonceFrom(url); - const res = await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ nonce, payload }), - }); - return res.json(); -} - -// --------------------------------------------------------------------------- -// capy deploy list --web -// --------------------------------------------------------------------------- - -describe('deployList --web', () => { - test('removing a row removes that target and nothing else', async () => { - writeTargets(); - const cap = captureConsole(); - try { - const run = deployList(ROOT, { web: true }); - const url = await waitFor(cap.url); - - // The page carries the rows the CLI read off disk — by name, never a value. - const html = await (await fetch(url)).text(); - expect(html).toContain('window.__CAPY_DATA__'); - expect(html).not.toContain('sk_live'); - - await answer(url, { __action: 'remove', target: 'legacy' }); - expect(await run).toBe(0); - } finally { - cap.restore(); - } - expect(savedNames()).toEqual(['cf-worker-production']); - }, 30_000); - - test('a cancelled listing changes nothing and exits 0', async () => { - // Closing the window is a refusal. A listing that ends without an answer - // has removed nothing, and it is not a failure. - // - // `{__action:'cancel'}` is not a shape invented for this test: it is what - // `withDeclineBridge` posts, and what the screen itself should post once it - // grows the control. The BROWSER-driven proof is - // `tests/ui/browserFlow.e2e.test.ts`, which clicks the real button and - // times the run — this asserts what the command does with the answer. - writeTargets(); - const cap = captureConsole(); - try { - const run = deployList(ROOT, { web: true }); - const url = await waitFor(cap.url); - await answer(url, { __action: 'cancel' }); - expect(await run).toBe(0); - } finally { - cap.restore(); - } - expect(savedNames()).toEqual(['cf-worker-production', 'legacy']); - }, 30_000); - - test('a target the page could not have offered is refused, not removed', async () => { - // The submitted name is resolved against the list the SERVER sent. A name - // that was never on it did not come from the screen. - writeTargets(); - const cap = captureConsole(); - try { - const run = deployList(ROOT, { web: true }); - const url = await waitFor(cap.url); - const refused = (await answer(url, { __action: 'remove', target: 'not-a-target' })) as { - error?: string; - }; - expect(refused.error).toContain('not saved for this project'); - // Still live, still waiting: an inline refusal keeps the page open. - await answer(url, { __action: 'cancel' }); - expect(await run).toBe(0); - } finally { - cap.restore(); - } - expect(savedNames()).toEqual(['cf-worker-production', 'legacy']); - }, 30_000); -}); - -// --------------------------------------------------------------------------- -// capy deploy targets-remove --web -// --------------------------------------------------------------------------- - -describe('deployRemove --web', () => { - test('declining the confirm KEEPS the target and exits 0', async () => { - // The terminal removes on a bare argument with no question at all. The - // settings behind that name took seven prompts to produce and - // `.capy/deploy.json` keeps no history — so a decline has to survive. - // - // The decline is POSTed here rather than clicked, and what a click actually - // costs is the other half of this: driven in a real browser this run sat - // for 300,021 ms before printing the very line asserted below. That half - // lives in `tests/ui/browserFlow.e2e.test.ts`, which clicks "Keep it" and - // fails the run if it has not ended fifteen seconds later. - writeTargets(); - const cap = captureConsole(); - try { - const run = deployRemove('legacy', ROOT, { web: true }); - const url = await waitFor(cap.url); - await answer(url, { __action: 'cancel' }); - expect(await run).toBe(0); - expect(cap.lines.join('\n')).toContain('Kept target'); - } finally { - cap.restore(); - } - expect(savedNames()).toEqual(['cf-worker-production', 'legacy']); - }, 30_000); - - test('confirming it removes exactly the named target', async () => { - writeTargets(); - const cap = captureConsole(); - try { - const run = deployRemove('legacy', ROOT, { web: true }); - const url = await waitFor(cap.url); - await answer(url, { __action: 'remove', target: 'legacy' }); - expect(await run).toBe(0); - } finally { - cap.restore(); - } - expect(savedNames()).toEqual(['cf-worker-production']); - }, 30_000); - - test('a name that is not saved never opens a window', async () => { - writeTargets(); - const cap = captureConsole(); - try { - const code = await deployRemove('nope', ROOT, { web: true }); - expect(code).toBe(1); - expect(cap.url()).toBe(''); - } finally { - cap.restore(); - } - expect(savedNames()).toEqual(['cf-worker-production', 'legacy']); - }, 30_000); -}); - -// --------------------------------------------------------------------------- -// "which target?" — the picker, at the layer that acts on the answer -// --------------------------------------------------------------------------- - -describe('ensureDeployTarget --web', () => { - test('the row that was clicked is the target the caller gets back', async () => { - writeTargets(); - writeKeep(); - const cap = captureConsole(); - try { - const run = ensureDeployTarget(ROOT, { web: true }); - const url = await waitFor(cap.url); - await answer(url, { __action: 'use', target: 'legacy' }); - const picked = await run; - expect(picked?.name).toBe('legacy'); - expect(picked?.branch).toBe('development'); - } finally { - cap.restore(); - } - }, 30_000); - - test('a cancelled pick resolves to nothing rather than the first row', async () => { - writeTargets(); - writeKeep(); - const cap = captureConsole(); - try { - const run = ensureDeployTarget(ROOT, { web: true }); - const url = await waitFor(cap.url); - await answer(url, { __action: 'cancel' }); - expect(await run).toBeNull(); - } finally { - cap.restore(); - } - }, 30_000); -}); - -// --------------------------------------------------------------------------- -// the setup picker: the params it hands the browser -// --------------------------------------------------------------------------- - -describe('runPicker --web, through ensureDeployTarget', () => { - test('the variables offered are the ones in THIS directory', async () => { - // The picker scopes its checkbox to the materialized .env of the directory - // it was handed. Reading a different directory's .env is how a target gets - // saved ticking a variable the project does not have. - writeKeep(); - writeFileSync(join(ROOT, '.env'), 'DATABASE_URL=enc:aaa\nSTRIPE_SECRET_KEY=enc:bbb\n'); - writeFileSync(join(ROOT, 'wrangler.toml'), 'name = "api"\n'); - const cap = captureConsole(); - try { - const run = ensureDeployTarget(ROOT, { web: true }); - const url = await waitFor(cap.url); - const html = await (await fetch(url)).text(); - const data = JSON.parse( - html.match(/window\.__CAPY_DATA__\s*=\s*(\{[\s\S]*?\});/)![1], - ); - - // keep.lock lists three; the .env in THIS directory has two. - expect(data.adapters.map((a: { id: string }) => a.id)).toContain('cf-worker'); - // No decrypted value goes out with them. - expect(html).not.toContain('enc:aaa'); - - await answer(url, { __action: 'cancel' }); - expect(await run).toBeNull(); - } finally { - cap.restore(); - } - }, 30_000); - - test('a branch with no variables refuses instead of saving an empty target', async () => { - // A target with nothing to push is a deploy that reports success having - // sent nothing. No window opens for it. - writeKeep(); - const cap = captureConsole(); - let thrown: unknown = null; - try { - await ensureDeployTarget(ROOT, { web: true }).catch((e) => (thrown = e)); - } finally { - cap.restore(); - } - expect(String(thrown)).toContain('no variables on the active branch'); - expect(cap.url()).toBe(''); - }, 30_000); -}); - -// --------------------------------------------------------------------------- -// capy deploy revoke --web — which token the prefix names -// --------------------------------------------------------------------------- - -describe('resolveTokenPrefix', () => { - const row = (deployId: string): DeployTokenListRow => ({ - deployId, - label: null, - createdAge: 'just now', - createdOn: '2026-07-30', - revokedAge: null, - }); - const ROWS = [row('a1b2c3d4e5f6a7b8c9d0'), row('a1b2c3d4e5f6ffffffff'), row('ffee00112233')]; - - test('a prefix only one token has resolves to it', () => { - expect(resolveTokenPrefix(ROWS, 'ffee')).toEqual({ code: 'ok', token: ROWS[2] }); - }); - - test('a prefix two tokens share is REFUSED, not resolved to the first', () => { - // Twelve characters is what the terminal shows of an id, so twelve is what - // a user retypes — and these two share all twelve. Revoking is - // irreversible and cuts a live pipeline off; "probably this one" is not an - // answer to which one. - const out = resolveTokenPrefix(ROWS, 'a1b2c3d4e5f6'); - expect(out.code).toBe('ambiguous'); - expect(out.code === 'ambiguous' && out.matches).toHaveLength(2); - }); - - test('a full id is never ambiguous, whatever else it prefixes', () => { - expect(resolveTokenPrefix(ROWS, 'a1b2c3d4e5f6a7b8c9d0')).toEqual({ - code: 'ok', - token: ROWS[0], - }); - }); - - test('a prefix nothing matches is its own answer', () => { - expect(resolveTokenPrefix(ROWS, 'deadbeef')).toEqual({ code: 'none' }); - }); -}); - -// --------------------------------------------------------------------------- -// capy deploy --json — the route, described rather than travelled -// --------------------------------------------------------------------------- - -describe('deployCommand --json', () => { - test('emits the SAME array the browser screens are served', async () => { - writeTargets(); - writeKeep(); - const cap = captureConsole(); - let code = 1; - try { - code = await deployCommand('legacy', { json: true }, ROOT); - } finally { - cap.restore(); - } - expect(code).toBe(0); - - const printed = JSON.parse(cap.lines.join('\n')); - const { stops, unanswered } = describeDeployRoute('legacy', {}, ROOT); - expect(printed.stops).toEqual(stops); - // Derived from the plan, never recomputed beside it. - expect(printed.unanswered).toEqual(unansweredDeployStops(stops)); - // Ten stations, always — a stop this run will not travel is `skipped` - // rather than absent. - expect(printed.stops).toHaveLength(deployPlan().length); - }, 30_000); - - test('a saved target settles the stops it answers, and says which are left', async () => { - writeTargets(); - writeKeep(); - const { stops, unanswered } = describeDeployRoute('legacy', {}, ROOT); - const byId = Object.fromEntries(stops.map((s) => [s.id, s])); - expect(byId.branch).toMatchObject({ state: 'done', answer: 'development' }); - expect(byId.name).toMatchObject({ state: 'done', answer: 'legacy' }); - expect(byId.variables).toMatchObject({ state: 'done', answer: '2 variables' }); - // Never asked: this run came in with a target name, so nobody chose a mode. - expect(byId.mode.state).toBe('skipped'); - // Still ahead of it, and named as such. - expect(unanswered).toContain('review'); - expect(unanswered).toContain('signin'); - expect(unanswered).not.toContain('deploy'); - }); - - test('an ad-hoc --target is never named, and says so', async () => { - writeKeep(); - const { stops, unanswered } = describeDeployRoute(undefined, { target: 'cf-worker' }, ROOT); - const byId = Object.fromEntries(stops.map((s) => [s.id, s])); - expect(byId.platform).toMatchObject({ state: 'done', answer: 'Cloudflare Workers' }); - // An ad-hoc target is never written to disk, so the naming question does - // not happen rather than going unanswered. - expect(byId.name.state).toBe('skipped'); - expect(unanswered).not.toContain('name'); - // Where the traveller stands is the first outstanding stop, taken off the - // plan itself. - expect(stops.find((s) => s.state === 'current')!.id).toBe(unanswered[0]); - }); - - test('a dry run draws its terminus unreachable, and prints no secret', async () => { - writeTargets(); - const cap = captureConsole(); - try { - expect(await deployCommand('legacy', { json: true, dryRun: true }, ROOT)).toBe(0); - } finally { - cap.restore(); - } - const printed = JSON.parse(cap.lines.join('\n')); - expect(printed.stops.find((s: { id: string }) => s.id === 'deploy').blank).toBe(true); - expect(cap.lines.join('\n')).not.toContain('sk_live'); - }, 30_000); -}); diff --git a/tests/commands/editMode.test.ts b/tests/commands/editMode.test.ts index 22c6199b..996126bf 100644 --- a/tests/commands/editMode.test.ts +++ b/tests/commands/editMode.test.ts @@ -13,36 +13,28 @@ import { EditScreen, focusedOn, type EditRow, type EditState } from '../../src/u const facts = (over: Partial): EditModeFacts => ({ hasName: false, - web: false, stdinIsTTY: false, nonTty: false, ...over, }); describe('decideEditMode', () => { - test('1. a terminal and no name is the full-table TUI, or the browser editor with --web: unchanged', () => { + test('1. a terminal and no name is the full-table TUI: unchanged', () => { expect(decideEditMode(facts({ stdinIsTTY: true }))).toBe('tui'); - expect(decideEditMode(facts({ stdinIsTTY: true, web: true }))).toBe('web'); }); - test('2. a terminal and a name is still the TUI (focused on NAME), or the browser editor with --web', () => { + test('2. a terminal and a name is still the TUI (focused on NAME)', () => { expect(decideEditMode(facts({ stdinIsTTY: true, hasName: true }))).toBe('tui'); - expect(decideEditMode(facts({ stdinIsTTY: true, hasName: true, web: true }))).toBe('web'); }); - test('a name with stdin not a terminal is piped mode, and --web is ignored', () => { + test('a name with stdin not a terminal is piped mode', () => { expect(decideEditMode(facts({ hasName: true }))).toBe('piped'); - expect(decideEditMode(facts({ hasName: true, web: true }))).toBe('piped'); }); test('7. no name and no terminal is refused (EDIT_NEEDS_TTY) - the hang this fixes', () => { expect(decideEditMode(facts({}))).toBe('refuse'); }); - test('no name, no terminal, --web stays the headless browser editor agents have always used', () => { - expect(decideEditMode(facts({ web: true }))).toBe('web'); - }); - test('--non-tty takes the terminal off the table even when stdin is one', () => { expect(decideEditMode(facts({ stdinIsTTY: true, nonTty: true }))).toBe('refuse'); // a name with nothing piped to read: refused, not a read from the keyboard diff --git a/tests/commands/intakeSecurity.test.ts b/tests/commands/intakeSecurity.test.ts deleted file mode 100644 index 20926e8a..00000000 --- a/tests/commands/intakeSecurity.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, test, expect } from 'bun:test'; -import { randomBytes } from 'crypto'; -import { nonceEqual, isLoopbackHost, isAllowedOrigin } from '../../src/commands/intakeSecurity'; - -describe('nonceEqual', () => { - test('true for identical nonces', () => { - const n = randomBytes(32).toString('hex'); - expect(nonceEqual(n, n)).toBe(true); - }); - test('false for different nonces', () => { - expect(nonceEqual(randomBytes(32).toString('hex'), randomBytes(32).toString('hex'))).toBe(false); - }); - test('false for length mismatch or non-string', () => { - expect(nonceEqual('abc', 'abcd')).toBe(false); - expect(nonceEqual(undefined, 'abcd')).toBe(false); - expect(nonceEqual(123, 'abcd')).toBe(false); - }); -}); - -describe('isLoopbackHost', () => { - test('only the exact bound loopback host passes', () => { - expect(isLoopbackHost('127.0.0.1:5000', 5000)).toBe(true); - expect(isLoopbackHost('127.0.0.1:5001', 5000)).toBe(false); - expect(isLoopbackHost('evil.com', 5000)).toBe(false); - expect(isLoopbackHost('localhost:5000', 5000)).toBe(false); - expect(isLoopbackHost(undefined, 5000)).toBe(false); - }); -}); - -describe('isAllowedOrigin', () => { - test('absent origin allowed; present must match exactly', () => { - expect(isAllowedOrigin(undefined, 5000)).toBe(true); - expect(isAllowedOrigin('http://127.0.0.1:5000', 5000)).toBe(true); - expect(isAllowedOrigin('http://evil.com', 5000)).toBe(false); - expect(isAllowedOrigin('https://127.0.0.1:5000', 5000)).toBe(false); - }); -}); diff --git a/tests/commands/inviteCommand.test.ts b/tests/commands/inviteCommand.test.ts index e24592aa..a30793de 100644 --- a/tests/commands/inviteCommand.test.ts +++ b/tests/commands/inviteCommand.test.ts @@ -1,34 +1,14 @@ /** * `capy invite`, at the command level. * - * WHAT THIS FILE IS FOR, in order of what would hurt most if it broke: - * - * 1. THE REDEEM CODE STOPS BEING PRINTED UNDER `--web`. It carries a - * double-wrapped copy of the organization key, unwrappable only by the - * invited email. `--web` is agent-only and an agent shelling - * `capy` reads stdout, so under it the code goes to a page and NOWHERE - * else — not stdout, not stderr, not the clipboard prompt. That is the - * load-bearing claim of this whole path and it was pinned by nothing: - * code reading confirmed it held, and one refactor would have moved the - * `console.log` out of its `else` branch with no test to notice. - * 2. THE PAGE IS STILL THERE WHEN THE COMMAND RETURNS. A URL printed for a - * server that has already been torn down is worse than no page at all — - * the code is unreachable and unrecoverable. So the page is fetched AFTER - * `execute()` has resolved, the way a human opening the printed link does. - * 3. A CANCELLED BROWSER MINTS NOTHING. Everything past the browser hands - * somebody a copy of the org key. - * 4. The rail `--json` prints describes the run that happened. - * - * The browser itself is driven in tests/ui/browserFlow.e2e.test.ts. Here the - * question wizard is stubbed and the CODE PAGE IS REAL, because the code page - * is where the invariant above lives. - * - * NEVER remove `CAPY_WEB_NO_OPEN`. Without it these tests open the developer's - * real browser. + * 1. The redeem code is printed (or, under `--json`, put on stdout because it + * was asked for). + * 2. The rail `--json` prints describes the run that happened. + * 3. A role this caller cannot grant is refused before anything is minted. + * 4. The interactive project checkbox is searchable (CAP-700). */ import { mock, spyOn, jest, describe, test, expect, beforeEach, afterAll } from 'bun:test'; -process.env.CAPY_WEB_NO_OPEN = '1'; const mockDetectProjectState = jest.fn(); const mockAuthenticate = jest.fn(); @@ -96,14 +76,11 @@ mock.module('inquirer', () => ({ prompt: mockPromptFn, })); -// The question wizard is stubbed; the code page is not. `serveInviteCode` is -// the real one, on a real loopback server, because "the code is on the page and -// not on stdout" cannot be checked against a stub of the page. -const real = await import('../../src/ui/memberScreens'); -const mockAskInBrowser = jest.fn(); -mock.module('../../src/ui/memberScreens', () => ({ - ...real, - askInviteInBrowser: mockAskInBrowser, +// The terminal project checkbox (CAP-700) is its own prompt, not an inquirer +// question — stubbed so the test can see what it was asked and answer it. +const mockSearchableCheckbox = jest.fn(); +mock.module('../../src/ui/searchableCheckbox', () => ({ + searchableCheckbox: mockSearchableCheckbox, })); afterAll(() => { @@ -130,10 +107,6 @@ function captureOutput(): { out: () => string; restore: () => void } { }; } -/** The loopback URL the `--web` run prints for its code page. */ -const pageUrlIn = (output: string): string => - output.match(/http:\/\/127\.0\.0\.1:\d+\/s\/[A-Za-z0-9_-]+/)?.[0] ?? ''; - describe('InviteCommand', () => { const mockExit = spyOn(process, 'exit').mockImplementation((() => { throw new Error('process.exit'); @@ -162,50 +135,8 @@ describe('InviteCommand', () => { mockInviteToProject.mockResolvedValue(undefined); }); - describe('--web keeps the redeem code off stdout', () => { - test('the code is on the page, the page is reachable, and neither stream holds it', async () => { - mockAskInBrowser.mockResolvedValue({ - role: 'member', - projectIds: ['p1'], - ttl: '6h', - cancelled: false, - }); - - const cap = captureOutput(); - try { - await new InviteCommand().execute('bob@example.com', { web: true }); - } finally { - cap.restore(); - } - const output = cap.out(); - - // The command has RETURNED. The page has to survive that — a URL printed - // for a server already torn down hands the user nothing. - const url = pageUrlIn(output); - expect(url, `no code-page URL in output:\n${output}`).not.toBe(''); - const res = await fetch(url); - expect(res.status).toBe(200); - const html = await res.text(); - - // The code, read off the page the way the browser reads it. - const redeem = html.match(/capy redeem [A-Za-z0-9_\-+/=]+/)?.[0] ?? ''; - expect(redeem, 'the page carries no redeem command').not.toBe(''); - expect(redeem.length).toBeGreaterThan('capy redeem '.length + 40); - - // …and nowhere in what the caller — an agent reading stdout — was given. - expect(output).not.toContain(redeem); - expect(output).not.toContain(redeem.slice('capy redeem '.length)); - expect(output).not.toMatch(/redeem [A-Za-z0-9_\-+/=]{20,}/); - // The clipboard prompt is a second copy of the same credential. - expect(mockPromptFn).not.toHaveBeenCalled(); - // What it DOES say: where the code is, and that this is deliberate. - expect(output).toContain('deliberately not printed here'); - }); - - test('--json still puts the code on stdout, because it was asked for', async () => { - // A caller that explicitly asks for the code in a machine-readable form - // gets it. `--web` is the flag that means "there is a browser to hand it - // to"; `--json` is the one that means "I am the one who needs it". + describe('the redeem code', () => { + test('--json puts the code on stdout, because it was asked for', async () => { const cap = captureOutput(); try { await new InviteCommand().execute('bob@example.com', { @@ -221,10 +152,7 @@ describe('InviteCommand', () => { expect(parsed.redeemCode.length).toBeGreaterThan(40); }); - test('without --web the code is printed, which is what --web exists to stop', async () => { - // The control for the test above: same command, no flag, and the code is - // right there on stdout. Without this the leak test could pass because - // nothing was minted at all. + test('without --json the code is printed for the caller to send', async () => { const cap = captureOutput(); try { await new InviteCommand().execute('bob@example.com', { @@ -238,79 +166,12 @@ describe('InviteCommand', () => { // `capy` is bold on this path, so the two words are not adjacent bytes. expect(cap.out()).toMatch(/redeem [A-Za-z0-9_\-+/=]{20,}/); }); - }); - - describe('the browser is asked, and its answer is the run', () => { - test('cancelling mints nothing and says so', async () => { - mockAskInBrowser.mockResolvedValue({ role: '', projectIds: [], cancelled: true }); - - const cap = captureOutput(); - try { - await new InviteCommand().execute('bob@example.com', { web: true }); - } finally { - cap.restore(); - } - - expect(cap.out()).toContain('No invite created.'); - // Everything past the browser hands somebody a copy of the org key. - expect(mockCreateInvite).not.toHaveBeenCalled(); - expect(mockWrapOuterLayer).not.toHaveBeenCalled(); - expect(pageUrlIn(cap.out())).toBe(''); - }); - - test('the page is told what the code is bound to, not what argv typed', async () => { - // `innerWrap` lowercases into the HKDF salt, so `Bob@Example.com` mints a - // code bound to `bob@example.com`. The screen draws "The address was - // cleaned up" from the two together — and could not, while the producer - // set neither. - mockAskInBrowser.mockResolvedValue({ role: 'admin', projectIds: [], cancelled: false }); - - const cap = captureOutput(); - try { - await new InviteCommand().execute('Bob@Example.com', { web: true }); - } finally { - cap.restore(); - } - - const params = mockAskInBrowser.mock.calls[0][0]; - expect(params.email).toBe('bob@example.com'); - expect(params.rawEmail).toBe('Bob@Example.com'); - }); - - test('a project flag that settled the stop still reaches the invite', async () => { - // `--project` settles the projects stop, so the browser never serves it - // and its answer comes back empty. Read as "no projects", this run grants - // nothing and reports success. - mockAskInBrowser.mockResolvedValue({ role: 'member', projectIds: [], ttl: '6h', cancelled: false }); - - const cap = captureOutput(); - try { - await new InviteCommand().execute('bob@example.com', { web: true, projects: ['warehouse'] }); - } finally { - cap.restore(); - } - - expect(mockCreateInvite).toHaveBeenCalledWith('org-123', 'bob@example.com', 'member', 'p2'); - }); - - test('a lifetime argv gave outranks one the browser offered', async () => { - // The bound on the one divergence `--web` introduces. The browser asks - // about expiry and the terminal never does, so `capy invite bob` and - // `capy invite bob --web` can mint different lifetimes — but only where - // argv left the lifetime unspecified. Name it on the command line and - // both paths mint the same invite, because §8.2's precedence puts an - // explicit flag above a control the same run put on screen. - mockAskInBrowser.mockResolvedValue({ - role: 'member', - projectIds: ['p1'], - ttl: '30m', - cancelled: false, - }); + test('a lifetime argv gave sets the expiry and is named on the rail', async () => { const cap = captureOutput(); const before = Date.now(); try { - await new InviteCommand().execute('bob@example.com', { web: true, json: true, ttl: '6h' }); + await new InviteCommand().execute('bob@example.com', { json: true, role: 'admin', ttl: '6h' }); } finally { cap.restore(); } @@ -322,44 +183,29 @@ describe('InviteCommand', () => { expect(parsed.stops.find((s: any) => s.id === 'expiry').flag).toBe('--ttl 6h'); }); - test('never opens the developer\'s real browser when CAPY_WEB_NO_OPEN is set', async () => { - mockAskInBrowser.mockResolvedValue({ role: 'admin', projectIds: [], cancelled: false }); - - const cap = captureOutput(); - try { - await new InviteCommand().execute('bob@example.com', { web: true }); - } finally { - cap.restore(); - } - - expect(mockAskInBrowser.mock.calls[0][0].open).toBe(false); - }); - - test('a run every flag already answered opens no browser at all', async () => { - // `--web` is where a question gets asked, not a page that must be opened. - // Nothing is outstanding here, so nothing is served but the code. + test('a project flag reaches the invite', async () => { const cap = captureOutput(); try { await new InviteCommand().execute('bob@example.com', { - web: true, - role: 'admin', - ttl: '6h', + role: 'member', + projects: ['warehouse'], + nonTty: true, + json: true, }); } finally { cap.restore(); } - expect(mockAskInBrowser).not.toHaveBeenCalled(); - expect(pageUrlIn(cap.out())).not.toBe(''); + expect(mockCreateInvite).toHaveBeenCalledWith('org-123', 'bob@example.com', 'member', 'p2'); }); }); describe('the rail describes the run that happened', () => { test('a re-issue that asked nothing reports nothing outstanding', async () => { - // `capy invite --web` with no --role takes the pure - // re-issue branch: no browser opens and the default lifetime is used. The - // rail used to carry `expiry · current` on a run that had already minted - // the code — a stop whose state does not describe what the run did. + // `capy invite ` with no --role takes the pure re-issue + // branch: nothing is asked and the default lifetime is used. The rail + // used to carry `expiry · current` on a run that had already minted the + // code — a stop whose state does not describe what the run did. mockListMemberDetails.mockResolvedValue({ members: [ { @@ -375,13 +221,12 @@ describe('InviteCommand', () => { const cap = captureOutput(); try { - await new InviteCommand().execute('bob@example.com', { web: true, json: true }); + await new InviteCommand().execute('bob@example.com', { json: true }); } finally { cap.restore(); } const { stops } = JSON.parse(cap.out()); - expect(mockAskInBrowser).not.toHaveBeenCalled(); expect(stops.map((s: any) => [s.id, s.state])).toEqual([ ['role', 'done'], ['projects', 'done'], @@ -397,83 +242,110 @@ describe('InviteCommand', () => { // lists a refused project as granted is a rail arguing with the failure // printed underneath it. mockInviteToProject.mockRejectedValue(new Error('503 from the service')); - mockAskInBrowser.mockResolvedValue({ - role: 'member', - projectIds: ['p1', 'p2'], - ttl: '6h', - cancelled: false, - }); const cap = captureOutput(); try { - await new InviteCommand().execute('bob@example.com', { web: true, json: true }); + await new InviteCommand().execute('bob@example.com', { + json: true, + role: 'member', + projects: ['p1', 'p2'], + nonTty: true, + }); } finally { cap.restore(); } const parsed = JSON.parse(cap.out()); const projects = parsed.stops.find((s: any) => s.id === 'projects'); - expect(projects.answer).toBe('storefront'); - expect(projects.answer).not.toContain('warehouse'); - expect(projects.detail).toContain('warehouse'); + expect(projects.answer).toBe('p1'); + expect(projects.answer).not.toContain('p2'); + expect(projects.detail).toContain('p2'); expect(parsed.projectAssignmentFailures).toHaveLength(1); }); - test('an answer given in the browser carries no flag, and one argv gave does', async () => { - mockAskInBrowser.mockResolvedValue({ - role: 'member', - projectIds: ['p1'], - ttl: '30m', - cancelled: false, - }); - + test('an answer an argv flag gave names the flag', async () => { const cap = captureOutput(); - try { - await new InviteCommand().execute('bob@example.com', { web: true, json: true }); - } finally { - cap.restore(); - } - - const { stops } = JSON.parse(cap.out()); - const byId = Object.fromEntries(stops.map((s: any) => [s.id, s])); - expect(byId.role.flag).toBeUndefined(); - expect(byId.role.answer).toBe('member'); - expect(byId.expiry.answer).toBe('30m'); - expect(byId.expiry.flag).toBeUndefined(); - - const cap2 = captureOutput(); try { await new InviteCommand().execute('bob@example.com', { - web: true, json: true, role: 'admin', ttl: '6h', }); } finally { - cap2.restore(); + cap.restore(); } - const flagged = Object.fromEntries( - JSON.parse(cap2.out()).stops.map((s: any) => [s.id, s]), - ); + const flagged = Object.fromEntries(JSON.parse(cap.out()).stops.map((s: any) => [s.id, s])); expect(flagged.role.flag).toBe('--role admin'); expect(flagged.expiry.flag).toBe('--ttl 6h'); }); }); - test('a role this caller cannot grant is refused before a browser opens', async () => { + test('a role this caller cannot grant is refused before anything is minted', async () => { mockGetOrgMe.mockResolvedValue({ role: 'project-admin', user_id: 'u-mike', admin_projects: ['p1'] }); const cap = captureOutput(); try { await expect( - new InviteCommand().execute('bob@example.com', { web: true, role: 'admin' }), + new InviteCommand().execute('bob@example.com', { role: 'admin', nonTty: true }), ).rejects.toThrow('process.exit'); } finally { cap.restore(); } expect(mockExit).toHaveBeenCalledWith(1); - expect(mockAskInBrowser).not.toHaveBeenCalled(); expect(mockCreateInvite).not.toHaveBeenCalled(); }); + + describe('the interactive project checkbox is searchable (CAP-700)', () => { + const planInput = {} as any; + const askProjects = (opts: Record, interactive: boolean) => + (new InviteCommand() as any).resolveInviteeProjects( + 'member', + opts, + { listProjects: mockListProjects }, + interactive, + false, + [], + planInput, + ); + + test('asks the searchable checkbox with the same message, in project order, nothing pre-ticked without a cwd project', async () => { + mockSearchableCheckbox.mockResolvedValue(['p2']); + const result = await askProjects({}, true); + expect(mockSearchableCheckbox).toHaveBeenCalledTimes(1); + const config = mockSearchableCheckbox.mock.calls[0][0] as any; + expect(config.message).toBe('Grant Member access to which projects?'); + expect(config.choices).toEqual([ + { name: 'storefront', value: 'p1', checked: false }, + { name: 'warehouse', value: 'p2', checked: false }, + ]); + expect(result).toEqual({ projectId: 'p2', extraProjectIds: [], projectSource: undefined }); + }); + + test('the cwd project is listed first and pre-ticked', async () => { + mockDetectProjectState.mockResolvedValue({ initialized: true, organizationId: 'org-123', projectId: 'p2' }); + mockSearchableCheckbox.mockResolvedValue(['p2', 'p1']); + const result = await askProjects({}, true); + const config = mockSearchableCheckbox.mock.calls[0][0] as any; + expect(config.choices.map((c: any) => [c.value, c.checked])).toEqual([['p2', true], ['p1', false]]); + expect(result).toEqual({ projectId: 'p2', extraProjectIds: ['p1'], projectSource: undefined }); + }); + + test('still refuses an empty selection', async () => { + mockSearchableCheckbox.mockResolvedValue(['p1']); + await askProjects({}, true); + const config = mockSearchableCheckbox.mock.calls[0][0] as any; + expect(config.validate([])).toBe('Pick at least one project'); + expect(config.validate(['p1'])).toBe(true); + }); + + test('--project and non-interactive runs never open the picker', async () => { + const byFlag = await askProjects({ projects: ['warehouse'] }, true); + expect(byFlag.projectId).toBe('p2'); + mockDetectProjectState.mockResolvedValue({ initialized: true, organizationId: 'org-123', projectId: 'p1' }); + const piped = await askProjects({}, false); + expect(piped.projectId).toBe('p1'); + expect(mockSearchableCheckbox).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/commands/kickCommand.test.ts b/tests/commands/kickCommand.test.ts index 84468c89..686fad1d 100644 --- a/tests/commands/kickCommand.test.ts +++ b/tests/commands/kickCommand.test.ts @@ -40,15 +40,6 @@ mock.module('inquirer', () => ({ prompt: mockPromptFn, })); -// The browser confirm. Stubbed rather than driven here — the real page is -// clicked in tests/ui/browserFlow.e2e.test.ts — because what this file is for -// is the wiring either side of it: which confirm gets asked, and whether a -// DELETE follows the answer. -const mockConfirmInBrowser = jest.fn(); -mock.module('../../src/ui/memberScreens', () => ({ - confirmKickInBrowser: mockConfirmInBrowser, -})); - afterAll(() => { mock.restore(); }); import { KickCommand } from '../../src/commands/kickCommand'; @@ -146,80 +137,25 @@ describe('KickCommand', () => { expect(mockKickMember).not.toHaveBeenCalled(); }); - describe('--web', () => { - beforeEach(() => { - mockListMemberDetails.mockResolvedValue({ - members: [ - { - membershipId: 'mem-alice-2', - userId: 'user-uuid-alice', - email: 'alice@acme.com', - role: 'member', - status: 'active', - createdAt: '2025-02-01T00:00:00Z', - projects: [{ id: 'p1', name: 'storefront', role: 'member', branches: [] }], - }, - ], - }); - }); - - it('asks the browser instead of the terminal, and removes on yes', async () => { - mockConfirmInBrowser.mockResolvedValue(true); - - await new KickCommand().execute('alice@acme.com', { web: true }); - - expect(mockPromptFn).not.toHaveBeenCalled(); - expect(mockKickMember).toHaveBeenCalledWith('org-123', 'mem-alice-2'); - // The screen is told which membership this run is about, so a submit - // aimed at any other one can be refused rather than performed. - expect(mockConfirmInBrowser.mock.calls[0][0].member).toMatchObject({ - membershipId: 'mem-alice-2', - email: 'alice@acme.com', - }); - }); - - it('removes nobody when the browser says no', async () => { - mockConfirmInBrowser.mockResolvedValue(false); - - await new KickCommand().execute('alice@acme.com', { web: true }); - - expect(mockKickMember).not.toHaveBeenCalled(); - }); - - it('a browser that never answered is a no, not an error', async () => { - // Closed, timed out, interrupted: `runBrowserWizard` rejects for all - // three, and none of them is agreement to cut somebody off from every - // secret in the organization. - mockConfirmInBrowser.mockRejectedValue(new Error('Timed out waiting for the browser')); - - await new KickCommand().execute('alice@acme.com', { web: true }); - - expect(mockKickMember).not.toHaveBeenCalled(); - }); - - it('never opens the developer\'s real browser when CAPY_WEB_NO_OPEN is set', async () => { - // The flag every test in this repo sets. The command has to honour it, or - // a suite run hijacks a window on somebody's machine. - const prev = process.env.CAPY_WEB_NO_OPEN; - process.env.CAPY_WEB_NO_OPEN = '1'; - mockConfirmInBrowser.mockResolvedValue(false); - try { - await new KickCommand().execute('alice@acme.com', { web: true }); - expect(mockConfirmInBrowser.mock.calls[0][0].open).toBe(false); - } finally { - if (prev === undefined) delete process.env.CAPY_WEB_NO_OPEN; - else process.env.CAPY_WEB_NO_OPEN = prev; - } + it('asks the terminal confirm, and removes on yes', async () => { + mockListMemberDetails.mockResolvedValue({ + members: [ + { + membershipId: 'mem-alice-2', + userId: 'user-uuid-alice', + email: 'alice@acme.com', + role: 'member', + status: 'active', + createdAt: '2025-02-01T00:00:00Z', + projects: [{ id: 'p1', name: 'storefront', role: 'member', branches: [] }], + }, + ], }); + mockPromptFn.mockResolvedValue({ confirm: true }); - it('leaves the terminal confirm alone without the flag', async () => { - mockPromptFn.mockResolvedValue({ confirm: true }); + await new KickCommand().execute('alice@acme.com'); - await new KickCommand().execute('alice@acme.com'); - - expect(mockConfirmInBrowser).not.toHaveBeenCalled(); - expect(mockPromptFn).toHaveBeenCalled(); - expect(mockKickMember).toHaveBeenCalledWith('org-123', 'mem-alice-2'); - }); + expect(mockPromptFn).toHaveBeenCalled(); + expect(mockKickMember).toHaveBeenCalledWith('org-123', 'mem-alice-2'); }); }); diff --git a/tests/commands/projectPickerWiring.test.ts b/tests/commands/projectPickerWiring.test.ts new file mode 100644 index 00000000..d83192e8 --- /dev/null +++ b/tests/commands/projectPickerWiring.test.ts @@ -0,0 +1,95 @@ +/** + * CAP-700: the project pickers that live inside bigger commands are wired to + * the type-to-filter prompt. `mock.module('inquirer', ...)` is process-wide: + * this file runs isolated (tests/run-tests.sh). + * + * The keystroke behaviour of the prompt itself is tested against the real + * inquirer prompt in tests/ui/searchPrompts.test.ts; here we check that each + * call site asks that prompt, with its own message, default and special row. + */ +import { describe, test, expect, mock, afterAll, beforeEach } from 'bun:test'; + +const promptMock = mock(async (_questions: any) => ({ projectChoice: 'chosen' })); +mock.module('inquirer', () => ({ + default: { prompt: promptMock, Separator: class {} }, +})); + +afterAll(() => { + mock.restore(); +}); + +import { + defaultAskExistingOrNewProject, + DISCOVERY_NEW_PROJECT, +} from '../../src/commands/connectors/dokploy'; +import { + orderProjectsForPicker, + vercelProjectQuestion, + type PickableProject, +} from '../../src/deploy/adapters/vercel'; + +beforeEach(() => { + promptMock.mockClear(); +}); + +describe('Dokploy discovery — "Which project should this folder use?"', () => { + const existing = [ + { id: 'p1', name: 'billing-api' }, + { id: 'p2', name: 'web-frontend' }, + ]; + + test('asks a searchable question; the new-project row leads and is the default', async () => { + const answer = await defaultAskExistingOrNewProject(existing, 'my-app'); + expect(answer).toBe('chosen'); + const [questions] = promptMock.mock.calls[0] as any[]; + expect(questions).toHaveLength(1); + const q = questions[0]; + expect(q.type).toBe('search'); + expect(q.message).toBe('Which project should this folder use?'); + expect(q.source('').map((c: any) => c.value)).toEqual([DISCOVERY_NEW_PROJECT, 'p1', 'p2']); + expect(q.source('')[0].name).toBe('New project (my-app)'); + }); + + test('typing filters, and the new-project row is matched on its own label', async () => { + await defaultAskExistingOrNewProject(existing, 'my-app'); + const q = (promptMock.mock.calls[0] as any[])[0][0]; + expect(q.source('web').map((c: any) => c.value)).toEqual(['p2']); + expect(q.source('my-app').map((c: any) => c.value)).toEqual([DISCOVERY_NEW_PROJECT]); + expect(q.source('zzz')).toEqual([]); + }); +}); + +describe('Vercel link picker — "Which Vercel project is this?"', () => { + const projects: PickableProject[] = [ + { projectId: 'a', projectName: 'zebra', orgId: 'o1', scopeLabel: 'acme' }, + { projectId: 'b', projectName: 'my-app', orgId: 'o1', scopeLabel: 'acme' }, + { projectId: 'c', projectName: 'alpha', orgId: 'o2', scopeLabel: 'me' }, + ]; + + test('the project named like the directory comes first, the rest by name, and the input is untouched', () => { + const before = projects.map((p) => p.projectId); + expect(orderProjectsForPicker(projects, 'my-app').map((p) => p.projectId)).toEqual(['b', 'c', 'a']); + expect(projects.map((p) => p.projectId)).toEqual(before); + }); + + test('keeps its message, lists scope/project rows, and keeps the "None of these" row', () => { + const q = vercelProjectQuestion(projects); + expect(q.type).toBe('search'); + expect(q.message).toBe('Which Vercel project is this?'); + const rows = q.source(''); + expect(rows.map((c: any) => c.name)).toEqual([ + 'acme/zebra', + 'acme/my-app', + 'me/alpha', + 'None of these — run `vercel link` instead', + ]); + expect(rows[3].value).toBeNull(); + }); + + test('typing filters across scope and name; the "None of these" row is matched on its own label', () => { + const q = vercelProjectQuestion(projects); + expect(q.source('me/al').map((c: any) => c.value?.projectId)).toEqual(['c']); + expect(q.source('my-a').map((c: any) => c.value?.projectId)).toEqual(['b']); + expect(q.source('none of').map((c: any) => c.value)).toEqual([null]); + }); +}); diff --git a/tests/commands/rotatePicker.test.ts b/tests/commands/rotatePicker.test.ts index 70622690..b1865236 100644 --- a/tests/commands/rotatePicker.test.ts +++ b/tests/commands/rotatePicker.test.ts @@ -1,9 +1,7 @@ import { describe, test, expect } from 'bun:test'; import { - buildRotateCandidates, buildRotatePickerChoices, rotationPlanLines, - travelledStops, } from '../../src/commands/rotateCommand'; import { rotationPlan } from '../../src/commands/connectors/plans'; import { KeepFile } from '../../src/types/index'; @@ -107,40 +105,6 @@ describe('buildRotatePickerChoices', () => { }); }); -describe('buildRotateCandidates', () => { - test('carries the same facts as the terminal rows, structured', () => { - // Same keep.lock lookup, which is why it lives beside the terminal - // builder: two lookups is two answers to "is this managed?". - const future = Math.floor(Date.now() / 1000) + 30 * 86400; - const keep = keepWith({ - STRIPE_SECRET_KEY: [ - { branch: 'main', connector: { ...stripeConnector, expires_at: future, mode: 'live' } }, - ], - DATABASE_URL: [{ branch: 'main' }], - }); - const rows = buildRotateCandidates(['DATABASE_URL', 'STRIPE_SECRET_KEY'], keep, 'main'); - - expect(rows[0]).toEqual({ name: 'DATABASE_URL', managed: false }); - - const stripe = rows[1]; - expect(stripe.managed).toBe(true); - expect(stripe.provider).toBe('stripe'); - expect(stripe.fingerprint).toBe('rk_…tst'); - expect(stripe.mode).toBe('live'); - expect(stripe.accountId).toBe('acct_test'); - // Issued through the provider's CLI, so rotating invalidates every - // teammate's copy. - expect(stripe.issuedByCapy).toBe(true); - // A number, not `expires in 30d` glued into a sentence. - expect(stripe.expiresInDays).toBe(29); - }); - - test('carries no ANSI, unlike the strings the terminal picker builds', () => { - const keep = keepWith({ A: [{ branch: 'main' }] }); - expect(JSON.stringify(buildRotateCandidates(['A'], keep, 'main'))).not.toContain('\x1b'); - }); -}); - describe('rotationPlanLines', () => { const strip = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); @@ -205,62 +169,3 @@ describe('rotationPlanLines', () => { expect(text).toContain('(--all)'); }); }); - -describe('travelledStops', () => { - const PLAN = rotationPlan({ - branch: 'development', - varName: 'STRIPE_SECRET_KEY', - needsIntegration: false, - providers: ['stripe'], - authProviders: ['stripe'], - deployDetail: 'ship directly to prod', - standing: 'plan', - }); - const at = (stops: Array<{ id: string; state: string }>, id: string) => - stops.find((s) => s.id === id)!; - - test('a rollout that failed puts the rail on the stop the run died at', () => { - // The page this screen exists for. The plan handed over untouched drew - // Rotate, Push and Deploy as stops still ahead of a run that had been - // through all three. - const stops = travelledStops(PLAN, [ - { id: 'rotate', label: 'Rotate', state: 'ok', detail: '1/1' }, - { id: 'push', label: 'Push', state: 'ok', detail: 'development' }, - { id: 'deploy', label: 'Deploy', state: 'fail', detail: 'prod' }, - ]); - expect(at(stops, 'rotate').state).toBe('done'); - expect(at(stops, 'push').state).toBe('done'); - expect(at(stops, 'deploy').state).toBe('current'); - expect(at(stops, 'deploy').blank).toBe(true); - // The pairing is inside the Rotate step, and it produced a key. - expect(at(stops, 'auth').state).toBe('done'); - expect(at(stops, 'auth').answer).toBe('paired'); - // A question settled before the run began is left exactly as declared. - expect(at(stops, 'variable')).toEqual(at(PLAN as never, 'variable') as never); - }); - - test('a deploy queued behind a failed rotation is upcoming, not skipped', () => { - // `pending` and `skip` are different facts: one never ran and might still - // have worked; the other was never going to. - const stops = travelledStops(PLAN, [ - { id: 'rotate', label: 'Rotate', state: 'fail', detail: '0/1' }, - { id: 'push', label: 'Push', state: 'pending', detail: 'development' }, - { id: 'deploy', label: 'Deploy', state: 'pending', detail: 'prod' }, - ]); - expect(at(stops, 'rotate').state).toBe('current'); - expect(at(stops, 'push').state).toBe('upcoming'); - expect(at(stops, 'deploy').state).toBe('upcoming'); - // Nothing rotated, so nothing proves the pairing happened. - expect(at(stops, 'auth').state).not.toBe('done'); - }); - - test('--no-push strikes through the stops it skipped', () => { - const stops = travelledStops(PLAN, [ - { id: 'rotate', label: 'Rotate', state: 'ok', detail: '1/1' }, - { id: 'push', label: 'Push', state: 'skip', detail: 'skipped by --no-push' }, - { id: 'deploy', label: 'Deploy', state: 'skip', detail: 'nothing was pushed' }, - ]); - expect(at(stops, 'push').state).toBe('skipped'); - expect(at(stops, 'deploy').state).toBe('skipped'); - }); -}); diff --git a/tests/commands/rotateRefusals.test.ts b/tests/commands/rotateRefusals.test.ts index 96e97b72..af72066d 100644 --- a/tests/commands/rotateRefusals.test.ts +++ b/tests/commands/rotateRefusals.test.ts @@ -1,45 +1,22 @@ /** - * `capy rotate --web` refuses onto a PAGE, not into a terminal nobody reads. + * `capy rotate`'s early refusals: no keep.lock, no active branch, nothing + * managed under `--all`, an unknown variable, no variables at all. * - * Rotate had six exits above its first `--web` branch — no keep.lock, no - * active branch, nothing managed under `--all`, an unknown variable, no - * variables at all, no connectors registered. Every one of them was - * `console.error(…)` + `process.exit(1)`. The exit code was right and the - * sentence was right, and under `--web` neither reached a surface: the flag - * exists because the caller is an agent, so the stream those lines went to has - * nobody on the other end. `capy rotate NOPE --web` was the one-line - * demonstration — a run that produced no output an agent could see, and no - * page, and no way to find out that the name was simply wrong. - * - * WHY THIS FILE AND NOT A SOURCE GUARD. `webContextThreading.test.ts` reads - * the source because the defect there is a missing ARGUMENT, which has no - * runtime signature short of driving a deploy. This defect does have one: a - * loopback URL gets printed and a page gets served, or it does not. So these - * tests run the real command against a real keep.lock, take the URL out of its - * own output, and fetch it — which is exactly what an agent would do, and the - * only check that cannot pass while the page is undeliverable. - * - * The other half of the contract is here too: a run WITHOUT `--web` must not - * grow a browser window, and must not hold itself open waiting for one. + * Each one prints its sentence to the terminal, exits 1, and opens nothing: + * the tests run the real command against a real keep.lock and read what it + * wrote. */ import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { setWebMode } from '../../src/ui/webMode'; import type { ConnectorMetadata, KeepFile } from '../../src/types/index'; -// The suite's own backstop is in `run-tests.sh`, but this file drives command -// code that opens a browser on the way to its ending page. A single-file run -// (`bun test tests/commands/rotateRefusals.test.ts`) must not launch the -// developer's Chrome, so set it here as well as there. -process.env.CAPY_WEB_NO_OPEN = '1'; - const TEST_DIR = join(tmpdir(), `capy-rotate-refusals-${process.pid}`); const ORIGINAL_CWD = process.cwd(); -/** The loopback address an ending page prints — `ScreenServer`'s `/s/`. */ -const PRINTED_URL = /http:\/\/127\.0\.0\.1:\d+\/s\/[A-Za-z0-9_-]+/; +/** The loopback address a page would print. No refusal may print one. */ +const PRINTED_URL = /http:\/\/127\.0\.0\.1:\d+\//; const stripeConnector: ConnectorMetadata = { provider: 'stripe', @@ -84,67 +61,8 @@ function writeFixture(opts: { } } -interface Refusal { - /** The served document, fetched the way a browser would. */ - page: string; - url: string; - exitCode: number | undefined; - /** Everything the run wrote to a terminal, both streams. */ - output: string; -} - -async function until(get: () => T | undefined, what: string): Promise { - for (let i = 0; i < 400; i++) { - const v = get(); - if (v !== undefined) return v; - await new Promise((r) => setTimeout(r, 25)); - } - throw new Error(`timed out waiting for ${what}`); -} - -/** - * Run a `--web` command that is expected to refuse, and hand back its page. - * - * The fetch is not optional and not merely an assertion: `serveEndingPage` - * holds the run open until the browser has the document, so a caller that - * never collects it is the same as a browser that never came. Fetching is what - * lets the command reach its exit at all. - */ -async function refusalUnderWeb(run: () => Promise): Promise { - setWebMode(true); - let exitCode: number | undefined; - let out = ''; - const record = - () => - (...args: unknown[]) => { - out += args.map(String).join(' ') + '\n'; - }; - const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { - exitCode = code; - throw new Error(`__exit_${code}__`); - }) as never); - const logSpy = spyOn(console, 'log').mockImplementation(record()); - const errSpy = spyOn(console, 'error').mockImplementation(record()); - - try { - const done = run().catch((err: unknown) => { - const m = err instanceof Error ? err.message : String(err); - if (!m.startsWith('__exit_')) throw err; - }); - const url = await until(() => out.match(PRINTED_URL)?.[0], 'the run to print a loopback URL'); - const page = await (await fetch(url)).text(); - await done; - return { page, url, exitCode, output: out }; - } finally { - exitSpy.mockRestore(); - logSpy.mockRestore(); - errSpy.mockRestore(); - setWebMode(false); - } -} - -/** The same, with no `--web`: nothing should be served and nothing awaited. */ -async function refusalInTerminal(run: () => Promise): Promise> { +/** Runs a command that is expected to refuse; hands back its exit code and everything it wrote. */ +async function refusalInTerminal(run: () => Promise): Promise<{ exitCode: number | undefined; output: string }> { let exitCode: number | undefined; let out = ''; const record = @@ -185,12 +103,13 @@ beforeEach(() => { afterEach(() => { process.chdir(ORIGINAL_CWD); if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); - setWebMode(false); }); -describe('every early refusal in rotate reaches a page under --web', () => { +/** The terminal half is allowed its bold; the words are what matter. */ +const plain = (output: string): string => output.replace(/\x1b\[[0-9;]*m/g, ''); + +describe('every early refusal in rotate reaches the terminal and exits 1', () => { test('an unknown variable names itself, its branch, and the names that would have worked', async () => { - // `capy rotate NOPE --web`. The demonstration case. writeFixture({ branch: 'development', vars: [ @@ -199,41 +118,38 @@ describe('every early refusal in rotate reaches a page under --web', () => { ], }); - const r = await refusalUnderWeb(() => rotate('NOPE', { web: true })); + const r = await refusalInTerminal(() => rotate('NOPE', {})); + const text = plain(r.output); - expect(r.page).toContain('window.__CAPY_DATA__'); - // The code, because the reader is usually not a person. - expect(r.page).toContain('VARIABLE_NOT_FOUND'); - expect(r.page).toContain('Variable not found'); - expect(r.page).toContain('NOPE is not in your environment on branch development.'); + expect(text).toContain('Variable not found'); + expect(text).toContain('NOPE is not in your environment on branch development.'); // The alternatives, which is the part that lets a caller correct itself // instead of stopping. - expect(r.page).toContain('DATABASE_URL, STRIPE_SECRET_KEY'); - // A refusal that ends on a page is still a refusal. + expect(text).toContain('Available: DATABASE_URL, STRIPE_SECRET_KEY'); + expect(PRINTED_URL.test(r.output)).toBe(false); expect(r.exitCode).toBe(1); }, 30_000); test('a directory that was never initialised', async () => { // No keep.lock at all — the very first thing `execute` checks. - const r = await refusalUnderWeb(() => rotate('ANY', { web: true })); - expect(r.page).toContain('NO_KEEP_FILE'); - expect(r.page).toContain('No keep.lock file found'); + const r = await refusalInTerminal(() => rotate('ANY', {})); + expect(plain(r.output)).toContain('No keep.lock file found'); + expect(PRINTED_URL.test(r.output)).toBe(false); expect(r.exitCode).toBe(1); }, 30_000); test('a keep.lock whose branch cannot be derived', async () => { // Two branches and nothing that picks between them: no `.env` header, no // `.capy/branch`, no sync state. `deriveActiveBranch` returns null rather - // than inventing a default, and rotate must say so somewhere visible. + // than inventing a default, and rotate must say so. writeFixture({ vars: [ { name: 'A', branch: 'development' }, { name: 'B', branch: 'staging' }, ], }); - const r = await refusalUnderWeb(() => rotate('A', { web: true })); - expect(r.page).toContain('NO_ACTIVE_BRANCH'); - expect(r.page).toContain('No active branch'); + const r = await refusalInTerminal(() => rotate('A', {})); + expect(plain(r.output)).toContain('No active branch'); expect(r.exitCode).toBe(1); }, 30_000); @@ -242,10 +158,8 @@ describe('every early refusal in rotate reaches a page under --web', () => { branch: 'development', vars: [{ name: 'DATABASE_URL', branch: 'development' }], }); - const r = await refusalUnderWeb(() => rotate(undefined, { web: true, all: true })); - expect(r.page).toContain('NO_MANAGED_KEYS'); - expect(r.page).toContain('No managed keys to rotate on this branch'); - expect(r.page).toContain('development'); + const r = await refusalInTerminal(() => rotate(undefined, { all: true })); + expect(plain(r.output)).toContain('No managed keys to rotate on this branch'); expect(r.exitCode).toBe(1); }, 30_000); @@ -256,53 +170,22 @@ describe('every early refusal in rotate reaches a page under --web', () => { branch: 'development', vars: [{ name: 'A', branch: 'staging' }], }); - const r = await refusalUnderWeb(() => rotate(undefined, { web: true })); - expect(r.page).toContain('NO_VARIABLES'); - expect(r.page).toContain('No variables on this branch yet'); + const r = await refusalInTerminal(() => rotate(undefined, {})); + expect(plain(r.output)).toContain('No variables on this branch yet'); expect(r.exitCode).toBe(1); }, 30_000); - test('no page carries a secret, only names and codes', async () => { + test('no refusal carries a secret, only names and codes', async () => { // Every one of these refusals happens before anything is decrypted, and - // the payload is built from keep.lock — which holds hashes and - // fingerprints, never values. Worth pinning: an "available variables" list - // is one careless join away from being an environment dump. + // the text is built from keep.lock — which holds hashes and fingerprints, + // never values. writeFixture({ branch: 'development', vars: [{ name: 'STRIPE_SECRET_KEY', branch: 'development', managed: true }], }); - const r = await refusalUnderWeb(() => rotate('NOPE', { web: true })); - expect(r.page).toContain('STRIPE_SECRET_KEY'); - expect(r.page).not.toContain('h-STRIPE_SECRET_KEY'); - expect(r.page).not.toContain('value_hash'); - }, 30_000); - - test('no ANSI escape reaches the document', async () => { - // The CLI bolds its own sentences on the way to a terminal, and those - // codes render as a literal `[1m` inside a page. - writeFixture({ branch: 'development', vars: [{ name: 'A', branch: 'development' }] }); - const r = await refusalUnderWeb(() => rotate('NOPE', { web: true })); - expect(r.page.includes(String.fromCharCode(27))).toBe(false); - expect(r.page.includes('[1m')).toBe(false); - }, 30_000); -}); - -describe('a run without --web grows no window', () => { - test('the sentence still goes to the terminal, and nothing is served', async () => { - // The other half of the contract. This must not turn every terminal - // failure into a browser window, and must not hold a plain run open - // waiting for a browser nobody asked for. - writeFixture({ - branch: 'development', - vars: [{ name: 'DATABASE_URL', branch: 'development' }], - }); const r = await refusalInTerminal(() => rotate('NOPE', {})); - // Stripped, because the terminal half is allowed its bold — that is the - // whole difference between the two surfaces. - const text = r.output.replace(/\x1b\[[0-9;]*m/g, ''); - expect(text).toContain('NOPE is not in your environment on branch development.'); - expect(text).toContain('Available: DATABASE_URL'); - expect(PRINTED_URL.test(r.output)).toBe(false); - expect(r.exitCode).toBe(1); + expect(r.output).toContain('STRIPE_SECRET_KEY'); + expect(r.output).not.toContain('h-STRIPE_SECRET_KEY'); + expect(r.output).not.toContain('value_hash'); }, 30_000); }); diff --git a/tests/commands/webContextThreading.test.ts b/tests/commands/webContextThreading.test.ts deleted file mode 100644 index 92239a00..00000000 --- a/tests/commands/webContextThreading.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * `--web` has to reach the HELPERS too, not just the command handler. - * - * `webFlagWiring.test.ts` guards the first hop: the action handler reads `web` - * off `optsWithGlobals()`. That hop was green for `capy rotate` the whole time - * the bug below was live, because the handler does read the flag — and then - * calls a helper without it. - * - * `ensureDeployTarget(cwd, web)` branches on its second argument twice: - * `pickTargetInBrowser` when several targets are saved, and `runPicker(…, web)` - * when none is, which serves the adapter → branch → settings → variables → - * delivery → name route through `setUpDeployTargetInBrowser`. The parameter - * defaults to `{}`. So `ensureDeployTarget(process.cwd())` type-checks, runs, - * skips every browser branch, and stops on an inquirer `Where are you - * deploying?` — in a flow whose entire reason for existing is that nobody is - * reading the terminal. - * - * Nothing failed. `deployWebFlow.test.ts` covers the web path thoroughly, but - * it calls `ensureDeployTarget(ROOT, { web: true })` directly, so it proves the - * destination works while saying nothing about whether anyone drives there. - * That is the shape of this whole class of bug: a built, tested, unreachable - * screen. The gap is in the wiring, and the wiring is what this file reads. - */ -import { describe, test, expect } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; - -const CLI_ROOT = resolve(import.meta.dir, '../..'); - -/** - * Helpers that take a web context and silently no-op without one, mapped to the - * command sources that must hand them one. Add a row when a helper grows a - * `WebContext` parameter with a default — the default is the hazard. - */ -const THREADED: Array<{ helper: string; callers: string[] }> = [ - { helper: 'ensureDeployTarget', callers: ['src/commands/rotateCommand.ts'] }, -]; - -/** - * Does every call to `helper` pass a second argument? - * - * Deliberately counts arguments rather than looking for the word `web`: the - * caller may pass a variable, an object literal, or a ternary, and a check that - * only recognised one spelling would go quietly green on the others. What makes - * the call wrong is that there is nothing in the second position at all. - */ -function everyCallPassesContext(source: string, helper: string): boolean { - // `await helper(` … matching to the closing paren of the call, shallowly: - // these call sites take `process.cwd()` first, so one level of nesting has to - // survive. Anything deeper is not a shape this codebase writes. - const re = new RegExp(`\\b${helper}\\s*\\(((?:[^()]|\\([^()]*\\))*)\\)`, 'g'); - const calls = [...source.matchAll(re)]; - if (calls.length === 0) return false; - return calls.every((m) => { - // Split on top-level commas only — `process.cwd()` has none, but a future - // first argument might. - let depth = 0; - for (const ch of m[1]) { - if (ch === '(') depth++; - else if (ch === ')') depth--; - else if (ch === ',' && depth === 0) return true; - } - return false; - }); -} - -describe('--web reaches the helpers that serve the screens', () => { - for (const { helper, callers } of THREADED) { - for (const caller of callers) { - test(`${caller} passes a web context to ${helper}()`, () => { - const source = readFileSync(join(CLI_ROOT, caller), 'utf8'); - expect( - everyCallPassesContext(source, helper), - `${caller} calls \`${helper}()\` with no web context. The parameter ` + - `defaults to \`{}\`, so every browser branch inside is skipped and the ` + - `run stops on a TTY prompt instead of serving a screen. Pass ` + - `\`web ? { web: true } : {}\`.`, - ).toBe(true); - }); - } - } - - test('the deploy-target gate admits --web, not just a TTY', () => { - const source = readFileSync(join(CLI_ROOT, 'src/commands/rotateCommand.ts'), 'utf8'); - // `--web` exists because the caller is an agent, which is exactly the case - // with no TTY. Gating the resolve on `isTTY` alone sent the intended caller - // down the branch that resolves nothing and ships to no target. - const gatedOnTtyAlone = /else if \(\s*isTTY\s*\)\s*\{[\s\S]{0,600}?ensureDeployTarget/.test( - source, - ); - expect( - gatedOnTtyAlone, - 'rotate resolves its deploy target behind `else if (isTTY)`, so a `--web` ' + - 'run without a TTY skips resolution entirely. The gate needs `web || isTTY`.', - ).toBe(false); - }); - - /** - * The detector, against the line it was written to catch. - * - * Both source guards written in this codebase so far shipped with a detector - * bug that made them report clean on the very code that prompted them — one - * missed static imports, one missed `serverMsg?.includes(...)`. A guard that - * cannot fail is worth less than no guard, because it is also a claim. - */ - test('the detector rejects the pre-fix call and accepts the fixed one', () => { - expect(everyCallPassesContext('await ensureDeployTarget(process.cwd());', 'ensureDeployTarget')).toBe(false); - expect( - everyCallPassesContext( - 'await ensureDeployTarget(process.cwd(), web ? { web: true } : {});', - 'ensureDeployTarget', - ), - ).toBe(true); - // A helper that is never called must not read as "every call is fine". - expect(everyCallPassesContext('nothing here', 'ensureDeployTarget')).toBe(false); - }); -}); diff --git a/tests/commands/webFlagWiring.test.ts b/tests/commands/webFlagWiring.test.ts deleted file mode 100644 index bc28c607..00000000 --- a/tests/commands/webFlagWiring.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * `--web` has to actually reach the command. - * - * It is declared ONCE, on the root program, so Commander binds it to the - * global scope: a subcommand reading `options.web` gets `undefined` forever. - * Every action handler has to read `command.optsWithGlobals().web` instead. - * - * The failure mode is SILENCE, which is why this file exists. `capy connect - * --web` parses cleanly, is accepted, and then hands an agent a TTY prompt it - * cannot answer — no error, no warning, nothing to grep for. Every reviewer of - * the conversion work found it independently, each time by reading rather than - * by anything failing. - * - * A source-level assertion rather than a behavioural one, deliberately: running - * each command for real needs a session, a project and in some cases a - * provider. What can be checked cheaply on every commit is that the wiring is - * present at all, which is precisely what was missing. - */ -import { describe, test, expect } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; - -const CLI_ROOT = resolve(import.meta.dir, '../..'); - -/** - * BOTH entrypoints, and the second one is why this file grew. - * - * `index-dev.ts` is what `capy-dev` runs, which is what every local run against - * a dev service uses — including the MCP, whose README points `CAPY_BIN` at it - * with `CAPY_API_URL=http://localhost:3000`. It had FIVE `optsWithGlobals` - * calls to `index.ts`'s twenty, so `capy-dev rotate --web` parsed the flag, - * accepted it, and prompted a TTY nobody was watching. This file checked only - * `index.ts` and reported green throughout. - * - * A mirror that is only half-checked is worse than an unchecked one: the - * checked half sets the expectation that both work. - */ -const ENTRYPOINTS = ['src/index.ts', 'src/index-dev.ts'] as const; -const SOURCE: Record = Object.fromEntries( - ENTRYPOINTS.map((f) => [f, readFileSync(join(CLI_ROOT, f), 'utf8')]), -); - -/** The source of one `.command('name')` registration, up to its closing `});`. */ -function commandBlock(entry: string, name: string): string { - const re = new RegExp(`\\.command\\('${name}[^\\n]*\\n(?:.*?\\n)*? \\}\\);\\n`); - const m = SOURCE[entry].match(re); - if (!m) throw new Error(`no registration for \`${name}\` in ${entry}`); - return m[0]; -} - -/** - * Which commands need the flag — DERIVED, never listed. - * - * The first version of this file carried a hand-written array of eleven - * command names. It passed while `capy edit`, `capy decrypt` and `capy deploy` - * sat unwired, because they were not in the array — a test whose coverage is a - * list has exactly the blind spot it was written to close, and a reviewer - * found the gap rather than the test doing it. - * - * So the list comes from the source: a command needs the flag when the command - * class it constructs declares a `web` option. That cannot drift, and a newly - * converted command is covered the moment it accepts one. - */ -function commandsNeedingWeb(entry: string): string[] { - const need: string[] = []; - for (const m of SOURCE[entry].matchAll(/\.command\('([a-z-]+)/g)) { - const name = m[1]; - let block: string; - try { - block = commandBlock(entry, name); - } catch { - continue; - } - // The implementation is always a dynamic import inside the handler. - const imp = block.match(/import\('\.\/commands\/([A-Za-z]+)'\)/); - if (!imp) continue; - const file = join(CLI_ROOT, `src/commands/${imp[1]}.ts`); - let src: string; - try { - src = readFileSync(file, 'utf8'); - } catch { - continue; - } - // Two signals, because one was not enough and the gap was on the command - // this matters most for. Declaring `web?: boolean` catches most; `rotate` - // and `connect` take their options as `RotateOpts`/`ConnectOpts`, declared - // over in `connectors/registry.ts`, so the declaration is not in the file - // the registration imports — and both were invisible here while - // `capy-dev rotate --web` silently prompted a TTY. - // - // READING `opts.web` is the signal that cannot hide: wherever the type - // lives, a command that can serve a screen has to consult the flag. - if (/\bweb\??:\s*boolean/.test(src) || /\bopts\.web\b/.test(src)) need.push(name); - } - return [...new Set(need)]; -} - -/** - * Does this handler take `web` FROM the merged globals? - * - * Not "does it mention `optsWithGlobals`" — that is what this file used to ask, - * and it is how `capy-dev connect stripe --web` shipped broken while this test - * was green. That handler calls `optsWithGlobals()` for a different reason - * entirely: the root program also declares `-f/--force`, so `--force` has to be - * read from the merge. `web` was simply never taken off the result, and a - * substring check for the call cannot tell the two apart. - * - * Both spellings in use are accepted: read straight through - * (`command.optsWithGlobals().web`), or via the local alias the handlers that - * also need `force` keep (`const merged = …; merged.web`). - */ -function readsWebFromGlobals(block: string): boolean { - if (/optsWithGlobals\(\)\s*\.\s*web\b/.test(block)) return true; - for (const m of block.matchAll(/const\s+(\w+)\s*=\s*[^;\n]*optsWithGlobals[^;]*;/g)) { - if (new RegExp(`\\b${m[1]}\\s*\\.\\s*web\\b`).test(block)) return true; - } - return false; -} - -/** One row per (entrypoint, command) so a failure names both. */ -const CASES: Array<[string, string]> = ENTRYPOINTS.flatMap((entry) => - commandsNeedingWeb(entry).map((name) => [entry, name] as [string, string]), -); - -describe('--web reaches the commands that can serve a screen', () => { - test.each(CASES)('%s: %s threads the inherited flag', (entry, name) => { - const block = commandBlock(entry, name); - expect( - readsWebFromGlobals(block), - `\`${name} --web\` parses in ${entry} but the handler never reads \`web\` off ` + - `the inherited globals, so the flag is silently dropped and the command ` + - `prompts a TTY nobody is watching. Read \`command.optsWithGlobals().web\`.`, - ).toBe(true); - }); - - test.each(ENTRYPOINTS)('%s declares the flag once, at the root', (entry) => { - // A subcommand redeclaring `--web` binds it locally, which makes - // `optsWithGlobals()` the wrong call for that one command — an - // inconsistency invisible until someone runs exactly that flow. - // `index-dev.ts` carried a second declaration on `add` for exactly as long - // as nothing checked it. - const declarations = SOURCE[entry].match(/\.option\('--web'/g) ?? []; - expect(declarations, `${entry} should declare --web once`).toHaveLength(1); - }); - - test.each(CASES)('%s: %s does not read it off the local options', (entry, name) => { - // `options.web` inside a subcommand is always undefined. It is an easy line - // to write, it looks right, and it does nothing. - const block = commandBlock(entry, name); - expect( - /\bweb:\s*options\.web\b/.test(block), - `${entry}: \`${name}\` reads options.web, which a subcommand never receives.`, - ).toBe(false); - }); - - test('the dev entrypoint covers what the production one does', () => { - // The mirror-drift guard. `index-dev.ts` is not a subset by design — it is - // meant to be the same CLI against a dev service — so a command that can - // serve a screen in one and not the other is drift, not a decision. - const prod = new Set(commandsNeedingWeb('src/index.ts')); - const dev = new Set(commandsNeedingWeb('src/index-dev.ts')); - const missing = [...prod].filter((c) => !dev.has(c) && SOURCE['src/index-dev.ts'].includes(`.command('${c}`)); - expect( - missing, - `these commands are registered in index-dev.ts and can serve a screen, but ` + - `the dev entrypoint was not counted as needing the flag: ${missing.join(', ')}`, - ).toEqual([]); - }); -}); diff --git a/tests/commands/webModeRemoved.test.ts b/tests/commands/webModeRemoved.test.ts new file mode 100644 index 00000000..ce81b86e --- /dev/null +++ b/tests/commands/webModeRemoved.test.ts @@ -0,0 +1,91 @@ +/** + * `--web` was removed. An agent that still passes it gets the coded refusal + * `WEB_MODE_REMOVED` and exit 1, and the command is NOT run. + * + * Exercised against the real BUILT cli (both entrypoints) — needs + * `bun run build` first, like the other tests that spawn `dist/`. A throwaway + * HOME and an empty working directory mean that a command which did run would + * print its own refusal (no keep.lock, no session) instead of this one. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const PROD_CLI = join(__dirname, '../../dist/index.js'); +const DEV_CLI = join(__dirname, '../../dist/index-dev.js'); + +function run(cli: string, args: string[]): { stdout: string; stderr: string; code: number } { + const home = mkdtempSync(join(tmpdir(), 'capy-webgone-')); + try { + const r = spawnSync('node', [cli, ...args], { + encoding: 'utf-8', + cwd: home, + env: { ...process.env, HOME: home, CAPY_WEB_NO_OPEN: '1' }, + }); + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', code: r.status ?? 1 }; + } finally { + rmSync(home, { recursive: true, force: true }); + } +} + +describe('--web is refused with WEB_MODE_REMOVED', () => { + for (const [name, cli] of [['capy', PROD_CLI], ['capy-dev', DEV_CLI]] as const) { + test(`${name}: --json prints { ok:false, code, error } on stdout and exits 1`, () => { + const r = run(cli, ['--web', 'info', '--json']); + expect(r.code).toBe(1); + const body = JSON.parse(r.stdout); + expect(body.ok).toBe(false); + expect(body.code).toBe('WEB_MODE_REMOVED'); + expect(typeof body.error).toBe('string'); + }); + + test(`${name}: without --json the sentence goes to stderr, stdout stays empty, exit 1`, () => { + const r = run(cli, ['--web', 'info']); + expect(r.code).toBe(1); + expect(r.stdout).toBe(''); + expect(r.stderr.length).toBeGreaterThan(0); + }); + } + + test('wherever the flag sits, the command does not run', () => { + for (const args of [ + ['info', '--web'], + ['add', 'SOME_NAME', '--web', '--json'], + ['edit', '--web', '--json'], + ['deploy', '--web', '--json'], + ['rotate', '--web', '--json'], + ]) { + const r = run(PROD_CLI, args); + expect(r.code, args.join(' ')).toBe(1); + const out = r.stdout.trim(); + // A command that ran would have refused for its own reason (no keep.lock). + if (out.startsWith('{')) expect(JSON.parse(out).code, args.join(' ')).toBe('WEB_MODE_REMOVED'); + else expect(r.stderr, args.join(' ')).not.toContain('keep.lock'); + } + }); +}); + +describe('help hides the flag but lists the code', () => { + test('--web is in no option list of `capy help --json`; the error code is listed', () => { + const r = run(PROD_CLI, ['help', '--json']); + expect(r.code).toBe(0); + const doc = JSON.parse(r.stdout); + const optionFlags: string[] = []; + const visit = (cmds: any[]): void => { + for (const c of cmds) { + for (const o of c.options) optionFlags.push(o.long); + visit(c.subcommands); + } + }; + visit(doc.commands); + expect(optionFlags).not.toContain('--web'); + expect(doc.errorCodes).toContain('WEB_MODE_REMOVED'); + }); + + test('--web is not in the human --help either', () => { + const r = run(PROD_CLI, ['--help']); + expect(r.stdout).not.toContain('--web'); + }); +}); diff --git a/tests/commands/webPhraseLeak.test.ts b/tests/commands/webPhraseLeak.test.ts deleted file mode 100644 index 1ae0ed94..00000000 --- a/tests/commands/webPhraseLeak.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * The invariant the browser onboarding path exists to hold: the 24-word - * recovery phrase is generated in this process, rendered into the loopback - * page for a human to write down, and NEVER printed to stdout or stderr. - * - * That is the whole reason `capy byoc --web` exists. When an agent shells this - * command through the MCP, the model sees the child's output and nothing else - * — so a phrase that reaches stdout is a phrase the model has, and a phrase - * the model has is a compromised master key. - * - * This was already verified by scripts/demo/verify-web-onboarding.mjs, which - * nothing ran: no reference in run-tests.sh, so it only executed when someone - * remembered to type it. The check needs no browser — the phrase is read out - * of the served HTML with fetch — so there is no reason it cannot run on every - * commit, which is what this file does. - * - * NEVER remove `CAPY_WEB_NO_OPEN`. Without it the command opens the - * developer's real browser. - */ -import { describe, test, expect, afterEach } from 'bun:test'; -import { spawn } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; - -const CLI_ROOT = resolve(import.meta.dir, '../..'); -const ENTRY = join(CLI_ROOT, 'src/index.ts'); -const URL_RE = /http:\/\/127\.0\.0\.1:(\d+)\/\?n=([a-f0-9]+)/; -const headers = { 'content-type': 'application/json' }; - -/** - * The 24 words as the page receives them. - * - * Read out of the payload the CLI inlines rather than off the rendered markup: - * the screen is a compiled document that builds its grid at runtime, so there - * is no server-rendered span to match. This is the same thing from the other - * side — whatever reaches `window.__CAPY_DATA__` is what the browser can show. - */ -function extractPhrase(html: string): string[] { - const match = html.match(/window\.__CAPY_DATA__ = (\{.*?\});/s); - if (!match) return []; - const data = JSON.parse(match[1].replace(/\\u003c/g, '<')) as { phraseWords?: string[] }; - return data.phraseWords ?? []; -} - -describe('capy byoc --web keeps the recovery phrase off the wire', () => { - let home = ''; - let child: ReturnType | null = null; - - afterEach(() => { - child?.kill('SIGKILL'); - child = null; - if (home) rmSync(home, { recursive: true, force: true }); - home = ''; - }); - - test('the phrase is on the page and in neither stdout nor stderr', async () => { - home = mkdtempSync(join(tmpdir(), 'capy-phrase-')); - - let out = ''; - let err = ''; - child = spawn('bun', [ENTRY, 'byoc', '--web'], { - cwd: home, - env: { - ...process.env, - HOME: home, - // Headless: drive the loopback ourselves. Without this the command - // opens the developer's real browser, which is never acceptable. - CAPY_WEB_NO_OPEN: '1', - CAPY_NO_AUTOCOMMIT: '1', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - child.stdout!.on('data', (d: Buffer) => (out += d.toString())); - child.stderr!.on('data', (d: Buffer) => (err += d.toString())); - - // Wait for the loopback URL to be announced. - let match: RegExpMatchArray | null = null; - for (let i = 0; i < 300 && !match; i++) { - match = (out + err).match(URL_RE); - if (!match) await new Promise((r) => setTimeout(r, 50)); - } - expect(match, `no loopback URL announced.\nstdout:\n${out}\nstderr:\n${err}`).not.toBeNull(); - - const base = `http://127.0.0.1:${match![1]}`; - const nonce = match![2]; - - // Ask for a freshly generated phrase, then fetch the page the way the - // person writing it down would: a compiled screen advances by RELOADING, - // so the CLI answers `{ next: true }` and serves the phrase step on the - // next GET of the same address. - const advanced = (await ( - await fetch(`${base}/submit`, { - method: 'POST', - headers: { ...headers, origin: base }, - body: JSON.stringify({ nonce, payload: { __action: 'submit', source: 'generate' } }), - }) - ).json()) as { next?: boolean }; - expect(advanced.next).toBe(true); - - const phrasePage = await (await fetch(match![0])).text(); - const words = extractPhrase(phrasePage); - expect(words).toHaveLength(24); - - // THE ASSERTION: no run of the phrase reaches either stream. - const terminal = out + err; - expect(terminal).not.toContain(words.join(' ')); - - /* - * Partial leaks too — but by SEQUENCE, not by word. - * - * Checking each word on its own does not work, and the first version of - * this test failed proving it: BIP39 words are ordinary English, and - * "never" is both a valid phrase word and part of the CLI's own line - * "values never touch this terminal or the AI". Flagging that is a false - * positive, and a check that cries wolf gets deleted. - * - * What is unambiguous is ORDER. Four consecutive words of the phrase, in - * the order the page rendered them, do not occur by chance in prose — so - * any such run is the phrase itself, whole or truncated, however it was - * formatted on the way out. - */ - const RUN = 4; - for (let i = 0; i + RUN <= words.length; i++) { - const run = words.slice(i, i + RUN); - // Whitespace-insensitive: a phrase printed one word per line, or in a - // padded grid, is still a leaked phrase. - const pattern = new RegExp(run.join('\\s+'), 'i'); - expect( - pattern.test(terminal), - `words ${i + 1}–${i + RUN} of the phrase ("${run.join(' ')}") appeared in the CLI's output`, - ).toBe(false); - } - - // And the page really did carry it — otherwise this test would pass just - // as well against a screen that renders nothing at all. - expect(phrasePage).toContain(words[0]); - - /* - * The other direction, which the compiled screen made checkable: the words - * must not be accepted BACK. The display step answers with consent and the - * CLI refuses a payload carrying a phrase, so a page that has been tampered - * with cannot hand this machine a key off the wire. - */ - const returned = (await ( - await fetch(`${base}/submit`, { - method: 'POST', - headers: { ...headers, origin: base }, - body: JSON.stringify({ - nonce, - payload: { __action: 'submit', confirmed: true, phrase: words.join(' ') }, - }), - }) - ).json()) as { error?: string }; - expect(returned.error).toBeDefined(); - }, 60_000); -}); diff --git a/tests/core/deployPlan.test.ts b/tests/core/deployPlan.test.ts index 7766d466..8126ed24 100644 --- a/tests/core/deployPlan.test.ts +++ b/tests/core/deployPlan.test.ts @@ -1,20 +1,11 @@ /** * The deploy route, made checkable. * - * `capy deploy` draws its rail on three screens, and until this file each of - * them carried its own copy of the station list in a lookup table inside its - * Screen.svelte. Three copies of one route is three chances for the rail to say - * something different depending on which page you happen to be standing on — - * and none of the three was the CLI's, which is the surface that decides how - * many questions there are. - * - * These tests pin the route and — the point of the exercise — assert that the - * browser payload and a headless caller are fed by ONE builder, by comparing - * what each emits for the same run. + * `capy deploy --json` emits this route. These tests pin its shape: the whole + * route is declared, and each stop says what settled it. */ import { describe, test, expect } from 'bun:test'; -import { deployPlan, unansweredDeployStops, SIGNIN_COMMAND } from '../../src/core/deployPlan'; -import { renderScreen } from '../../src/ui/screens/serve'; +import { deployPlan, unansweredDeployStops } from '../../src/core/deployPlan'; const ROUTE = [ 'platform', @@ -122,50 +113,4 @@ describe('deployPlan', () => { // Only the gate is left, which is the one thing --yes answers. expect(unansweredDeployStops(settled)).toEqual(['review']); }); - - test('§8 parity: the browser payload and a headless caller carry one array', () => { - const stops = deployPlan({ - at: 'review', - answers: { platform: 'Vercel', branch: 'production', name: 'vercel-prod' }, - skipped: ['mode'], - dryRun: true, - }); - const jsonSurface = JSON.parse(JSON.stringify({ stops })); - - const html = renderScreen('deploy-plan-confirm', { - nonce: 'test-nonce', - stops, - target: { - name: 'vercel-prod', - adapterId: 'vercel', - adapterLabel: 'Vercel', - branch: 'production', - mode: 'ci', - options: [], - vars: ['DATABASE_URL'], - saved: true, - }, - action: 'ci', - dryRun: true, - preflight: [], - } as never); - - // The payload is inlined into the page verbatim, so the array can be read - // back out of the served HTML and compared. - const match = html.match(/window\.__CAPY_DATA__ = (\{.*?\});/s); - expect(match).not.toBeNull(); - const browserSurface = JSON.parse(match![1].replace(/\\u003c/g, '<')); - expect(browserSurface.stops).toEqual(jsonSurface.stops); - // And the modifiers survive the round trip — they are the part a rail - // needs to explain itself, and the easiest thing to lose in a serialize. - expect(browserSurface.stops.find((s: any) => s.id === 'signin').manual).toBe(true); - expect(browserSurface.stops.find((s: any) => s.id === 'deploy').blank).toBe(true); - expect(browserSurface.stops.find((s: any) => s.id === 'mode').state).toBe('skipped'); - }); - - test('the manual sign-in command is known for every shipped adapter', () => { - for (const id of ['cf-worker', 'cf-pages', 'vercel', 'aws-ssm']) { - expect(typeof SIGNIN_COMMAND[id]).toBe('string'); - } - }); }); diff --git a/tests/core/initWizardPlan.test.ts b/tests/core/initWizardPlan.test.ts deleted file mode 100644 index 636fcdf6..00000000 --- a/tests/core/initWizardPlan.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * The first-run route, as the CLI declares it. - * - * The claim under test is the one the whole trainstop model rests on: the rail - * a person reads and the array a headless caller would evaluate come out of - * ONE builder, and that builder distinguishes the three things a bare label - * cannot — a stop this run will not visit (`skipped`), a stop whose fork is - * not settled yet (`blank`), and a stop that is genuinely next (`current`). - */ -import { describe, test, expect } from 'bun:test'; -import { initWizardPlan } from '../../src/core/initWizardPlan'; - -const ids = (i: Parameters[0]) => initWizardPlan(i).map((s) => s.id); -const byId = (i: Parameters[0], id: string) => - initWizardPlan(i).find((s) => s.id === id)!; - -describe('initWizardPlan', () => { - test('declares all ten stops, in order, whatever this run will do', () => { - // Nothing is known yet and the route is still the whole route. - expect(ids({})).toEqual([ - 'auth', - 'organization', - 'organization-name', - 'recovery', - 'redeem', - 'project', - 'project-name', - 'branch', - 'branch-name', - 'encrypt', - ]); - // A run that answers everything drops nothing either. - expect( - ids({ - orgCount: 2, - organization: { kind: 'existing', name: 'mikes-market-hq' }, - hasOrgKey: true, - projectCount: 0, - project: { kind: 'new', name: 'mikes-market' }, - branchChoice: 'development', - localEnvCount: 3, - encrypt: true, - }), - ).toHaveLength(10); - }); - - test('sign-in is settled before anything opens', () => { - // `capy` authenticates first; the browser is only reached once there is a - // session, so this stop can never be the one the traveller stands on. - const auth = byId({ signedInAs: 'mike@market.example' }, 'auth'); - expect(auth.state).toBe('done'); - expect(auth.answer).toBe('mike@market.example'); - }); - - test('an unsettled fork is blank, not skipped and not upcoming', () => { - // Nobody has chosen an organization, so whether this run names one is not - // yet knowable — and "we do not know" is a different drawing from "this - // will not happen". - const plan = initWizardPlan({ orgCount: 2 }); - expect(plan.find((s) => s.id === 'organization')!.state).toBe('current'); - for (const id of ['organization-name', 'recovery']) { - const stop = plan.find((s) => s.id === id)!; - expect(stop.blank).toBe(true); - expect(stop.state).toBe('upcoming'); - } - }); - - test('the side of a fork this run did not take is skipped', () => { - const existing = initWizardPlan({ - orgCount: 2, - organization: { kind: 'existing', name: 'mikes-market-hq' }, - }); - expect(existing.find((s) => s.id === 'organization-name')!.state).toBe('skipped'); - expect(existing.find((s) => s.id === 'recovery')!.state).toBe('skipped'); - // …and a settled stop is no longer blank, whatever it looked like before. - expect(existing.find((s) => s.id === 'organization-name')!.blank).toBeUndefined(); - - const created = initWizardPlan({ - orgCount: 0, - organization: { kind: 'new', name: 'mikes-market-hq' }, - recoveryShown: true, - }); - // With no organizations at all the CLI never asks which one to use. - expect(created.find((s) => s.id === 'organization')!.state).toBe('skipped'); - expect(created.find((s) => s.id === 'organization-name')!.answer).toBe('mikes-market-hq'); - expect(created.find((s) => s.id === 'recovery')!.state).toBe('done'); - }); - - test('the recovery phrase stop is manual: it happens, and not on this page', () => { - expect(byId({}, 'recovery').manual).toBe(true); - expect(byId({}, 'redeem').manual).toBe(true); - }); - - test('a device that already holds the key never visits redeem', () => { - expect(byId({ hasOrgKey: true }, 'redeem').state).toBe('skipped'); - // A device that does not is standing on it — the refusal lands on a - // station that was drawn from the start rather than out of nowhere. - const missing = initWizardPlan({ - orgCount: 2, - organization: { kind: 'existing', name: 'mikes-market-hq' }, - hasOrgKey: false, - }); - expect(missing.find((s) => s.id === 'redeem')!.state).toBe('current'); - }); - - test('an organization with no projects is not asked which project to use', () => { - const none = initWizardPlan({ - orgCount: 1, - organization: { kind: 'existing', name: 'hq' }, - hasOrgKey: true, - projectCount: 0, - }); - expect(none.find((s) => s.id === 'project')!.state).toBe('skipped'); - expect(none.find((s) => s.id === 'project-name')!.state).toBe('current'); - - // A lookup that FAILED lands in the same place by a different road, and - // the input says which — the CLI swallows that error and would otherwise - // walk the user into a second project alongside one they already have. - const failed = initWizardPlan({ - orgCount: 1, - organization: { kind: 'existing', name: 'hq' }, - hasOrgKey: true, - projectCount: 0, - projectsUnavailable: true, - }); - expect(failed.find((s) => s.id === 'project')!.state).toBe('skipped'); - expect(failed.find((s) => s.id === 'project-name')!.state).toBe('current'); - }); - - test('bootstrapping an existing project skips every stop after it', () => { - // That path pulls the project's development branch and returns; there is - // no first branch to choose and no .env consent to give. - const plan = initWizardPlan({ - orgCount: 1, - organization: { kind: 'existing', name: 'hq' }, - hasOrgKey: true, - projectCount: 3, - project: { kind: 'existing', name: 'mikes-market' }, - }); - for (const id of ['project-name', 'branch', 'branch-name', 'encrypt']) { - expect(plan.find((s) => s.id === id)!.state).toBe('skipped'); - } - }); - - test('the default branch skips the naming stop; another branch does not', () => { - const base = { - orgCount: 1, - organization: { kind: 'existing' as const, name: 'hq' }, - hasOrgKey: true, - projectCount: 0, - project: { kind: 'new' as const, name: 'mikes-market' }, - }; - expect(initWizardPlan({ ...base, branchChoice: 'development' }).find((s) => s.id === 'branch-name')!.state) - .toBe('skipped'); - const other = initWizardPlan({ ...base, branchChoice: 'other' }); - expect(other.find((s) => s.id === 'branch-name')!.state).toBe('current'); - expect(other.find((s) => s.id === 'branch-name')!.blank).toBeUndefined(); - }); - - test('an empty directory never reaches the consent gate', () => { - const base = { - orgCount: 1, - organization: { kind: 'existing' as const, name: 'hq' }, - hasOrgKey: true, - projectCount: 0, - project: { kind: 'new' as const, name: 'mikes-market' }, - branchChoice: 'development' as const, - }; - expect(initWizardPlan({ ...base, localEnvCount: 0 }).find((s) => s.id === 'encrypt')!.state).toBe('skipped'); - expect(initWizardPlan({ ...base, localEnvCount: 4 }).find((s) => s.id === 'encrypt')!.state).toBe('current'); - // Refusing the gate is an ANSWER, not a stop that did not happen. - const refused = initWizardPlan({ ...base, localEnvCount: 4, encrypt: false }); - expect(refused.find((s) => s.id === 'encrypt')!.state).toBe('done'); - expect(refused.find((s) => s.id === 'encrypt')!.answer).toBe('no'); - }); - - test('exactly one stop is current, and it is the first one outstanding', () => { - const plan = initWizardPlan({ - orgCount: 2, - organization: { kind: 'existing', name: 'hq' }, - hasOrgKey: true, - projectCount: 2, - }); - expect(plan.filter((s) => s.state === 'current').map((s) => s.id)).toEqual(['project']); - // Everything after it is ahead, never behind. - expect(plan.slice(plan.findIndex((s) => s.id === 'project') + 1).every((s) => s.state !== 'done')).toBe(true); - }); - - test('a run that has answered nothing has every stop still ahead of it', () => { - // The empty plan, which is what the first page is drawn from: one stop to - // stand on and eight in front of it, and nothing settled but the sign-in - // that already happened. - const plan = initWizardPlan({}); - expect(plan.filter((s) => s.state === 'done').map((s) => s.id)).toEqual(['auth']); - expect(plan.filter((s) => s.state === 'current' || s.state === 'upcoming').map((s) => s.id)).toEqual([ - 'organization', - 'organization-name', - 'recovery', - 'redeem', - 'project', - 'project-name', - 'branch', - 'branch-name', - 'encrypt', - ]); - }); - - test('carries no secret material of any kind', () => { - const plan = initWizardPlan({ - signedInAs: 'mike@market.example', - orgCount: 1, - organization: { kind: 'new', name: 'hq' }, - recoveryShown: true, - hasOrgKey: true, - projectCount: 0, - project: { kind: 'new', name: 'mikes-market' }, - branchChoice: 'other', - branchName: 'staging', - localEnvCount: 12, - encrypt: true, - }); - const json = JSON.stringify(plan); - // The 24 words and the values are the two things this flow handles, and - // neither has a field on this route to travel in. A completed `recovery` - // stop records THAT it happened and nothing about what was shown. - expect(plan.find((s) => s.id === 'recovery')!.answer).toBeUndefined(); - expect(json).not.toContain('abandon'); - expect(json).not.toContain('sk_live'); - // Every answer on the rail is a name or a verdict — never a word list. - for (const stop of plan) { - if (stop.answer) expect(stop.answer.split(/\s+/).length).toBeLessThan(4); - } - }); -}); diff --git a/tests/core/invitePlan.test.ts b/tests/core/invitePlan.test.ts index 3efdbc69..0219fa0b 100644 --- a/tests/core/invitePlan.test.ts +++ b/tests/core/invitePlan.test.ts @@ -1,69 +1,54 @@ /** - * The parity claim for `capy invite`, made checkable. - * - * §8 says the rail a person reads in the browser and the array a headless - * caller parses from `--json` are the same object. These tests pin the shape - * and — the point of the exercise — assert the two surfaces are fed by ONE - * builder, by comparing what each emits for the same run. + * The route `capy invite` describes in `--json`, made checkable. * * The expiry stop gets the most attention here because it is the one station * with no terminal counterpart: `resolveNotAfter` reads four sources in order - * and never asks, so on a terminal run the rail's job is to say which of the - * four decided. Getting that wrong would put "you chose this" next to a - * lifetime an environment variable chose. + * and never asks, so the plan's job is to say which of the four decided. + * Getting that wrong would put "you chose this" next to a lifetime an + * environment variable chose. */ import { describe, test, expect } from 'bun:test'; -import { - invitePlan, - unansweredInviteStops, - roleNeedsProjects, - grantedProjects, - parseTtl, - formatTtl, - formatRelativeFuture, -} from '../../src/core/invitePlan'; -import { renderScreen } from '../../src/ui/screens/serve'; +import { invitePlan, roleNeedsProjects, parseTtl, formatTtl } from '../../src/core/invitePlan'; -/** A terminal run: nothing settled by argv, and nowhere to ask about expiry. */ -const TTY = { defaultTtl: '12h', canAskExpiry: false }; -/** A `--web` run: the expiry stop becomes a question. */ -const WEB = { defaultTtl: '12h', canAskExpiry: true }; +/** Nothing settled by argv. The expiry is always settled before the command starts. */ +const TTY = { defaultTtl: '12h' }; describe('invitePlan', () => { test('an unanswered run stands on the first stop and declares all four', () => { - const stops = invitePlan(WEB); + const stops = invitePlan(TTY); expect(stops.map((s) => s.id)).toEqual(['role', 'projects', 'expiry', 'code']); expect(stops[0].state).toBe('current'); expect(stops[1].state).toBe('upcoming'); - expect(stops[2].state).toBe('upcoming'); + // The expiry is settled before the command starts, so it is never a question. + expect(stops[2].state).toBe('done'); expect(stops[3].state).toBe('upcoming'); }); test('the whole route is declared even when a stop will not be reached', () => { // A run that skips projects still declared four stations, which is what // makes "how many questions is this" answerable before the first one. - expect(invitePlan(WEB).length).toBe(4); - expect(invitePlan({ ...WEB, role: { value: 'admin', flag: '--role admin' } }).length).toBe(4); + expect(invitePlan(TTY).length).toBe(4); + expect(invitePlan({ ...TTY, role: { value: 'admin', flag: '--role admin' } }).length).toBe(4); }); test('--role settles the stop, marked with the flag that settled it', () => { - const stops = invitePlan({ ...WEB, role: { value: 'admin', flag: '--role admin' } }); + const stops = invitePlan({ ...TTY, role: { value: 'admin', flag: '--role admin' } }); expect(stops[0]).toMatchObject({ state: 'done', answer: 'admin', flag: '--role admin' }); }); test('an org-wide role skips the project stop rather than dropping it', () => { // `admin` reaches every project, so the run never visits that station — // and the rail says so up front instead of the stop silently vanishing. - const stops = invitePlan({ ...WEB, role: { value: 'admin', flag: '--role admin' } }); + const stops = invitePlan({ ...TTY, role: { value: 'admin', flag: '--role admin' } }); expect(stops[1].state).toBe('skipped'); expect(stops[1].detail).toContain('every project'); - // The traveller moves past it to the next real question. - expect(stops[2].state).toBe('current'); + // The traveller moves past it to the next stop that is not settled: the code. + expect(stops[3].state).toBe('current'); }); test('a scoped role keeps its project stop', () => { for (const role of ['member', 'project-admin']) { - const stops = invitePlan({ ...WEB, role: { value: role, flag: `--role ${role}` } }); + const stops = invitePlan({ ...TTY, role: { value: role, flag: `--role ${role}` } }); expect(stops[1].state).toBe('current'); expect(roleNeedsProjects(role)).toBe(true); } @@ -75,7 +60,7 @@ describe('invitePlan', () => { // marker, `Role member` on a re-issue is indistinguishable from an answer // the user gave two seconds ago. const stops = invitePlan({ - ...WEB, + ...TTY, role: { value: 'member', flag: 'existing membership' }, projects: { names: ['storefront'], flag: 'existing membership' }, }); @@ -86,7 +71,7 @@ describe('invitePlan', () => { test('an answer the browser gave carries no flag', () => { // Nobody has to be told why they were not asked a question they just // answered — and claiming `--role` settled it would be a lie about a run. - const stops = invitePlan({ ...WEB, role: { value: 'member' } }); + const stops = invitePlan({ ...TTY, role: { value: 'member' } }); expect(stops[0]).toMatchObject({ state: 'done', answer: 'member' }); expect(stops[0].flag).toBeUndefined(); }); @@ -96,7 +81,6 @@ describe('invitePlan', () => { // on a TTY the question is already answered — by the 7-day default here. const stops = invitePlan(TTY); expect(stops[2]).toMatchObject({ state: 'done', answer: '12h', flag: 'default' }); - expect(unansweredInviteStops(stops)).toEqual(['role', 'projects']); }); test('the environment variable that shortens an invite is named on the rail', () => { @@ -108,43 +92,20 @@ describe('invitePlan', () => { test('--expires outranks --ttl, the way resolveNotAfter reads them', () => { const stops = invitePlan({ - ...WEB, + ...TTY, expiry: { value: '2026-08-01T00:00:00Z', flag: '--expires 2026-08-01T00:00:00Z' }, }); expect(stops[2]).toMatchObject({ state: 'done', flag: '--expires 2026-08-01T00:00:00Z' }); }); - test('only --web makes expiry a question', () => { - expect(unansweredInviteStops(invitePlan(WEB))).toEqual(['role', 'projects', 'expiry']); - expect(unansweredInviteStops(invitePlan(TTY))).toEqual(['role', 'projects']); - }); - - test('unansweredInviteStops is what decides whether a browser opens at all', () => { - // Fully specified: nothing left to ask, so `--web` asks nothing. - const settled = invitePlan({ - ...WEB, - role: { value: 'admin', flag: '--role admin' }, - expiry: { value: '12h', flag: '--ttl 12h' }, - }); - expect(unansweredInviteStops(settled)).toEqual([]); - // …and the run is then standing on the code, which is the only stop left. - expect(settled[3].state).toBe('current'); - }); - - test('a skipped stop is settled, not outstanding', () => { - const stops = invitePlan({ ...WEB, role: { value: 'admin' }, expiry: { value: '12h' } }); - expect(stops[1].state).toBe('skipped'); - expect(unansweredInviteStops(stops)).toEqual([]); - }); - test('every stop carries a detail, in the CLI\'s own words', () => { // These strings used to live in a lookup table inside the screen, keyed by // stop id, which meant the browser drew a route the CLI never described. - for (const stop of invitePlan(WEB)) { + for (const stop of invitePlan(TTY)) { expect(typeof stop.detail).toBe('string'); expect(stop.detail!.length).toBeGreaterThan(0); } - const stops = invitePlan(WEB); + const stops = invitePlan(TTY); // Lifted verbatim from `--role` / `--project` / `--ttl`'s own help text. expect(stops[0].detail).toBe('invitee role: member | project-admin | admin'); expect(stops[1].detail).toBe('grant project access'); @@ -157,7 +118,7 @@ describe('invitePlan', () => { // cannot appear in `answer` — and dropping it silently would hide the // failure rather than report it, which is what `note` is for. const stops = invitePlan({ - ...WEB, + ...TTY, role: { value: 'member' }, projects: { names: ['storefront'], @@ -170,71 +131,10 @@ describe('invitePlan', () => { detail: '1 more the service refused: warehouse', }); // Without one, the stop keeps the flag's own description. - expect(invitePlan({ ...WEB, role: { value: 'member' }, projects: { names: ['storefront'] } })[1].detail).toBe( + expect(invitePlan({ ...TTY, role: { value: 'member' }, projects: { names: ['storefront'] } })[1].detail).toBe( 'grant project access', ); }); - - test('§8 parity: the browser payload and --json carry the same array', () => { - const input = { - ...WEB, - role: { value: 'member', flag: '--role member' }, - projects: { names: ['storefront'], flag: '--project storefront' }, - }; - const stops = invitePlan(input); - const jsonSurface = JSON.parse(JSON.stringify({ stops })); - - const html = renderScreen('invite-teammate', { - nonce: 'test-nonce', - inviteeEmail: 'bob@example.com', - orgName: 'mikes-market', - callerEmail: 'mike@example.com', - callerRole: 'owner', - grantableRoles: [], - defaultRole: 'member', - projects: [], - expiry: { presets: [], defaultTtl: '12h', maxTtlHours: 12 }, - stops, - } as never); - - // The payload is inlined into the page verbatim, so the array can be read - // back out of the served HTML and compared. - const match = html.match(/window\.__CAPY_DATA__ = (\{.*?\});/s); - expect(match).not.toBeNull(); - const browserSurface = JSON.parse(match![1].replace(/\\u003c/g, '<')); - - expect(browserSurface.stops).toEqual(jsonSurface.stops); - // And the markers survive the round trip — they are the part a rail needs - // to explain itself, and the easiest thing to lose in a serialization. - expect(browserSurface.stops[0].flag).toBe('--role member'); - expect(browserSurface.stops[1].answer).toBe('storefront'); - }); -}); - -describe('grantedProjects', () => { - test('a stop --project settled is not a stop the browser answered', () => { - // `--project storefront --web` never serves the projects step, so the - // browser's answer is empty. Reading that emptiness as "no projects" is - // how such a run grants nothing and reports success. - expect(grantedProjects('member', [], ['p1', 'p2'])).toEqual(['p1', 'p2']); - }); - - test('an answer given in the browser wins over nothing', () => { - expect(grantedProjects('member', ['p3'], [])).toEqual(['p3']); - }); - - test('the browser outranks the flag once it has actually been asked', () => { - // The only way both are populated is a run that served the step anyway; - // the answer somebody just gave is the later word. - expect(grantedProjects('project-admin', ['p3'], ['p1'])).toEqual(['p3']); - }); - - test('an org-wide role takes none of them, whatever a flag said', () => { - // The terminal path achieves this by never entering its project block at - // all — `capy invite bob --role admin --project storefront` grants org-wide - // access and ignores the flag. Here it has to be said out loud. - expect(grantedProjects('admin', ['p3'], ['p1'])).toEqual([]); - }); }); describe('the TTL vocabulary', () => { @@ -247,9 +147,8 @@ describe('the TTL vocabulary', () => { expect(parseTtl(' 7d ')).toBe(7 * 86_400_000); }); - test('parseTtl refuses rather than exiting, so a page can reject an answer', () => { - // The command's own `--ttl` handler keeps the exit; a browser submit that - // took the process down with it would leave the user staring at a dead tab. + test('parseTtl refuses rather than exiting', () => { + // The command's own `--ttl` handler keeps the exit. expect(parseTtl('soon')).toBeNull(); expect(parseTtl('')).toBeNull(); expect(parseTtl('7 days')).toBeNull(); @@ -262,16 +161,4 @@ describe('the TTL vocabulary', () => { expect(formatTtl(90 * 60_000)).toBe('90m'); expect(formatTtl(45_000)).toBe('45s'); }); - - test('an expiry reads forwards, never backwards', () => { - // `formatRelativeTime` only knows "ago"; rendering an expiry through it is - // how a screen tells somebody their fresh code lapsed just now. - const now = Date.parse('2026-07-30T00:00:00Z'); - expect(formatRelativeFuture(now + 7 * 86_400_000, now)).toBe('in 7 days'); - expect(formatRelativeFuture(now + 86_400_000, now)).toBe('in 1 day'); - expect(formatRelativeFuture(now + 3_600_000, now)).toBe('in 1 hour'); - expect(formatRelativeFuture(now + 30 * 60_000, now)).toBe('in 30 minutes'); - expect(formatRelativeFuture(now + 5_000, now)).toBe('in under a minute'); - expect(formatRelativeFuture(now - 5_000, now)).toBe('in the past'); - }); }); diff --git a/tests/core/onboardingPlan.test.ts b/tests/core/onboardingPlan.test.ts deleted file mode 100644 index a0fab890..00000000 --- a/tests/core/onboardingPlan.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * The four routes the org / onboarding commands declare before they open - * anything. - * - * Pinned here rather than through a screen because that is the claim: the rail - * a person reads and the array a headless caller parses come out of these - * functions, so a divergence between the two surfaces has to start with a - * change to one of these tests. - * - * The property worth guarding hardest is that a stop this run will not visit is - * declared `skipped` rather than dropped. A filtered list describes the run; the - * point of a declared route is that it describes the ROUTE, including the - * branch nobody took. - */ -import { describe, test, expect } from 'bun:test'; -import { - byocConnectPlan, - createOrgPlan, - localOnboardingPlan, - orgSwitchPlan, - ORG_CREATE_STOP_IDS, - PHRASE_SOURCE_LABELS, -} from '../../src/core/onboardingPlan'; - -describe('orgSwitchPlan', () => { - test('declares the create branch on a run that is switching, struck through', () => { - const stops = orgSwitchPlan({}); - expect(stops.map((s) => s.id)).toEqual(['org', 'name', 'phrase', 'create', 'project']); - // Nothing chosen yet, so the create stops are not this run's — but they are - // on the map, which is the whole reason an account with no organizations is - // not promised a two-stop journey. - for (const id of ORG_CREATE_STOP_IDS) { - expect(stops.find((s) => s.id === id)!.state).toBe('skipped'); - } - expect(stops[0].state).toBe('current'); - expect(stops.find((s) => s.id === 'project')!.state).toBe('upcoming'); - }); - - test('choosing the create row lights the three stops it adds', () => { - const stops = orgSwitchPlan({ creating: true }); - for (const id of ORG_CREATE_STOP_IDS) { - expect(stops.find((s) => s.id === id)!.state).toBe('upcoming'); - } - // The rail says which row was picked, in the screen's own words. - expect(stops[0]).toMatchObject({ state: 'done', answer: 'Create a new organization' }); - }); - - test('an answered organization moves the traveller to the project stop', () => { - const stops = orgSwitchPlan({ orgName: 'mikes-market' }); - expect(stops[0]).toMatchObject({ state: 'done', answer: 'mikes-market' }); - expect(stops.find((s) => s.id === 'project')!.state).toBe('current'); - }); - - test('a walked create route is behind the traveller, not struck through', () => { - // The screen served immediately after Name → Recovery phrase → Create. With - // only `orgName` to go on the rail called all three "not needed" on the very - // page that follows them, to a user who had just written down 24 words. - const stops = orgSwitchPlan({ orgName: 'Northwind', created: true }); - for (const id of ORG_CREATE_STOP_IDS) { - expect(stops.find((s) => s.id === id)!.state).toBe('done'); - } - expect(stops[0]).toMatchObject({ state: 'done', answer: 'Northwind' }); - // The one stop left is where the traveller is. - expect(stops.find((s) => s.id === 'project')!.state).toBe('current'); - expect(stops.filter((s) => s.state === 'current')).toHaveLength(1); - }); - - test('the walked phrase stop still carries only the consent', () => { - const phrase = orgSwitchPlan({ orgName: 'Northwind', created: true }).find( - (s) => s.id === 'phrase', - )!; - expect(phrase.answer).toBe('written down'); - expect(phrase.answer!.split(/\s+/)).toHaveLength(2); - }); - - test('a chosen project is done, carrying its own name', () => { - const stops = orgSwitchPlan({ orgName: 'mikes-market', projectName: 'storefront' }); - expect(stops.find((s) => s.id === 'project')).toMatchObject({ - state: 'done', - answer: 'storefront', - }); - }); -}); - -describe('createOrgPlan', () => { - test('name, recovery phrase, create — declared before the phrase exists', () => { - const stops = createOrgPlan({}); - expect(stops.map((s) => s.id)).toEqual(['name', 'phrase', 'create']); - expect(stops.map((s) => s.state)).toEqual(['current', 'upcoming', 'upcoming']); - }); - - test('a named organization stands on the phrase stop', () => { - const stops = createOrgPlan({ name: ' Northwind Labs ' }); - expect(stops[0]).toMatchObject({ state: 'done', answer: 'Northwind Labs' }); - expect(stops[1].state).toBe('current'); - }); - - test('the phrase stop never carries the phrase, only that it was written down', () => { - const stops = createOrgPlan({ name: 'Northwind', confirmed: true }); - const phrase = stops.find((s) => s.id === 'phrase')!; - expect(phrase).toMatchObject({ state: 'done', answer: 'written down' }); - // Whatever the answer is, it is four characters of English and not a seed. - expect(phrase.answer!.split(/\s+/)).toHaveLength(2); - }); -}); - -describe('localOnboardingPlan', () => { - test('four stops, and the phrase source is answered in the CLI\'s own words', () => { - expect(localOnboardingPlan({}).map((s) => s.id)).toEqual([ - 'source', - 'phrase', - 'passphrase', - 'finish', - ]); - expect(localOnboardingPlan({ source: 'generate' })[0]).toMatchObject({ - state: 'done', - answer: PHRASE_SOURCE_LABELS.generate, - }); - expect(localOnboardingPlan({ source: 'enter' })[0].answer).toBe( - PHRASE_SOURCE_LABELS.enter, - ); - }); - - test('the traveller moves one stop at a time as answers land', () => { - expect(localOnboardingPlan({}).map((s) => s.state)).toEqual([ - 'current', - 'upcoming', - 'upcoming', - 'upcoming', - ]); - expect(localOnboardingPlan({ source: 'generate' }).map((s) => s.state)).toEqual([ - 'done', - 'current', - 'upcoming', - 'upcoming', - ]); - expect( - localOnboardingPlan({ source: 'generate', phraseSettled: true }).map((s) => s.state), - ).toEqual(['done', 'done', 'current', 'upcoming']); - }); - - test('no stop ever carries a recovery phrase as its answer', () => { - const stops = localOnboardingPlan({ - source: 'generate', - phraseSettled: true, - passphraseSettled: true, - }); - expect(stops.find((s) => s.id === 'phrase')!.answer).toBeUndefined(); - expect(stops.find((s) => s.id === 'passphrase')!.answer).toBeUndefined(); - }); -}); - -describe('byocConnectPlan', () => { - test('the certificate stop is a blank until a probe has been run', () => { - const stops = byocConnectPlan({}); - expect(stops.map((s) => s.id)).toEqual(['url', 'verify', 'trust', 'name', 'save']); - const trust = stops.find((s) => s.id === 'trust')!; - // Not skipped: nothing has looked yet, and promising to skip a station the - // plan has not reached is a promise it cannot keep. - expect(trust.state).toBe('upcoming'); - expect(trust.blank).toBe(true); - // Finding a root certificate is work done outside Capy. - expect(trust.manual).toBe(true); - }); - - test('a probe that trusted the certificate skips that stop for good', () => { - const trust = byocConnectPlan({ url: 'https://capy.acme.com', certUntrusted: false }).find( - (s) => s.id === 'trust', - )!; - expect(trust.state).toBe('skipped'); - expect(trust.blank).toBeUndefined(); - }); - - test('an untrusted certificate makes it the stop the run is standing on', () => { - const trust = byocConnectPlan({ url: 'https://capy.acme.com', certUntrusted: true }).find( - (s) => s.id === 'trust', - )!; - expect(trust.state).toBe('current'); - const settled = byocConnectPlan({ - url: 'https://capy.acme.com', - certUntrusted: true, - caBundle: '~/certs/acme-root.crt', - }).find((s) => s.id === 'trust')!; - expect(settled).toMatchObject({ state: 'done', answer: '~/certs/acme-root.crt' }); - }); - - test('a URL from argv is marked as answered by the argument, not by a question', () => { - const [url] = byocConnectPlan({ url: 'https://capy.acme.com', urlFromArgv: true }); - expect(url).toMatchObject({ state: 'done', answer: 'https://capy.acme.com', flag: 'argument' }); - // Typed into the field instead: same answer, no flag to quote. - const [typed] = byocConnectPlan({ url: 'https://capy.acme.com' }); - expect(typed.flag).toBeUndefined(); - }); - - test('every stop says what happens there', () => { - for (const stop of byocConnectPlan({})) { - expect(stop.detail && stop.detail.length > 0).toBe(true); - } - }); - - test('a verified instance moves the traveller to the name', () => { - const stops = byocConnectPlan({ url: 'https://capy.acme.com', verified: true }); - expect(stops.find((s) => s.id === 'verify')!.state).toBe('done'); - expect(stops.find((s) => s.id === 'name')!.state).toBe('current'); - }); - - test('an unsettled address leaves everything behind it unanswered', () => { - // What a run that mistyped the host is looking at. `url` unset is the whole - // statement: nothing downstream of an address nobody accepted may claim an - // answer, and the traveller is standing on the question, not past it. - const stops = byocConnectPlan({ urlFromArgv: true }); - expect(stops.find((s) => s.id === 'url')).toMatchObject({ state: 'current' }); - expect(stops.find((s) => s.id === 'url')!.answer).toBeUndefined(); - expect(stops.find((s) => s.id === 'verify')!.state).toBe('upcoming'); - expect(stops.find((s) => s.id === 'trust')).toMatchObject({ state: 'upcoming', blank: true }); - expect(stops.filter((s) => s.state === 'current')).toHaveLength(1); - }); - - test('the certificate question overtakes verify rather than lighting up beside it', () => { - // Both stops were `current` at once: the run cannot be at two stations. - const stops = byocConnectPlan({ url: 'https://capy.acme.com', certUntrusted: true }); - expect(stops.find((s) => s.id === 'trust')!.state).toBe('current'); - expect(stops.find((s) => s.id === 'verify')!.state).toBe('upcoming'); - expect(stops.filter((s) => s.state === 'current')).toHaveLength(1); - }); - - test('a bundle that made the instance verify is an answer, not a skipped stop', () => { - // `certUntrusted` is false once the handshake completes — with the bundle in - // it. Read in the other order the rail struck the station through and then - // hung the bundle path off it as the answer to a question it never asked. - const trust = byocConnectPlan({ - url: 'https://capy.acme.com', - verified: true, - certUntrusted: false, - caBundle: '~/certs/acme-root.crt', - }).find((s) => s.id === 'trust')!; - expect(trust).toMatchObject({ state: 'done', answer: '~/certs/acme-root.crt' }); - }); - - test('exactly one stop is current in every state this flow can reach', () => { - const states = [ - {}, - { url: 'u' }, - { url: 'u', certUntrusted: true }, - { url: 'u', certUntrusted: true, caBundle: '~/c.crt' }, - { url: 'u', verified: true, certUntrusted: false }, - { url: 'u', verified: true, certUntrusted: false, profileName: 'acme' }, - ]; - for (const input of states) { - expect(byocConnectPlan(input).filter((s) => s.state === 'current').length).toBeLessThanOrEqual(1); - } - }); -}); diff --git a/tests/core/recoverPlan.test.ts b/tests/core/recoverPlan.test.ts deleted file mode 100644 index 8b7ed34d..00000000 --- a/tests/core/recoverPlan.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * The route `capy recover` declares before it opens anything. - * - * What is being pinned is that the whole route exists from the first render — - * including the stop this run will skip and the one it cannot commit to yet. - * The terminal announces none of it: you answer "which organization", and only - * then does a destructive overwrite confirm appear, with nothing having warned - * you it was coming. A rail that grew that stop on arrival would be the same - * ambush with nicer typography. - */ -import { describe, test, expect } from 'bun:test'; -import { recoverPlan } from '../../src/core/recoverPlan'; - -const BASE = { signedIn: true, userEmail: 'vince@capy.sc', wordCount: 24 }; - -const byId = (stops: ReturnType, id: string) => - stops.find((s) => s.id === id)!; - -describe('recoverPlan', () => { - test('draws every stop from the first render, in order', () => { - const stops = recoverPlan(BASE); - expect(stops.map((s) => s.id)).toEqual([ - 'auth', - 'organization', - 'overwrite', - 'phrase', - 'write', - ]); - }); - - test('the sign-in is history by the time a screen exists', () => { - // `recover` authenticates before it asks anything, so `auth` is done and - // carries who it signed in as. Dotted track over history would say "over - // to you" about something that is already finished. - const auth = byId(recoverPlan(BASE), 'auth'); - expect(auth.state).toBe('done'); - expect(auth.answer).toBe('vince@capy.sc'); - expect(auth.manual).toBeUndefined(); - }); - - test('an unfinished sign-in is the one stop drawn as manual', () => { - const auth = byId(recoverPlan({ ...BASE, signedIn: false }), 'auth'); - expect(auth.state).toBe('current'); - expect(auth.manual).toBe(true); - // …and nothing after it is where the traveller stands. - expect(byId(recoverPlan({ ...BASE, signedIn: false }), 'organization').state).toBe('upcoming'); - }); - - test('the overwrite gate is blank until the organization stop settles it', () => { - // Whether this stop exists at all depends on the organization you pick, so - // it cannot be a real station yet — and it must not be left out either. - const overwrite = byId(recoverPlan(BASE), 'overwrite'); - expect(overwrite.state).toBe('upcoming'); - expect(overwrite.blank).toBe(true); - }); - - test('an organization with no key here strikes the gate out rather than dropping it', () => { - const stops = recoverPlan({ ...BASE, orgName: 'Demos', hasKeyOnThisDevice: false }); - expect(byId(stops, 'overwrite').state).toBe('skipped'); - expect(byId(stops, 'overwrite').blank).toBeUndefined(); - // With nothing to destroy, the phrase is where the run stands. - expect(byId(stops, 'phrase').state).toBe('current'); - expect(stops).toHaveLength(5); - }); - - test('an organization with a key here makes the gate the current stop', () => { - const stops = recoverPlan({ ...BASE, orgName: 'Demos', hasKeyOnThisDevice: true }); - expect(byId(stops, 'organization').state).toBe('done'); - expect(byId(stops, 'organization').answer).toBe('Demos'); - expect(byId(stops, 'overwrite').state).toBe('current'); - // The phrase is not reachable until the gate is answered. - expect(byId(stops, 'phrase').state).toBe('upcoming'); - }); - - test('agreeing to the overwrite moves the run on to the phrase', () => { - const stops = recoverPlan({ - ...BASE, - orgName: 'Demos', - hasKeyOnThisDevice: true, - overwriteAgreed: true, - }); - expect(byId(stops, 'overwrite').state).toBe('done'); - expect(byId(stops, 'overwrite').answer).toBe('replace it'); - expect(byId(stops, 'phrase').state).toBe('current'); - }); - - test('the phrase stop counts the words the run expects, not a hardcoded 24', () => { - expect(byId(recoverPlan({ ...BASE, wordCount: 12 }), 'phrase').detail).toBe('all 12 words'); - expect(byId(recoverPlan(BASE), 'phrase').detail).toBe('all 24 words'); - }); - - test('a phrase nothing could check leaves the run standing at the write', () => { - // The only state that reaches the write stop with a page still open: the - // trial decryption could not run and the user is being asked to write - // anyway. On every other run the phrase stop verifies and writes at once. - const stops = recoverPlan({ - ...BASE, - orgName: 'Demos', - hasKeyOnThisDevice: false, - phraseEntered: true, - }); - expect(byId(stops, 'phrase').state).toBe('done'); - expect(byId(stops, 'write').state).toBe('current'); - }); - - test('the write stop stops promising a verification that could not run', () => { - // Standing at this stop means the trial decryption is the thing that DID - // NOT happen. A rail still describing it as "verify it, then save it" is - // describing a step that failed as though it were ahead of you. - const stopped = byId( - recoverPlan({ ...BASE, orgName: 'Demos', hasKeyOnThisDevice: false, phraseEntered: true }), - 'write', - ); - expect(stopped.detail).toBe('nothing here could verify it, so saving it is a decision'); - - // Everywhere else the stop is still ahead, and it still promises it. - const ahead = byId(recoverPlan({ ...BASE, orgName: 'Demos', hasKeyOnThisDevice: false }), 'write'); - expect(ahead.detail).toContain('verify it against'); - }); - - test('nothing on the rail is or describes phrase material', () => { - const stops = recoverPlan({ - ...BASE, - orgName: 'Demos', - hasKeyOnThisDevice: true, - overwriteAgreed: true, - phraseEntered: true, - }); - // The phrase stop never carries an answer: "what you typed" has no safe - // summary, and a word count is already in the detail. - expect(byId(stops, 'phrase').answer).toBeUndefined(); - expect(byId(stops, 'write').answer).toBeUndefined(); - }); - - test('a whitespace-only organization name is not an answer', () => { - const stops = recoverPlan({ ...BASE, orgName: ' ', hasKeyOnThisDevice: true }); - expect(byId(stops, 'organization').state).toBe('current'); - }); -}); diff --git a/tests/core/secretEditPlan.test.ts b/tests/core/secretEditPlan.test.ts deleted file mode 100644 index 8cb663b2..00000000 --- a/tests/core/secretEditPlan.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * The routes `capy edit` and `capy decrypt` travel. - * - * The claim these tests exist to hold: the rail a person reads and the array a - * headless caller parses are the SAME array. `secret-table` and - * `secret-commit-review` are two halves of one journey and used to carry their - * own lookup tables of station labels; they get them from `secretEditPlan` now, - * so a station cannot be called two things. - */ -import { describe, test, expect } from 'bun:test'; -import { secretEditPlan } from '../../src/core/secretEditPlan'; -import { decryptPlan } from '../../src/core/decryptPlan'; -import { renderScreen } from '../../src/ui/screens/serve'; - -const EDIT = { - variableCount: 9, - changeCount: 0, - localMode: false, - destination: 'mikes-market/staging', - at: 'edit' as const, -}; - -describe('secretEditPlan', () => { - test('draws every station up front, including the ones this run has not reached', () => { - const stops = secretEditPlan(EDIT); - expect(stops.map((s) => s.id)).toEqual(['edit', 'review', 'write', 'result']); - expect(stops.map((s) => s.state)).toEqual(['current', 'upcoming', 'upcoming', 'upcoming']); - }); - - test('exactly one station is where the traveller is standing', () => { - for (const at of ['edit', 'review', 'write', 'result'] as const) { - const current = secretEditPlan({ ...EDIT, at }).filter((s) => s.state === 'current'); - expect(current).toHaveLength(1); - expect(current[0].id).toBe(at); - } - }); - - test('a finished stop reports what was decided, not what was on offer', () => { - const stops = secretEditPlan({ ...EDIT, changeCount: 1, at: 'review' }); - expect(stops[0]).toEqual({ id: 'edit', label: 'Edit values', state: 'done', answer: '1 change' }); - // …and the count agrees with its own noun on both sides of 1. - expect(secretEditPlan({ ...EDIT, changeCount: 4, at: 'review' })[0].answer).toBe('4 changes'); - expect(secretEditPlan({ ...EDIT, variableCount: 1 })[0].detail).toBe('1 variable'); - }); - - test('the write stop names Keep only when there is a Keep to push to', () => { - const server = secretEditPlan(EDIT)[2]; - expect(server).toMatchObject({ - label: 'Encrypt and push to Keep', - detail: 'mikes-market/staging', - detailMono: true, - }); - - // A local profile pushes nothing, and its detail is a sentence rather than - // an identifier — so it must not be set in the machine face. - const local = secretEditPlan({ ...EDIT, localMode: true })[2]; - expect(local).toMatchObject({ label: 'Commit locally', detail: 'to this machine only' }); - expect(local.detailMono).toBeUndefined(); - }); - - test('a discarded run marks the write as skipped, never as done', () => { - const stops = secretEditPlan({ ...EDIT, changeCount: 2, at: 'result', discarded: true }); - expect(stops[2].state).toBe('skipped'); - expect(stops[3].state).toBe('current'); - - const committed = secretEditPlan({ ...EDIT, changeCount: 2, at: 'result' }); - expect(committed[2].state).toBe('done'); - }); - - test('the result stop stops describing itself once you are standing on it', () => { - expect(secretEditPlan(EDIT)[3].detail).toBe('what this run changed'); - expect(secretEditPlan({ ...EDIT, at: 'result' })[3].detail).toBeUndefined(); - }); - - test('the array the screen renders is the array this returns', () => { - const stops = secretEditPlan(EDIT); - const html = renderScreen('secret-table', { - nonce: 'n', - projectName: 'mikes-market', - branch: 'staging', - mode: 'server', - stops, - rows: [], - }); - expect(html).toContain('Encrypt and push to Keep'); - expect(html).toContain(JSON.stringify(stops).slice(1, 40)); - }); -}); - -describe('decryptPlan', () => { - const BASE = { wordCount: 24, outputFile: '.env.main.decrypted', usingSession: false }; - - test('phrase, decrypt, write — declared before the first box opens', () => { - const stops = decryptPlan(BASE); - expect(stops.map((s) => s.id)).toEqual(['phrase', 'decrypt', 'write']); - expect(stops[0]).toMatchObject({ state: 'current', detail: '24 words, typed here' }); - expect(stops[2].detail).toBe('.env.main.decrypted'); - }); - - test('an open session skips the phrase stop instead of dropping it', () => { - const stops = decryptPlan({ ...BASE, usingSession: true }); - expect(stops[0].state).toBe('skipped'); - expect(stops[1].state).toBe('current'); - }); - - test('a run with no branch names no file rather than naming an empty one', () => { - expect(decryptPlan({ ...BASE, outputFile: null })[2].detail).toBeUndefined(); - }); - - test('the word count is the payload\'s, never assumed', () => { - expect(decryptPlan({ ...BASE, wordCount: 12 })[0].detail).toBe('12 words, typed here'); - }); - - test('a finished run leaves nothing current', () => { - const stops = decryptPlan({ ...BASE, finished: true }); - expect(stops.some((s) => s.state === 'current')).toBe(false); - expect(stops.map((s) => s.state)).toEqual(['done', 'done', 'done']); - }); -}); diff --git a/tests/e2e/stripeConnector.e2e.test.ts b/tests/e2e/stripeConnector.e2e.test.ts index c02290b0..a2d78191 100644 --- a/tests/e2e/stripeConnector.e2e.test.ts +++ b/tests/e2e/stripeConnector.e2e.test.ts @@ -119,9 +119,8 @@ describe('live-mode firewall (capy-dev)', () => { }); // Both streams. The firewall refusal is a typed `DEV_LIVE_FIREWALL` now, // so it goes out through `displayErrorAndExit` — which prints on stdout - // like every other typed failure in the CLI, and which is also what serves - // the page under `--web`. What matters here is the sentence a person sees, - // not which descriptor it arrived on. + // like every other typed failure in the CLI. What matters here is the + // sentence a person sees, not which descriptor it arrived on. const logSpy = spyOn(console, 'log').mockImplementation(() => {}); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); diff --git a/tests/helpers/cdp.ts b/tests/helpers/cdp.ts deleted file mode 100644 index 3cf174a4..00000000 --- a/tests/helpers/cdp.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Minimal Chrome DevTools Protocol driver — no npm dependencies. - * - * Test-only. Used by the browser end-to-end tests to drive a real page — fill - * a field, click the button, watch what the CLI actually receives — which is - * the only check that can catch a control wired to nothing. - * - * Deliberately drives a *downloaded* Chrome for Testing headless shell with a - * throwaway profile; it must never attach to, launch, or read the developer's - * real browser profile. Vendored here rather than imported from the monorepo - * so a standalone clone still typechecks; the tests skip when no cached shell - * is present. - */ -import { spawn, type ChildProcess } from 'node:child_process'; -import { mkdirSync, existsSync, readdirSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { homedir } from 'node:os'; - -/** Newest cached chrome-headless-shell, or null when none is installed. */ -export function findHeadlessShell(): string | null { - const root = resolve(homedir(), '.cache/puppeteer/chrome-headless-shell'); - if (!existsSync(root)) return null; - const builds = readdirSync(root) - .filter((d) => /^mac|^linux|^win/.test(d)) - // Directory names embed the version (mac_arm-148.0.7778.97); sort numerically. - .sort((a, b) => compareVersions(versionOf(a), versionOf(b))); - for (const build of builds.reverse()) { - for (const inner of readdirSync(resolve(root, build))) { - const bin = resolve(root, build, inner, 'chrome-headless-shell'); - if (existsSync(bin)) return bin; - } - } - return null; -} - -const versionOf = (dir: string): string => dir.split('-')[1] ?? '0'; - -function compareVersions(a: string, b: string): number { - const pa = a.split('.').map(Number); - const pb = b.split('.').map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const d = (pa[i] ?? 0) - (pb[i] ?? 0); - if (d !== 0) return d; - } - return 0; -} - -interface Pending { - resolve: (v: Record) => void; - reject: (e: Error) => void; -} - -/** One CDP session bound to one target (tab). */ -export class CdpSession { - private nextId = 1; - private pending = new Map(); - private listeners = new Map) => void>>(); - - constructor(private ws: WebSocket) { - ws.addEventListener('message', (ev) => { - const msg = JSON.parse(String(ev.data)) as { - id?: number; - result?: Record; - error?: { message: string }; - method?: string; - params?: Record; - }; - if (msg.id !== undefined) { - const p = this.pending.get(msg.id); - if (!p) return; - this.pending.delete(msg.id); - if (msg.error) p.reject(new Error(msg.error.message)); - else p.resolve(msg.result ?? {}); - return; - } - if (msg.method) { - for (const fn of this.listeners.get(msg.method) ?? []) fn(msg.params ?? {}); - } - }); - } - - send(method: string, params: Record = {}): Promise> { - const id = this.nextId++; - this.ws.send(JSON.stringify({ id, method, params })); - return new Promise((res, rej) => { - this.pending.set(id, { resolve: res, reject: rej }); - setTimeout(() => { - if (this.pending.delete(id)) rej(new Error(`CDP timeout: ${method}`)); - }, 30_000); - }); - } - - on(method: string, fn: (p: Record) => void): void { - const arr = this.listeners.get(method) ?? []; - arr.push(fn); - this.listeners.set(method, arr); - } - - once(method: string, timeoutMs = 15_000): Promise> { - return new Promise((res, rej) => { - const timer = setTimeout(() => rej(new Error(`CDP event timeout: ${method}`)), timeoutMs); - this.on(method, (p) => { - clearTimeout(timer); - res(p); - }); - }); - } - - /** Evaluate an expression in the page and return its JSON value. */ - async eval(expression: string): Promise { - const r = (await this.send('Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true, - })) as { result?: { value?: T }; exceptionDetails?: { text?: string } }; - if (r.exceptionDetails) throw new Error(r.exceptionDetails.text ?? 'evaluate failed'); - return r.result?.value as T; - } - - close(): void { - this.ws.close(); - } -} - -export class Browser { - private constructor( - private proc: ChildProcess, - private endpoint: string, - ) {} - - /** - * Start a shell, retrying a startup that dies (up to three attempts). - * - * A shell that dies during startup is the one flaky failure this suite has: - * a full batch run launches a couple of dozen of them against the same - * machine, and an early exit surfaces as a test failing in ~150ms — before - * it has driven anything. Retrying the LAUNCH cannot mask a product defect, - * because at this point no page has been served and nothing has been - * clicked; every assertion still has to pass on the browser that comes up. - */ - static async launch(profileDir: string): Promise { - let last: unknown; - for (let attempt = 0; attempt < 3; attempt++) { - try { - return await Browser.spawnOnce(profileDir); - } catch (err) { - last = err; - await new Promise((r) => setTimeout(r, 250 * (attempt + 1))); - } - } - throw last instanceof Error ? last : new Error(String(last)); - } - - private static async spawnOnce(profileDir: string): Promise { - const bin = findHeadlessShell(); - if (!bin) { - throw new Error( - 'No cached chrome-headless-shell found under ~/.cache/puppeteer. ' + - 'Install one with: bunx @puppeteer/browsers install chrome-headless-shell@stable', - ); - } - mkdirSync(profileDir, { recursive: true }); - const proc = spawn( - bin, - [ - '--remote-debugging-port=0', - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--disable-gpu', - '--hide-scrollbars', - '--force-device-scale-factor=2', - '--disable-lcd-text', - '--font-render-hinting=none', - '--allow-pre-commit-input', - 'about:blank', - ], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - - const endpoint = await new Promise((res, rej) => { - let buf = ''; - const timer = setTimeout(() => rej(new Error('browser did not report a devtools endpoint')), 20_000); - proc.stderr?.on('data', (d: Buffer) => { - buf += d.toString(); - const m = buf.match(/ws:\/\/[^\s]+/); - if (m) { - clearTimeout(timer); - res(m[0]); - } - }); - proc.on('exit', (code) => { - clearTimeout(timer); - rej(new Error(`browser exited early (${code}): ${buf.slice(-400)}`)); - }); - }).catch((err) => { - // Never leave a half-started shell behind for the retry to compete with. - proc.kill('SIGKILL'); - throw err; - }); - - return new Browser(proc, endpoint); - } - - /** Open a fresh tab and return a session attached to it. */ - async newPage(width: number, height: number): Promise { - const base = this.endpoint.replace(/^ws:\/\//, 'http://').replace(/\/devtools\/browser\/.*$/, ''); - const r = await fetch(`${base}/json/new?about:blank`, { method: 'PUT' }); - const target = (await r.json()) as { webSocketDebuggerUrl: string }; - const ws = new WebSocket(target.webSocketDebuggerUrl); - await new Promise((res, rej) => { - ws.addEventListener('open', () => res(), { once: true }); - ws.addEventListener('error', () => rej(new Error('devtools socket failed')), { once: true }); - }); - const s = new CdpSession(ws); - await s.send('Page.enable'); - await s.send('Runtime.enable'); - await s.send('Emulation.setDeviceMetricsOverride', { - width, - height, - deviceScaleFactor: 2, - mobile: false, - }); - return s; - } - - close(): void { - this.proc.kill('SIGTERM'); - } -} diff --git a/tests/helpers/fakeTerminal.ts b/tests/helpers/fakeTerminal.ts new file mode 100644 index 00000000..e0d359e0 --- /dev/null +++ b/tests/helpers/fakeTerminal.ts @@ -0,0 +1,41 @@ +/** + * A fake terminal for driving real inquirer prompts in tests: keys go in on a + * stream, rendered output comes back as text. Nothing touches the real stdin, + * stdout, a browser or the network. + */ +import { PassThrough } from 'stream'; + +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; + +export const KEYS = { + enter: '\r', + down: '\x1b[B', + up: '\x1b[A', + backspace: '\x7f', + escape: '\x1b', +} as const; + +export function fakeTerminal() { + const input = new PassThrough(); + const output = new PassThrough(); + const chunks: string[] = []; + output.on('data', (c) => chunks.push(String(c))); + const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + return { + input, + output, + context: { input, output }, + /** Send keys one at a time, letting the prompt re-render between them. A lone Esc waits out readline's escape timeout. */ + async type(...keys: string[]): Promise { + for (const k of keys) { + input.write(k); + await wait(k === '\x1b' ? 600 : 15); + } + }, + /** Everything drawn so far, with colour codes removed. */ + screen(): string { + return chunks.join('').replace(ANSI, ''); + }, + wait, + }; +} diff --git a/tests/run-tests.sh b/tests/run-tests.sh index de6d45e7..6c3a7756 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -10,11 +10,9 @@ cd "$(dirname "$0")/.." # auto-commit (CAP-303) create real commits here. export CAPY_NO_AUTOCOMMIT=1 -# No test may launch the developer's browser. Every `--web` call site reads -# this before calling `open()`, and the browser tests drive a downloaded -# headless shell with a throwaway profile instead. Individual tests also pass -# `open: false`; this is the backstop for the ones that go through a command, -# where the flag is not theirs to pass. +# No test may launch the developer's browser. `openScreen` reads this before +# calling `open()`; it is the backstop for the tests that go through a command +# (the sign-in callback, the deploy page), where the flag is not theirs to pass. export CAPY_WEB_NO_OPEN=1 FAIL=0 @@ -44,6 +42,7 @@ ISOLATED_FILES=( tests/commands/deployRevokeWiring.test.ts tests/commands/deployDirectModeTiming.test.ts tests/commands/deployDokployPickerTokenEnv.test.ts + tests/commands/projectPickerWiring.test.ts tests/files/fileManager.test.ts tests/ui/promptEngine.test.ts tests/commands/decryptCommand.test.ts @@ -56,7 +55,6 @@ ISOLATED_FILES=( tests/service/serviceClient.test.ts tests/commands/profileCommand.test.ts tests/commands/localOnlyFlow.test.ts - tests/ui/deployDeadline.test.ts tests/ui/secretsScreenSearchHashing.test.ts tests/commands/rotatePromotesThenRotates.test.ts tests/system/systemStore.test.ts diff --git a/tests/ui/branchScreens.test.ts b/tests/ui/branchScreens.test.ts deleted file mode 100644 index e58c3b7c..00000000 --- a/tests/ui/branchScreens.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -/** - * `capy checkout`, served as compiled screens. - * - * Two things these pin that nothing else could: - * - * - the rail the browser draws is `branchCreatePlan`'s array, not one this - * file rebuilt. `tests/core/branchCreatePlan.test.ts` already proves the - * plan and `--json` agree; these prove the payload is fed by the same - * call, which is what closes the loop. - * - the seed preview carries variable NAMES and never a value. `checkout -b` - * decrypts .env in-process to seed the new branch, so this payload is the - * one place in the flow where plaintext could escape. - */ -import { describe, test, expect } from 'bun:test'; -import { - buildBranchCreateData, - buildBranchListData, - branchNameProblem, - createBranchInBrowser, - chooseBranchInBrowser, -} from '../../src/ui/branchScreens'; -import { branchCreatePlan } from '../../src/core/branchCreatePlan'; -import type { Branch } from '../../src/types/index'; - -const headers = { 'content-type': 'application/json' }; - -async function waitForUrl(getUrl: () => string): Promise { - for (let i = 0; i < 300 && !getUrl(); i++) await new Promise((r) => setTimeout(r, 10)); - return getUrl(); -} - -const EXISTING = [ - { name: 'development', isProtected: false }, - { name: 'production', isProtected: true }, -]; - -const CREATE = { - projectName: 'mikes-market', - branchName: 'release', - existingBranches: EXISTING, - seedFrom: 'development', - seedVarNames: ['DATABASE_URL', 'STRIPE_SECRET_KEY'], - open: false, -}; - -const BRANCHES: Branch[] = [ - { - id: 'b1', - name: 'development', - project_id: 'p1', - is_protected: false, - created_at: '2026-07-30T09:00:00.000Z', - }, - // Deliberately named like a protected branch and NOT protected: the payload - // must read `is_protected`, never the name. - { id: 'b2', name: 'production', project_id: 'p1', is_protected: false }, - // …and the reverse, so neither direction can be passing by accident. - { id: 'b3', name: 'spike', project_id: 'p1', is_protected: true }, -]; - -const LIST = { - projectName: 'mikes-market', - activeBranch: 'development', - branches: BRANCHES, - canDelete: false, - open: false, -}; - -describe('buildBranchCreateData', () => { - test('the rail is the plan, not a second array built for the browser', () => { - const d = buildBranchCreateData(CREATE, 'n'); - expect(d.stops).toEqual(branchCreatePlan({ branchName: 'release' })); - expect(d.stops.map((s) => s.id)).toEqual(['name', 'protection', 'create']); - }); - - test('a name from argv arrives answered, so the run stands on protection', () => { - const d = buildBranchCreateData(CREATE, 'n'); - expect(d.view).toBe('protection'); - expect(d.stops[0]).toMatchObject({ state: 'done', answer: 'release', flag: 'argument' }); - expect(d.stops[1].state).toBe('current'); - }); - - test('a name that is only whitespace is no name, and the run asks for one', () => { - const d = buildBranchCreateData({ ...CREATE, branchName: ' ' }, 'n'); - expect(d.view).toBe('name'); - expect(d.name).toBe(''); - expect(d.stops[0].state).toBe('current'); - }); - - test('a flag-answered protection stop is marked, and nothing is left to ask', () => { - const d = buildBranchCreateData({ ...CREATE, isProtected: true }, 'n'); - expect(d.isProtected).toBe(true); - expect(d.stops[1]).toMatchObject({ state: 'done', answer: 'protected', flag: '--protected' }); - // No stop is `skipped`: a flag resolved this one, it did not fall away. - expect(d.stops.some((s) => s.state === 'skipped')).toBe(false); - }); - - test('the seed preview is variable names and nothing else', () => { - const d = buildBranchCreateData( - { ...CREATE, seedVarNames: ['STRIPE_SECRET_KEY', 'DATABASE_URL'] }, - 'n', - ); - const json = JSON.stringify(d); - // The values behind those names, had they been read, look like this. - expect(json).not.toContain('sk_live'); - expect(json).not.toContain('postgres://'); - expect(d.seedVarNames).toEqual(['STRIPE_SECRET_KEY', 'DATABASE_URL']); - }); - - test('an unreadable .env is said out loud rather than seeded as empty', () => { - const d = buildBranchCreateData( - { ...CREATE, seedVarNames: [], seedUnreadable: true }, - 'n', - ); - expect(d.seedUnreadable).toBe(true); - // Distinct from a directory whose .env simply has nothing in it. - expect(buildBranchCreateData({ ...CREATE, seedVarNames: [] }, 'n').seedUnreadable).toBeUndefined(); - }); - - test('both questions say how they are answered without a browser', () => { - const d = buildBranchCreateData(CREATE, 'n'); - expect(d.nonTty?.name?.command).toBe('capy checkout -b release'); - // `--no-protected` exists and commander binds it to false, so an - // unprotected branch IS reachable headlessly. The escape has to say the - // flag that works, not the one that used to. - expect(d.nonTty?.protection?.command).toBe('capy checkout -b release --no-protected'); - expect(d.nonTty?.protection?.why).toContain('--protected'); - }); - - test('no terminal escape reaches the payload', () => { - const d = buildBranchCreateData({ ...CREATE, projectName: '\x1b[1mmikes-market\x1b[0m' }, 'n'); - expect(d.projectName).toBe('mikes-market'); - expect(JSON.stringify(d)).not.toContain('\x1b'); - }); -}); - -describe('branchNameProblem', () => { - test('names the CLI and the screen both refuse', () => { - expect(branchNameProblem('', EXISTING)).toBe('Branch name cannot be empty'); - expect(branchNameProblem(' ', EXISTING)).toBe('Branch name cannot be empty'); - expect(branchNameProblem('my branch', EXISTING)).toBe( - 'Branch names cannot contain spaces or line breaks', - ); - // Commander reads a leading hyphen as an option, so `-wip` could never be - // checked out from a command line again. - expect(branchNameProblem('-wip', EXISTING)).toBe('Branch names cannot start with a hyphen'); - }); - - test('a taken name says what kind of branch took it', () => { - expect(branchNameProblem('development', EXISTING)).toBe( - 'development already exists in this project', - ); - expect(branchNameProblem('production', EXISTING)).toBe( - 'production already exists in this project, as a protected branch', - ); - }); - - test('a usable name has no problem', () => { - expect(branchNameProblem('release', EXISTING)).toBeUndefined(); - expect(branchNameProblem(' release ', EXISTING)).toBeUndefined(); - }); -}); - -describe('createBranchInBrowser', () => { - test('the protection answer comes back and the page is the compiled screen', async () => { - let url = ''; - const done = createBranchInBrowser({ ...CREATE, onListen: (u) => (url = u) }); - - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - const page = await (await fetch(u.href)).text(); - expect(page).toContain('window.__CAPY_DATA__'); - expect(page).toContain('mikes-market'); - // Served whole: no wizard shell wrapped around it. - expect(page).not.toContain('id="screen"'); - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'submit', isProtected: true } }), - }); - - expect(await done).toEqual({ name: 'release', isProtected: true, cancelled: false }); - }); - - test('an unnamed run asks both questions, in the order the plan declared', async () => { - let url = ''; - const done = createBranchInBrowser({ ...CREATE, branchName: ' ', onListen: (u) => (url = u) }); - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - const first = await (await fetch(u.href)).text(); - expect(first).toContain('Name the branch'); - - // Answering the name does not finish the run: it advances, and the browser - // is told to come back for the next whole document. - const advanced = await ( - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'submit', name: 'release' } }), - }) - ).json(); - expect(advanced).toEqual({ next: true }); - - // The same URL now serves the SECOND stop, with the first one settled. - const second = await (await fetch(u.href)).text(); - expect(second).toContain('Protect this branch?'); - expect(second).toContain('release'); - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'submit', isProtected: false } }), - }); - expect(await done).toEqual({ name: 'release', isProtected: false, cancelled: false }); - }); - - test('a name the screen holds its button on is refused, not posted', async () => { - // The screen already knows `development` is taken — it holds the branch - // list — so this can only arrive from something that is not that screen. - // Creating it anyway means the server's prose, after a round trip. - let url = ''; - let resolved = false; - const done = createBranchInBrowser({ ...CREATE, branchName: '', onListen: (u) => (url = u) }); - void done.then(() => (resolved = true)); - - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - const res = await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'submit', name: 'development' } }), - }); - // Inline refusal: 200 with an error keeps the user on the step. - expect(res.status).toBe(200); - expect((await res.json()).error).toBe('development already exists in this project'); - await new Promise((r) => setTimeout(r, 20)); - expect(resolved).toBe(false); - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'cancel' } }), - }); - expect((await done).cancelled).toBe(true); - }); - - test('a protection answer that is not a boolean is refused', async () => { - // Guessing here would decide who can reach this branch, which is the one - // security setting the flow exists to set. - let url = ''; - const done = createBranchInBrowser({ ...CREATE, onListen: (u) => (url = u) }); - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - const res = await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'submit', isProtected: 'protected' } }), - }); - expect((await res.json()).error).toContain('not an answer'); - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'submit', isProtected: false } }), - }); - expect(await done).toEqual({ name: 'release', isProtected: false, cancelled: false }); - }); - - test('cancelling creates nothing and says so', async () => { - let url = ''; - const done = createBranchInBrowser({ ...CREATE, onListen: (u) => (url = u) }); - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'cancel' } }), - }); - - expect(await done).toEqual({ name: 'release', isProtected: false, cancelled: true }); - }); -}); - -describe('buildBranchListData', () => { - test('protection is read off is_protected, never off the name', () => { - const d = buildBranchListData(LIST, 'n'); - expect(d.branches.find((b) => b.name === 'production')!.isProtected).toBe(false); - expect(d.branches.find((b) => b.name === 'spike')!.isProtected).toBe(true); - }); - - test('the current row is the one this directory is on', () => { - const d = buildBranchListData(LIST, 'n'); - expect(d.branches.filter((b) => b.isCurrent).map((b) => b.name)).toEqual(['development']); - // Null means the CLI could not tell, so no row may claim to be current. - const unknown = buildBranchListData({ ...LIST, activeBranch: null }, 'n'); - expect(unknown.branches.some((b) => b.isCurrent)).toBe(false); - }); - - test('a protected branch is still offered, because the grant is not knowable here', () => { - // The server's branch list carries no per-caller grant. `false` would read - // as knowledge this CLI does not have and would lock out a member who - // holds one — the terminal picker offers every branch and lets the 403 - // say the accurate thing. - const d = buildBranchListData(LIST, 'n'); - expect(d.branches.every((b) => b.canSwitch)).toBe(true); - }); - - test('counts come from keep.lock and are omitted where it has none', () => { - const d = buildBranchListData({ ...LIST, variableCounts: { development: 14 } }, 'n'); - expect(d.branches.find((b) => b.name === 'development')!.variableCount).toBe(14); - // Absent rather than zero: "no count" and "no variables" are different claims. - expect(d.branches.find((b) => b.name === 'spike')!.variableCount).toBeUndefined(); - }); - - test('age is the CLI\'s own wording, and absent without a timestamp', () => { - const d = buildBranchListData({ ...LIST, now: new Date('2026-07-30T12:00:00.000Z') }, 'n'); - expect(d.branches.find((b) => b.name === 'development')!.age).toBe('created 3 hours ago'); - expect(d.branches.find((b) => b.name === 'production')!.age).toBeUndefined(); - }); - - test('a checkout may not delete, and says how to switch without a browser', () => { - const d = buildBranchListData(LIST, 'n'); - expect(d.canDelete).toBe(false); - expect(d.nonTty?.command).toBe('capy checkout '); - // No drift count is claimed: checkout refuses to run with a dirty tree, so - // by the time this is served there is nothing to count. - expect(d.localChanges).toBeUndefined(); - }); - - test('no terminal escape reaches the payload', () => { - const d = buildBranchListData({ ...LIST, projectName: '\x1b[1mmikes-market\x1b[0m' }, 'n'); - expect(d.projectName).toBe('mikes-market'); - expect(JSON.stringify(d)).not.toContain('\x1b'); - }); -}); - -describe('chooseBranchInBrowser', () => { - test('picking a row hands the checkout a branch that exists', async () => { - let url = ''; - const done = chooseBranchInBrowser({ ...LIST, onListen: (u) => (url = u) }); - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - const page = await (await fetch(u.href)).text(); - expect(page).toContain('window.__CAPY_DATA__'); - expect(page).not.toContain('id="screen"'); - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'switch', branch: 'spike' } }), - }); - - expect(await done).toEqual({ branch: 'spike', cancelled: false }); - }); - - test('a name the screen never offered is refused', async () => { - // The answer is fed straight into a real checkout. A branch not in the - // list the server sent did not come from the list the screen drew. - let url = ''; - const done = chooseBranchInBrowser({ ...LIST, onListen: (u) => (url = u) }); - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - const res = await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'switch', branch: 'not-a-branch' } }), - }); - expect((await res.json()).error).toContain('not in this project'); - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'switch', branch: 'spike' } }), - }); - expect((await done).branch).toBe('spike'); - }); - - test('deleting is refused: this run drew no delete control', async () => { - // `capy checkout` has never deleted anything, so the screen renders no - // such button. Acting on one would remove every secret on a branch off the - // back of a submit the page could not have produced. - let url = ''; - const done = chooseBranchInBrowser({ ...LIST, onListen: (u) => (url = u) }); - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - const res = await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'delete', branch: 'spike' } }), - }); - expect((await res.json()).error).toContain('not part of capy checkout'); - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'cancel' } }), - }); - expect((await done).cancelled).toBe(true); - }); - - test('switching to the branch this directory is already on is refused', async () => { - let url = ''; - const done = chooseBranchInBrowser({ ...LIST, onListen: (u) => (url = u) }); - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - const res = await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'switch', branch: 'development' } }), - }); - expect((await res.json()).error).toContain('already on that branch'); - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'cancel' } }), - }); - await done; - }); - - test('cancelling switches nothing', async () => { - let url = ''; - const done = chooseBranchInBrowser({ ...LIST, onListen: (u) => (url = u) }); - const u = new URL(await waitForUrl(() => url)); - const nonce = u.searchParams.get('n') ?? ''; - - await fetch(`http://127.0.0.1:${u.port}/submit`, { - method: 'POST', - headers, - body: JSON.stringify({ nonce, payload: { __action: 'cancel' } }), - }); - - expect(await done).toEqual({ branch: '', cancelled: true }); - }); -}); diff --git a/tests/ui/browserFlow.e2e.test.ts b/tests/ui/browserFlow.e2e.test.ts deleted file mode 100644 index f58d0ed7..00000000 --- a/tests/ui/browserFlow.e2e.test.ts +++ /dev/null @@ -1,5552 +0,0 @@ -/** - * The browser end-to-end tests: a real headless browser loads the page the CLI - * serves, clicks the controls a person would click, and the CLI's own reducer - * says what it received. - * - * WHY THIS EXISTS. Every other test here drives the loopback server with fetch, - * which proves the transport and proves nothing about the page. That gap is not - * theoretical — the screens shipped a "Continue in terminal" button wired to a - * hand-back no branch of this CLI ever implemented, a control key the server - * does not read, and an answer shape the reducer could not parse. Each was - * carefully reasoned about, and each would have died on first contact with a - * click. - * - * These tests emulate the MCP flow, which is the only flow: `--web` is agent - * -only, so a headless browser driving the page IS the real usage, not a - * simulation of it. - * - * Skipped when no Chrome for Testing shell is cached, so a standalone clone - * still runs its suite. Install one with: - * bunx @puppeteer/browsers install chrome-headless-shell@stable - */ -import { describe, test, expect, afterEach, afterAll, beforeAll } from 'bun:test'; -import { spawn, type ChildProcess } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { Browser, findHeadlessShell, type CdpSession } from '../helpers/cdp'; -import { runBrowserWizard } from '../../src/ui/browserWizard'; - -const HAS_BROWSER = findHeadlessShell() !== null; -const describeBrowser = HAS_BROWSER ? describe : describe.skip; - -if (!HAS_BROWSER) { - console.warn( - 'browserFlow.e2e: no cached chrome-headless-shell — skipping. ' + - 'Install: bunx @puppeteer/browsers install chrome-headless-shell@stable', - ); -} - -async function waitForUrl(getUrl: () => string): Promise { - for (let i = 0; i < 400 && !getUrl(); i++) await new Promise((r) => setTimeout(r, 10)); - return getUrl(); -} -/** - * Wait for a condition in THIS process (a promise settling, a callback firing). - * - * `until` polls the page; this polls the CLI, which is what most of the - * endings below are about — a flow that ends by nobody clicking anything has - * nothing on screen left to assert against. - */ -async function untilHere(ok: () => boolean, what: string, budgetMs = 10_000): Promise { - for (let i = 0; i < budgetMs / 25; i++) { - if (ok()) return; - await new Promise((r) => setTimeout(r, 25)); - } - throw new Error(`timed out waiting for ${what}`); -} - - - -/** Evaluate an expression in the page and return its JSON value. */ -async function evaluate(page: CdpSession, expression: string): Promise { - const res = (await page.send('Runtime.evaluate', { - expression, - awaitPromise: true, - returnByValue: true, - })) as { result?: { value?: T }; exceptionDetails?: { text?: string } }; - if (res.exceptionDetails) throw new Error(res.exceptionDetails.text ?? 'page threw'); - return res.result?.value as T; -} - -/** - * Poll a predicate in the page until it holds, or fail loudly. - * - * A poll that lands while the page is navigating comes back from CDP as - * "Inspected target navigated or closed". That is not a failed predicate — it - * is the page doing the exact thing the caller is waiting for it to do, since - * every standalone step advances by RELOADING — so it is swallowed and the - * next tick asks again. - * - * The exception is REMEMBERED rather than dropped, and reported with the - * timeout. This is the shared harness every flow appends to: a predicate with - * a typo in it throws on every tick, and a bare `catch {}` turned that into - * five seconds of silence and "timed out waiting for X" — the one message that - * sends the reader looking at the product instead of at the test. It is not - * MATCHED on: the navigation race and a broken predicate arrive as the same - * CDP error with nothing structured to tell them apart, and keying test - * control flow off the wording of a Chrome error message would be a harness - * that breaks when Chrome rewords it. - */ -async function until(page: CdpSession, expression: string, what: string): Promise { - let last: Error | undefined; - for (let i = 0; i < 200; i++) { - try { - if (await evaluate(page, `!!(${expression})`)) return; - last = undefined; - } catch (err) { - last = err as Error; - } - await new Promise((r) => setTimeout(r, 25)); - } - throw new Error( - `timed out waiting for ${what}${last ? ` (last page error: ${last.message})` : ''}`, - ); -} - -/** The members parcel's name for `until`. One helper, not two that can drift. */ -const untilSettled = until; - -/** Click as soon as the control exists, rather than assuming it already does. */ -async function clickWhenReady(page: CdpSession, finder: string, what: string): Promise { - let last = ''; - for (let i = 0; i < 300; i++) { - try { - const hit = await evaluate( - page, - `(() => { const el = ${finder}; if (!el) return false; el.click(); return true; })()`, - ); - if (hit) return; - } catch (err) { - last = err instanceof Error ? err.message : String(err); - } - await new Promise((r) => setTimeout(r, 25)); - } - throw new Error(`timed out clicking ${what}${last ? ` (last: ${last})` : ''}`); -} - -const CLI_ROOT = resolve(import.meta.dir, '../..'); - -/** An absolute import specifier for a CLI module, for use inside a child snippet. */ -const imp = (rel: string): string => JSON.stringify(join(CLI_ROOT, rel)); - -/** - * Either loopback address a `--web` run prints: an ending page served by - * `ScreenServer` (`/s/`), or a wizard step (`/?n=`). - */ -const PRINTED_URL = /http:\/\/127\.0\.0\.1:\d+\/(?:s\/[A-Za-z0-9_-]+|\?n=[a-f0-9]+)/; - -export interface CliChild { - /** The first loopback URL the child printed. */ - url: Promise; - exit: Promise; - output: () => string; - dispose: () => void; -} - -/** - * Run one snippet in a child `bun`, and hand back its URL and its EXIT CODE. - * - * Two facts about a `--web` run can only be observed from outside the process - * that has them: whether a page outlives the exit that follows it, and what - * that exit code is. Both matter — an ending served into a socket that closes - * microseconds later is a page nobody sees, and a refusal that exits 1 with a - * stack trace is a decision rendered as a crash. - * - * One helper for both, at module scope, because a second copy is a second set - * of assumptions about the URL shape, the environment and the teardown. - */ -function spawnCliChild(source: string): CliChild { - const scratch = mkdtempSync(join(tmpdir(), 'capy-child-')); - const file = join(scratch, 'run.ts'); - writeFileSync(file, source); - // NEVER without CAPY_WEB_NO_OPEN: the child calls the same helpers the CLI - // calls, and those helpers open a browser. - const child = spawn('bun', [file], { - cwd: CLI_ROOT, - env: { ...process.env, CAPY_WEB_NO_OPEN: '1' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let out = ''; - child.stdout?.on('data', (c: Buffer) => (out += String(c))); - child.stderr?.on('data', (c: Buffer) => (out += String(c))); - const exit = new Promise((res) => child.on('exit', (code) => res(code ?? -1))); - const url = (async () => { - for (let i = 0; i < 800; i++) { - const m = out.match(PRINTED_URL); - if (m) return m[0]; - await new Promise((r) => setTimeout(r, 25)); - } - throw new Error(`the child never printed a URL. Output:\n${out}`); - })(); - return { - url, - exit, - output: () => out, - dispose: () => { - child.kill('SIGKILL'); - rmSync(scratch, { recursive: true, force: true }); - }, - }; -} - -/** Navigate and wait until the page is actually running, not merely loaded. */ -async function navigate(page: CdpSession, url: string): Promise { - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - await untilSettled(page, `document.body && document.body.innerHTML.length > 0`, 'the page to be running'); -} - -describeBrowser('browser flow, driven by a real browser', () => { - let browser: Browser | null = null; - let profile = ''; - - afterEach(() => { - browser?.close(); - browser = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - const page = await browser.newPage(1280, 820); - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - return page; - } - - test('typing in the form and clicking submit reaches the reducer', async () => { - let url = ''; - const seen: Array> = []; - const done = runBrowserWizard( - { - title: 'Name your branch', - firstScreen: { - html: '
', - }, - open: false, - onListen: (u) => (url = u), - }, - async (_step, payload) => { - seen.push(payload); - return { done: true, result: payload }; - }, - ); - - const page = await open(await waitForUrl(() => url)); - - // Type like a person: set the field, then click the button by its text. - await evaluate(page, `document.querySelector('input[name=branch]').value = 'feature/checkout'`); - await evaluate( - page, - `[...document.querySelectorAll('button')].find(b => b.textContent.includes('Create the branch')).click()`, - ); - - expect(await done).toEqual({ branch: 'feature/checkout' }); - expect(seen).toHaveLength(1); - expect(seen[0].branch).toBe('feature/checkout'); - }, 60_000); - - test('a screen posting structured JSON reaches the reducer intact', async () => { - // The path the compiled screens use. A flat form cannot express this. - let url = ''; - let received: Record | undefined; - const done = runBrowserWizard( - { - title: 'Resolve', - firstScreen: { html: '' }, - open: false, - onListen: (u) => (url = u), - }, - async (_step, payload) => { - received = payload; - return { done: true, result: payload }; - }, - ); - - const page = await open(await waitForUrl(() => url)); - await evaluate( - page, - `document.getElementById('go').addEventListener('click', () => window.capySubmit({ - resolutions: [{ key: 'STRIPE_KEY', take: 'remote' }, { key: 'DB_URL', take: 'local' }], - applyToAll: true, - count: 2, - })); document.getElementById('go').click()`, - ); - - await done; - expect(received).toEqual({ - resolutions: [ - { key: 'STRIPE_KEY', take: 'remote' }, - { key: 'DB_URL', take: 'local' }, - ], - applyToAll: true, - count: 2, - }); - }, 60_000); - - test('an inline refusal keeps the user on the step, with the reason on screen', async () => { - // The CLI answers a refusal with HTTP 200 and an `error` field. A page that - // read only the status would advance past a step the CLI never took. - let url = ''; - let attempts = 0; - const done = runBrowserWizard( - { - title: 'Name it', - firstScreen: { - html: '
', - }, - open: false, - onListen: (u) => (url = u), - }, - async (_step, payload) => { - attempts += 1; - if (payload.name === 'taken') return { error: 'That name is already in use.' }; - return { done: true, result: payload }; - }, - ); - - const page = await open(await waitForUrl(() => url)); - const click = `[...document.querySelectorAll('button')].find(b => b.textContent.includes('Use this name')).click()`; - - await evaluate(page, click); - await until(page, `document.getElementById('status').textContent.includes('already in use')`, 'the refusal to show'); - - // Still the same step: the form is there and the button is live again. - expect(await evaluate(page, `!!document.querySelector('input[name=name]')`)).toBe(true); - expect(await evaluate(page, `!document.querySelector('button').disabled`)).toBe(true); - - // Correcting it now finishes the flow. - await evaluate(page, `document.querySelector('input[name=name]').value = 'free'`); - await evaluate(page, click); - expect(await done).toEqual({ name: 'free' }); - expect(attempts).toBe(2); - }, 60_000); - - test('a standalone step is served whole and the page advances by reloading', async () => { - // The compiled-screen path end to end: no shell around the document, and - // the browser fetches the next step rather than being handed its markup. - let url = ''; - const doc = (body: string): string => - `${body}`; - - const done = runBrowserWizard( - { - title: 'Compiled', - firstScreen: { html: doc('

STEP ONE

'), standalone: true }, - open: false, - onListen: (u) => (url = u), - }, - async (step) => - step === 0 - ? { screen: { html: doc('

STEP TWO

'), standalone: true } } - : { done: true, result: { finished: true } }, - ); - - const page = await open(await waitForUrl(() => url)); - - // Served as itself — the wizard shell is not wrapped around it. - expect(await evaluate(page, `document.getElementById('h').textContent`)).toBe('STEP ONE'); - expect(await evaluate(page, `!document.getElementById('screen')`)).toBe(true); - - // Answering reloads, and the same URL yields the NEXT step. - // - // Started rather than awaited: `evaluate` asks CDP to resolve the returned - // promise, and this one resolves by NAVIGATING. Waiting on it is a race - // between the response and the reload, and when the reload wins CDP answers - // "Inspected target navigated or closed" — a failure about the test's own - // instrumentation, on the one path whose whole subject is that the page - // navigates. `until` below is what actually waits for the next step. - await evaluate(page, `window.answer(); undefined`); - await until(page, `document.getElementById('h') && document.getElementById('h').textContent === 'STEP TWO'`, 'step two'); - - await evaluate(page, `window.answer(); undefined`); - expect(await done).toEqual({ finished: true }); - }, 60_000); - - test('closing the window is refusal: the run never resolves on its own', async () => { - // Contract: an unanswered step has not been approved. Nothing the browser - // does by leaving may look like consent. - let url = ''; - let resolved = false; - let reducerCalled = false; - const done = runBrowserWizard( - { - title: 'Encrypt and push', - firstScreen: { html: '
', doneMessage: 'x' }, - open: false, - onListen: (u) => (url = u), - timeoutMs: 1_500, - }, - async () => { - reducerCalled = true; - return { done: true, result: { encrypted: true } }; - }, - ); - void done.then(() => (resolved = true)).catch(() => undefined); - - const page = await open(await waitForUrl(() => url)); - // Leave without answering. - await page.send('Page.navigate', { url: 'about:blank' }); - - await new Promise((r) => setTimeout(r, 400)); - expect(reducerCalled).toBe(false); - expect(resolved).toBe(false); - - // The wizard ends by timing out, not by treating the close as an answer. - await done.catch(() => undefined); - expect(reducerCalled).toBe(false); - }, 60_000); -}); - -describeBrowser('the sync conflict resolver, driven by a real browser', () => { - let browser: Browser | null = null; - let profile = ''; - - afterEach(() => { - browser?.close(); - browser = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - const page = await browser.newPage(1280, 820); - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - return page; - } - - const PARAMS = { - rows: [ - { variable: 'STRIPE_KEY', pinned: 'sk_…001', local: 'sk_…002', remote: 'sk_…003' }, - { variable: 'DB_URL', pinned: 'pos…dev', local: 'pos…loc', remote: 'pos…rem' }, - ], - unresolvable: new Set(), - showLocal: true, - showRemote: true, - localMode: false, - isOnboarding: false, - isBehind: false, - remoteState: 'ok' as const, - actions: [ - { value: 'commit_local' as const, label: 'Commit and push all local values' }, - { value: 'retrieve_pinned' as const, label: 'Retrieve all pinned values' }, - { value: 'individual' as const, label: 'Individually resolve' }, - { value: 'skip' as const, label: 'Continue working' }, - ], - projectName: 'mikes-market', - branch: 'development', - open: false, - }; - - test('a whole-run action can be chosen and applied by clicking', async () => { - // The level the old browser resolver did not have. It discarded the CLI's - // menu and hard-coded per-variable resolution, so "take theirs" meant - // answering once per variable. - const { resolveConflictInBrowser } = await import('../../src/ui/syncConflictScreen'); - let url = ''; - const done = resolveConflictInBrowser({ ...PARAMS, onListen: (u) => (url = u) }); - - const page = await open(await waitForUrl(() => url)); - - // The review stop renders the diff the CLI computed. - expect(await evaluate(page, `document.body.textContent.includes('STRIPE_KEY')`)).toBe(true); - // Snippets only — no full secret reaches the page. - expect(await evaluate(page, `document.body.textContent.includes('sk_live')`)).toBe(false); - - await evaluate(page, `document.querySelector('[data-test=to-choose]').click()`); - await until(page, `document.querySelector('[data-test=apply-action]')`, 'the action menu'); - - // Pick the CLI's own wording, the way a person would. - await evaluate( - page, - `[...document.querySelectorAll('[role=radio],[role=option],li,label')] - .find(el => el.textContent.trim().startsWith('Retrieve all pinned values')).click()`, - ); - await evaluate(page, `document.querySelector('[data-test=apply-action]').click()`); - - expect(await done).toEqual({ action: 'retrieve_pinned', choices: {}, cancelled: false }); - }, 60_000); - - test('closing the window resolves nothing', async () => { - // An unanswered conflict is a refusal. Nothing about leaving may look like - // agreement to rewrite somebody's .env. - const { resolveConflictInBrowser } = await import('../../src/ui/syncConflictScreen'); - let url = ''; - let settled = false; - const done = resolveConflictInBrowser({ ...PARAMS, timeoutMs: 1_500, onListen: (u) => (url = u) }); - void done.then(() => (settled = true)).catch(() => undefined); - - const page = await open(await waitForUrl(() => url)); - await page.send('Page.navigate', { url: 'about:blank' }); - - await new Promise((r) => setTimeout(r, 400)); - expect(settled).toBe(false); - await done.catch(() => undefined); - }, 60_000); -}); - -describeBrowser('capy checkout, driven by a real browser', () => { - let browser: Browser | null = null; - let profile = ''; - - afterEach(() => { - browser?.close(); - browser = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - const page = await browser.newPage(1280, 820); - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - return page; - } - - /** - * Type into a field the way a person does. - * - * Assigning `.value` alone is invisible to the screen: `bind:value` listens - * for `input`, so a test that only sets the property proves the CLI can read - * a field nobody could have filled in. - */ - const type = (selector: string, value: string): string => - `(() => { const el = document.querySelector(${JSON.stringify(selector)}); - el.value = ${JSON.stringify(value)}; - el.dispatchEvent(new Event('input', { bubbles: true })); })()`; - - /** Click the button whose visible text contains `text`. */ - const clickButton = (text: string): string => - `[...document.querySelectorAll('button')] - .find(b => b.textContent.includes(${JSON.stringify(text)})).click()`; - - /** - * Click the option row whose label is exactly `label`. - * - * Exact, on the label element rather than the row: `protected` is a suffix - * of `unprotected`, and this flow's whole subject is which of those two it - * ends up being. - */ - const clickOption = (label: string): string => - `[...document.querySelectorAll('[role=radio]')] - .find(el => el.querySelector('.label').textContent.trim() === ${JSON.stringify(label)}).click()`; - - const EXISTING = [ - { name: 'development', isProtected: false }, - { name: 'production', isProtected: true }, - ]; - - const CREATE = { - projectName: 'mikes-market', - existingBranches: EXISTING, - seedFrom: 'development', - seedVarNames: ['DATABASE_URL', 'STRIPE_SECRET_KEY'], - open: false, - }; - - test('both stops of the create route are walked by clicking', async () => { - // The whole point of the standalone path: answering stop one is a page - // RELOAD, not a markup swap, and the same URL then serves stop two. Three - // inventions this session died on exactly this. - const { createBranchInBrowser } = await import('../../src/ui/branchScreens'); - let url = ''; - // No name in argv, so the plan leaves both stops outstanding. - const done = createBranchInBrowser({ ...CREATE, branchName: '', onListen: (u) => (url = u) }); - - const page = await open(await waitForUrl(() => url)); - - // The rail is the CLI's plan, drawn whole before anything was answered — - // including the stop this page is not standing on. - expect(await evaluate(page, `document.body.textContent.includes('Protection')`)).toBe(true); - - await evaluate(page, type('input[type=text], .field input', 'release')); - await evaluate(page, clickButton('Use this name')); - - // Stop two arrives by navigation, at the same address. - await until(page, `document.body.textContent.includes('Protect this branch?')`, 'the protection stop'); - // …with stop one now settled on the rail. - expect(await evaluate(page, `document.body.textContent.includes('release')`)).toBe(true); - // The commit point says what comes across: variable NAMES, never a value. - // `-b` skips both dirty guards, so this is the only warning there is. - expect(await evaluate(page, `document.body.textContent.includes('STRIPE_SECRET_KEY')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('sk_live')`)).toBe(false); - - await evaluate(page, clickOption('protected')); - await evaluate(page, clickButton('Create branch')); - - expect(await done).toEqual({ name: 'release', isProtected: true, cancelled: false }); - }, 60_000); - - test('a name the branch list already holds never reaches the button', async () => { - // The terminal validates nothing: the name is a positional that goes - // straight to POST /branches, so "already taken" is the server's prose - // after a round trip. The screen holds the list, so it answers while the - // user is still typing — and this is the check that it is wired up. - const { createBranchInBrowser } = await import('../../src/ui/branchScreens'); - let url = ''; - let settled = false; - const done = createBranchInBrowser({ - ...CREATE, - branchName: '', - timeoutMs: 4_000, - onListen: (u) => (url = u), - }); - void done.then(() => (settled = true)).catch(() => undefined); - - const page = await open(await waitForUrl(() => url)); - - await evaluate(page, type('input[type=text], .field input', 'production')); - await until( - page, - `document.body.textContent.includes('already exists in this project')`, - 'the collision to be named', - ); - // It says WHICH kind of branch took it, which the server's error does not. - expect( - await evaluate(page, `document.body.textContent.includes('as a protected branch')`), - ).toBe(true); - expect( - await evaluate(page, `document.querySelector('button[type=submit]').disabled`), - ).toBe(true); - - // Nothing was sent, so nothing was created. - expect(settled).toBe(false); - await done.catch(() => undefined); - }, 60_000); - - test('a wrong branch name becomes a picker instead of an exit code', async () => { - // `capy checkout ` prints the list and exits 1: the branch the user - // meant is on the screen and unreachable. Here the listing IS the answer. - const { chooseBranchInBrowser } = await import('../../src/ui/branchScreens'); - let url = ''; - const done = chooseBranchInBrowser({ - projectName: 'mikes-market', - activeBranch: 'development', - branches: [ - { id: 'b1', name: 'development', project_id: 'p1', is_protected: false }, - { id: 'b2', name: 'staging', project_id: 'p1', is_protected: false }, - { id: 'b3', name: 'spike', project_id: 'p1', is_protected: true }, - ], - variableCounts: { development: 14 }, - canDelete: false, - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - // Protection is drawn from `is_protected`, so the invite-only branch is - // marked here — the terminal picker marks nothing and lets a 403 explain. - expect(await evaluate(page, `document.body.textContent.includes('protected')`)).toBe(true); - // A run that cannot delete draws no delete control. - expect( - await evaluate( - page, - `![...document.querySelectorAll('button')].some(b => b.textContent.includes('Delete'))`, - ), - ).toBe(true); - - await evaluate(page, clickOption('staging')); - await evaluate(page, clickButton('Switch branch')); - - expect(await done).toEqual({ branch: 'staging', cancelled: false }); - }, 60_000); - - test('the branch this directory is already on cannot be picked', async () => { - // The list opens on the current row, so the button starts held down: - // "switch to where you already are" is not a thing a checkout can do. - const { chooseBranchInBrowser } = await import('../../src/ui/branchScreens'); - let url = ''; - const done = chooseBranchInBrowser({ - projectName: 'mikes-market', - activeBranch: 'development', - branches: [ - { id: 'b1', name: 'development', project_id: 'p1', is_protected: false }, - { id: 'b2', name: 'staging', project_id: 'p1', is_protected: false }, - ], - canDelete: false, - open: false, - timeoutMs: 4_000, - onListen: (u) => (url = u), - }); - void done.catch(() => undefined); - - const page = await open(await waitForUrl(() => url)); - - expect( - await evaluate( - page, - `[...document.querySelectorAll('button')].find(b => b.textContent.includes('Switch branch')).disabled`, - ), - ).toBe(true); - - // Choosing another row releases it. - await evaluate(page, clickOption('staging')); - await until( - page, - `![...document.querySelectorAll('button')].find(b => b.textContent.includes('Switch branch')).disabled`, - 'the switch button to go live', - ); - - await done.catch(() => undefined); - }, 60_000); -}); - -describeBrowser('capy connect, driven by a real browser', () => { - let browser: Browser | null = null; - let profile = ''; - - afterEach(() => { - browser?.close(); - browser = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - const page = await browser.newPage(1280, 820); - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - return page; - } - - /** Click the button whose visible text contains `text`. */ - const clickButton = (text: string): string => - `[...document.querySelectorAll('button')] - .find(b => b.textContent.includes(${JSON.stringify(text)})).click()`; - - /** - * Click the option row whose label is exactly `label`. - * - * Exact, on the label element rather than the row: `test` is a substring of - * half the names in this flow, and the mode question's two answers are the - * difference between a sandbox and real money. - */ - const clickOption = (label: string): string => - `[...document.querySelectorAll('[role=radio]')] - .find(el => el.querySelector('.label').textContent.trim() === ${JSON.stringify(label)}).click()`; - - /** - * Type into a field the way a person does. - * - * Assigning `.value` alone is invisible to the screen: `bind:value` listens - * for `input`, so a test that only sets the property proves the CLI can read - * a field nobody could have filled in — which is precisely the thing the - * live gate exists to make impossible. - */ - const type = (selector: string, value: string): string => - `(() => { const el = document.querySelector(${JSON.stringify(selector)}); - el.value = ${JSON.stringify(value)}; - el.dispatchEvent(new Event('input', { bubbles: true })); })()`; - - const PLAN = { - provider: 'stripe', - branch: 'development', - requiresTool: 'stripe', - requiresAuth: true, - push: true, - }; - - test('the variable, mode and overwrite stops are walked by clicking', async () => { - // Three questions, TWO different compiled screens, one server. The whole - // point of the standalone path: answering a stop is a page RELOAD, not a - // markup swap, and the same URL then serves the next question — including - // when the next question is a different document entirely. - const { askConnectInBrowser } = await import('../../src/ui/connectScreens'); - const { currentVarState } = await import('../../src/commands/connectors/stripe'); - - // A real-looking key in .env, reduced by the describer the CLI uses. - const ctx = { - keep: { version: '3.0', org_id: 'o', project_id: 'p', project_name: 'mikes-market', variables: {} }, - branch: 'development', - localPlaintext: { STRIPE_SECRET_KEY: 'rk_live_51HabcdefgHIJKLMNOPqrs' }, - } as never; - - let url = ''; - const done = askConnectInBrowser({ - provider: 'stripe', - projectName: 'mikes-market', - branch: 'development', - plan: PLAN, - questions: [ - { - kind: 'var', - vars: [ - { name: 'STRIPE_SECRET_KEY', looksRelated: true, hasValue: true }, - { name: 'DATABASE_URL', looksRelated: false, hasValue: true }, - ], - defaultVarName: 'STRIPE_SECRET_KEY', - }, - { - kind: 'mode', - modes: [ - { id: 'test', available: true, keyPrefix: 'rk_test_' }, - { id: 'live', available: true, keyPrefix: 'rk_live_' }, - ], - }, - { - kind: 'overwrite', - varName: 'STRIPE_SECRET_KEY', - current: currentVarState(ctx, 'STRIPE_SECRET_KEY'), - incoming: { keyPrefix: 'rk_test_', mode: 'test', fingerprint: 'rk_…xyz' }, - }, - ], - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - // The rail is the CLI's plan, drawn whole before anything was answered — - // including the stops this page is not standing on. - expect(await evaluate(page, `document.body.textContent.includes('Sign in')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('Account')`)).toBe(true); - // And NOT `Refresh key`. That stop re-paired with Stripe to freshen a - // nearly-expired key, which only made sense while connect wrote the key - // into `.env`. A rail that still drew it would be promising a step the - // command no longer travels. - expect(await evaluate(page, `document.body.textContent.includes('Refresh key')`)).toBe(false); - - await evaluate(page, clickOption('STRIPE_SECRET_KEY')); - await evaluate(page, clickButton('Use this variable')); - - // Stop two arrives by navigation, at the same address. - await until(page, `document.body.textContent.includes('Test or live')`, 'the mode stop'); - // …with stop one now settled on the rail. - expect(await evaluate(page, `document.body.textContent.includes('STRIPE_SECRET_KEY')`)).toBe(true); - - await evaluate(page, clickOption('test')); - await evaluate(page, clickButton('Continue')); - - // Stop three is a DIFFERENT compiled document, served at the same URL. - await until(page, `document.body.textContent.includes('Overwrite value')`, 'the overwrite guard'); - // It says what is in the slot — as a fingerprint. The plaintext stays in - // the frame that decrypted it. - expect(await evaluate(page, `document.body.textContent.includes('rk_…qrs')`)).toBe(true); - expect( - await evaluate(page, `document.body.textContent.includes('rk_live_51HabcdefgHIJKLMNOPqrs')`), - ).toBe(false); - - await evaluate(page, clickButton('Overwrite the value')); - - expect(await done).toEqual({ - answers: { var: 'STRIPE_SECRET_KEY', mode: 'test', overwrite: true }, - cancelled: false, - }); - }, 60_000); - - test('a mode with no key on this machine cannot be chosen', async () => { - // The terminal offers both unconditionally and exits one question later, - // in readKeyFromSection, having already asked for the account. - const { askConnectInBrowser } = await import('../../src/ui/connectScreens'); - let url = ''; - let settled = false; - const done = askConnectInBrowser({ - provider: 'stripe', - projectName: 'mikes-market', - branch: 'development', - plan: PLAN, - questions: [ - { - kind: 'mode', - modes: [ - { id: 'test', available: true, keyPrefix: 'rk_test_' }, - { id: 'live', available: false, blockedBy: 'NO_KEY' }, - ], - }, - ], - open: false, - timeoutMs: 4_000, - onListen: (u) => (url = u), - }); - void done.then(() => (settled = true)).catch(() => undefined); - - const page = await open(await waitForUrl(() => url)); - - // The row says why, in the screen's words for the CLI's condition. - expect( - await evaluate(page, `document.body.textContent.includes('No live-mode API key')`), - ).toBe(true); - - // Clicking it does not move the selection off the safe answer. - await evaluate(page, clickOption('live')); - expect( - await evaluate( - page, - `[...document.querySelectorAll('[role=radio]')] - .find(el => el.querySelector('.label').textContent.trim() === 'live') - .getAttribute('aria-checked') === 'true'`, - ), - ).toBe(false); - - expect(settled).toBe(false); - await done.catch(() => undefined); - }, 60_000); - - test('the live gate holds its button until the account ID is typed back', async () => { - // The typed echo is the design. It is the only affordance in the kit that - // cannot be completed by muscle memory, and this is the check that the - // button really is held — not merely styled as though it were. - const { confirmLiveActionInBrowser } = await import('../../src/ui/connectScreens'); - const { connectPlan } = await import('../../src/commands/connectors/plans'); - let url = ''; - const done = confirmLiveActionInBrowser({ - action: 'rotate', - provider: 'stripe', - projectName: 'mikes-market', - branch: 'development', - varName: 'STRIPE_SECRET_KEY', - accountId: 'acct_1234', - keyPrefix: 'rk_live_', - push: true, - stops: connectPlan({ - ...PLAN, - standing: null, - varName: 'STRIPE_SECRET_KEY', - mode: 'live', - account: 'acct_1234', - }), - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - // The CLI's own line, in the CLI's own capitals. - expect( - await evaluate(page, `document.body.textContent.includes('LIVE MODE — REAL STRIPE ACCOUNT')`), - ).toBe(true); - // Eight characters of the key, and nothing that says which key. - expect(await evaluate(page, `document.body.textContent.includes('rk_live_…')`)).toBe(true); - expect( - await evaluate(page, `document.body.textContent.includes('rk_live_51Habcdefg')`), - ).toBe(false); - - const submit = `document.querySelector('button[type=submit]')`; - expect(await evaluate(page, `${submit}.disabled`)).toBe(true); - - // A near miss is a near miss, and the page says which character to check. - await evaluate(page, type('.field input', 'acct_1235')); - await until(page, `document.body.textContent.includes('character by character')`, 'the mismatch'); - expect(await evaluate(page, `${submit}.disabled`)).toBe(true); - - await evaluate(page, type('.field input', 'acct_1234')); - await until(page, `!${submit}.disabled`, 'the button to go live'); - - await evaluate(page, clickButton('Rotate the live key')); - expect(await done).toBe(true); - }, 60_000); - - test('a gate with no account to name offers no way through', async () => { - // The terminal falls back to asking the user to type the literal string - // `(unknown)`, which satisfies the prompt and confirms nothing. - const { confirmLiveActionInBrowser } = await import('../../src/ui/connectScreens'); - const { connectPlan } = await import('../../src/commands/connectors/plans'); - let url = ''; - let settled = false; - const done = confirmLiveActionInBrowser({ - action: 'connect', - provider: 'stripe', - projectName: 'mikes-market', - branch: 'development', - varName: 'STRIPE_SECRET_KEY', - accountId: null, - keyPrefix: 'rk_live_', - push: true, - stops: connectPlan({ ...PLAN, standing: null, mode: 'live' }), - open: false, - timeoutMs: 4_000, - onListen: (u) => (url = u), - }); - void done.then(() => (settled = true)).catch(() => undefined); - - const page = await open(await waitForUrl(() => url)); - - expect( - await evaluate(page, `document.body.textContent.includes('Capy cannot name this account')`), - ).toBe(true); - // No field to type into, and no primary button over it. - expect(await evaluate(page, `!document.querySelector('.field input')`)).toBe(true); - expect(await evaluate(page, `!document.querySelector('button[type=submit]')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('(unknown)')`)).toBe(false); - - expect(settled).toBe(false); - await done.catch(() => undefined); - }, 60_000); - - test('the connector list previews the binary it needs before you pick it', async () => { - const { chooseConnectorInBrowser } = await import('../../src/ui/connectScreens'); - let url = ''; - const done = chooseConnectorInBrowser({ - projectName: 'mikes-market', - branch: 'development', - connectors: [ - { - id: 'stripe', - description: 'Stripe API key (test or live, restricted)', - requiresAuth: true, - requiresTool: 'stripe', - toolFound: false, - blocked: { - code: 'PROVIDER_CLI_MISSING', - title: 'stripe CLI not found.', - detail: 'Capy reads the key the Stripe CLI already holds.', - remedy: 'brew install stripe/stripe-cli/stripe', - }, - managedCount: 2, - }, - { id: 'acme', description: 'Acme token', toolFound: true }, - ], - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - // The three things the terminal's two columns cannot say. - expect(await evaluate(page, `document.body.textContent.includes('stripe missing')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('browser sign-in')`)).toBe(true); - expect( - await evaluate(page, `document.body.textContent.includes('2 variables on this branch')`), - ).toBe(true); - - // It opens on the connector that can actually run, not on the warning. - await evaluate(page, clickButton('Set up connector')); - expect(await done).toEqual({ provider: 'acme', cancelled: false }); - }, 60_000); - - test('the account step lists config.toml and returns the section that was clicked', async () => { - // `pickAccount` refuses outright without a TTY — "2 Stripe accounts in - // config.toml; can't pick one without a prompt" — so for the caller `--web` - // exists for this question does not exist at all. The rows are the config's - // sections verbatim, and the answer is the SECTION name while the row is - // labelled with the account id: two different strings, and the CLI writes - // the one it can hand back to `stripe login --project-name=`. - const { askConnectInBrowser } = await import('../../src/ui/connectScreens'); - let url = ''; - const done = askConnectInBrowser({ - provider: 'stripe', - projectName: 'mikes-market', - branch: 'development', - plan: PLAN, - questions: [ - { - kind: 'account', - accounts: [ - { id: 'sandbox', accountId: 'acct_0001', hasTestKey: true, hasLiveKey: false }, - { - id: 'prod', - displayName: 'Mike’s Market (prod)', - accountId: 'acct_9999', - hasTestKey: true, - hasLiveKey: true, - }, - ], - }, - ], - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - expect(await evaluate(page, `document.body.textContent.includes('Stripe account')`)).toBe(true); - // Which modes an account can serve, from the config rather than from a - // failure two questions later. - expect(await evaluate(page, `document.body.textContent.includes('test + live')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('test only')`)).toBe(true); - - await evaluate(page, clickOption('acct_9999')); - await evaluate(page, clickButton('Use this account')); - - // The section name, which is what the CLI looks the key up by — not the - // account id the row was labelled with. - expect(await done).toEqual({ answers: { account: 'prod' }, cancelled: false }); - }, 60_000); - - test('the near-expiry offer can be declined, and the decline comes back as false', async () => { - // `expiringSoonPrompt` never asks a headless caller: it prints "skipping - // re-login (non-interactive)" and takes the key that is there. Here the - // offer is made, and the answer this test gives is the one that is NOT the - // default — a screen that only ever returns its own default has proved - // nothing. - const { askConnectInBrowser } = await import('../../src/ui/connectScreens'); - let url = ''; - const done = askConnectInBrowser({ - provider: 'stripe', - projectName: 'mikes-market', - branch: 'development', - plan: PLAN, - questions: [{ kind: 'refresh', mode: 'live', expiresInDays: 1, command: 'stripe login' }], - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - // A real plural from a number, not the CLI's `day(s)`. - expect(await evaluate(page, `document.body.textContent.includes('Expires in 1 day')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('day(s)')`)).toBe(false); - - await evaluate(page, clickOption('Use the key Stripe already has')); - await evaluate(page, clickButton('Continue')); - - expect(await done).toEqual({ answers: { refresh: false }, cancelled: false }); - }, 60_000); - - test('a mode question with no runnable answer is a wall, not a question', async () => { - // One config section holding only a live key, read by `capy-dev`: live is - // blocked by the dev firewall, test has no key. The screen disables both - // rows and the reducer refuses either, so ASKING would leave the run - // sitting on a dead page until the wizard's five-minute timeout. It is - // served as an ending instead — no controls, a reason, and a command. - const { showConnectBlockedInBrowser } = await import('../../src/ui/connectScreens'); - const { noRunnableMode, stripeModeOptions } = await import( - '../../src/commands/connectors/stripe' - ); - const { connectPlan } = await import('../../src/commands/connectors/plans'); - - // The CLI's own condition, not a hand-written payload: one section, live - // key only, under capy-dev. - const modes = stripeModeOptions( - [{ name: 'default', account_id: 'acct_1234', live_mode_api_key: 'rk_live_51Habcdefg' }], - undefined, - true, - ); - const blocked = noRunnableMode(modes); - expect(blocked?.code).toBe('DEV_MODE_NO_TEST_KEY'); - - let url = ''; - const served = showConnectBlockedInBrowser({ - provider: 'stripe', - projectName: 'mikes-market', - branch: 'development', - step: 'mode', - stops: connectPlan({ ...PLAN, standing: 'mode', varName: 'STRIPE_SECRET_KEY' }), - blocked: blocked!, - open: false, - timeoutMs: 30_000, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - expect( - await evaluate( - page, - `document.body.textContent.includes('capy-dev cannot use the only key Stripe is holding')`, - ), - ).toBe(true); - // Nothing to answer, and nothing that looks like it could be answered. - expect(await evaluate(page, `!document.querySelector('button[type=submit]')`)).toBe(true); - expect(await evaluate(page, `!document.querySelector('[role=radio]')`)).toBe(true); - // The way on without a browser is on the page. - expect(await evaluate(page, `document.body.textContent.includes('stripe login')`)).toBe(true); - // The key that caused it is not on it. - expect(await evaluate(page, `document.body.textContent.includes('rk_live_51Habcdefg')`)).toBe(false); - - // The wall ends the run rather than holding it: this resolves once the - // browser has the page, and the command exits after it. - expect(await served).toBe(url); - }, 60_000); -}); - -describeBrowser('capy rotate, driven by a real browser', () => { - let browser: Browser | null = null; - let profile = ''; - - afterEach(() => { - browser?.close(); - browser = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - const page = await browser.newPage(1280, 820); - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - return page; - } - - const clickButton = (text: string): string => - `[...document.querySelectorAll('button')] - .find(b => b.textContent.includes(${JSON.stringify(text)})).click()`; - - const clickOption = (label: string): string => - `[...document.querySelectorAll('[role=radio]')] - .find(el => el.querySelector('.label').textContent.trim() === ${JSON.stringify(label)}).click()`; - - const CANDIDATES = [ - { - name: 'STRIPE_SECRET_KEY', - managed: true, - provider: 'stripe', - fingerprint: 'rk_…tst', - expiresInDays: 1, - mode: 'live' as const, - accountId: 'acct_1234', - issuedByCapy: true, - }, - { name: 'DATABASE_URL', managed: false }, - ]; - - test('nothing is pre-selected on the variable step, and picking one returns it', async () => { - // A list of credentials to invalidate. The terminal's picker opens on the - // first row, which puts a live key one keypress away. - const { askRotateVariableInBrowser } = await import('../../src/ui/rotateScreens'); - const { rotationPlan } = await import('../../src/commands/connectors/plans'); - let url = ''; - const done = askRotateVariableInBrowser({ - step: 'variable', - projectName: 'mikes-market', - branch: 'development', - devMode: false, - all: false, - noPush: false, - stops: rotationPlan({ - branch: 'development', - providers: ['stripe'], - authProviders: ['stripe'], - standing: 'variable', - }), - candidates: CANDIDATES, - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - // The whole route, including the stops this question is nowhere near. - for (const label of ['Variable', 'Integration', 'Auth', 'Rotate', 'Push', 'Deploy']) { - expect( - await evaluate(page, `document.body.textContent.includes(${JSON.stringify(label)})`), - ).toBe(true); - } - // Real plurals, from a number rather than the CLI's `day(s)`. - expect(await evaluate(page, `document.body.textContent.includes('expires in 1 day')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('day(s)')`)).toBe(false); - - expect( - await evaluate( - page, - `[...document.querySelectorAll('[role=radio]')].some(el => el.getAttribute('aria-checked') === 'true')`, - ), - ).toBe(false); - - await evaluate(page, clickOption('DATABASE_URL')); - await evaluate(page, clickButton('Continue')); - - expect(await done).toEqual({ variable: 'DATABASE_URL', cancelled: false }); - }, 60_000); - - test('the plan gate exists for a caller with no TTY, and is answered by clicking', async () => { - // `!opts.skipPrompts && isTTY` drops `Proceed?` on a piped run — the only - // approval the whole rotate → push → deploy chain has. - const { confirmRotatePlanInBrowser } = await import('../../src/ui/rotateScreens'); - const { rotationPlan } = await import('../../src/commands/connectors/plans'); - let url = ''; - const done = confirmRotatePlanInBrowser({ - step: 'plan', - projectName: 'mikes-market', - branch: 'development', - devMode: false, - all: false, - noPush: false, - stops: rotationPlan({ - branch: 'development', - varName: 'STRIPE_SECRET_KEY', - needsIntegration: false, - providers: ['stripe'], - authProviders: ['stripe'], - standing: 'plan', - }), - varName: 'STRIPE_SECRET_KEY', - targets: [CANDIDATES[0]], - deployTargetCount: 0, - advisories: [ - { code: 'provider-flag-ignored', detail: '--provider stripe only applies to an unmanaged variable.' }, - ], - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - // A flag the CLI accepts and silently drops now says so before approval. - expect( - await evaluate(page, `document.body.textContent.includes('The integration you named was not used')`), - ).toBe(true); - // The consequence is above the button, never inside its label. - expect( - await evaluate(page, `document.body.textContent.includes('real customers, real money')`), - ).toBe(true); - // No deploy target resolved, so the button promises only what will happen. - expect( - await evaluate( - page, - `document.querySelector('button[type=submit]').textContent.includes('Rotate and push')`, - ), - ).toBe(true); - // No key material anywhere on the page. - expect(await evaluate(page, `document.body.textContent.includes('rk_live_')`)).toBe(false); - - await evaluate(page, clickButton('Rotate and push')); - expect(await done).toBe(true); - }, 60_000); - - test('closing the plan window approves nothing', async () => { - // An unanswered gate has not been approved. Nothing about leaving may look - // like agreement to invalidate a live key. - const { confirmRotatePlanInBrowser } = await import('../../src/ui/rotateScreens'); - const { rotationPlan } = await import('../../src/commands/connectors/plans'); - let url = ''; - let settled = false; - const done = confirmRotatePlanInBrowser({ - step: 'plan', - projectName: 'mikes-market', - branch: 'development', - devMode: false, - all: false, - noPush: false, - stops: rotationPlan({ - branch: 'development', - providers: ['stripe'], - authProviders: ['stripe'], - standing: 'plan', - }), - targets: [CANDIDATES[0]], - open: false, - timeoutMs: 1_500, - onListen: (u) => (url = u), - }); - void done.then(() => (settled = true)).catch(() => undefined); - - const page = await open(await waitForUrl(() => url)); - await page.send('Page.navigate', { url: 'about:blank' }); - - await new Promise((r) => setTimeout(r, 400)); - expect(settled).toBe(false); - await done.catch(() => undefined); - }, 60_000); - - test('a rotation that shipped nothing says so, and says not to run it again', async () => { - // The worst middle this command can leave: the keys are live in Capy and - // every running system still holds the old ones. The instinct is to re-run - // `capy rotate`, which fetches a third key and widens the gap. - const { showRotateProgressInBrowser } = await import('../../src/ui/rotateScreens'); - const { rotationPlan } = await import('../../src/commands/connectors/plans'); - let url = ''; - // STARTED, not awaited: an ending now holds the run open until the browser - // has the page, which is the whole point of it — so awaiting it before - // opening the browser would be waiting for a reader who has not been let - // in yet. - const served = showRotateProgressInBrowser({ - outcome: 'deploy-failed', - projectName: 'mikes-market', - branch: 'development', - all: false, - noPush: false, - devMode: false, - stops: rotationPlan({ - branch: 'development', - varName: 'STRIPE_SECRET_KEY', - needsIntegration: false, - providers: ['stripe'], - authProviders: ['stripe'], - deployDetail: 'ship directly to prod', - }), - steps: [ - { id: 'rotate', label: 'Rotate', state: 'ok', detail: '1/1' }, - { id: 'push', label: 'Push', state: 'ok', detail: 'development' }, - { id: 'deploy', label: 'Deploy', state: 'fail', detail: 'prod' }, - ], - keys: [ - { - name: 'STRIPE_SECRET_KEY', - provider: 'stripe', - outcome: 'rotated', - pushed: true, - mode: 'live', - issuedByCapy: true, - }, - ], - deploy: { targetName: 'prod', targetCount: 1 }, - open: false, - timeoutMs: 30_000, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - expect(await evaluate(page, `document.body.textContent.includes('Rollout failed')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('Do not run')`)).toBe(true); - // A key Capy issued: every teammate's copy stopped working when this ran. - expect( - await evaluate( - page, - `document.body.textContent.includes('Teammates are holding a key that no longer works')`, - ), - ).toBe(true); - // It reports; it does not decide. There is nothing here a click could send. - expect(await evaluate(page, `!document.querySelector('button[type=submit]')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('rk_live_')`)).toBe(false); - - // And the run ends once the page has been read, rather than sitting on the - // socket for its timeout. - expect(await served).toBe(url); - }, 60_000); - - test('the integration step is a real list, and picking one returns it', async () => { - // The step a piped `capy rotate ` never asks: off a TTY the CLI - // auto-picks the single registered provider with no output at all, and the - // connect flow it hands off to runs with `force: true` — so the value in - // that variable is replaced rather than rotated. - const { askRotateIntegrationInBrowser } = await import('../../src/ui/rotateScreens'); - const { rotationPlan } = await import('../../src/commands/connectors/plans'); - let url = ''; - const done = askRotateIntegrationInBrowser({ - step: 'integration', - projectName: 'mikes-market', - branch: 'development', - devMode: false, - all: false, - noPush: false, - stops: rotationPlan({ - branch: 'development', - varName: 'DATABASE_URL', - needsIntegration: true, - standing: 'integration', - }), - integrations: [ - { name: 'stripe', description: 'Stripe API key (test or live, restricted)' }, - { name: 'acme', description: 'Acme token' }, - ], - varName: 'DATABASE_URL', - open: false, - onListen: (u) => (url = u), - }); - - const page = await open(await waitForUrl(() => url)); - - // The consequence the terminal never states, before the list rather than - // after the write. - expect( - await evaluate(page, `document.body.textContent.includes('This replaces the current value')`), - ).toBe(true); - // Nothing pre-selected, however few are registered. - expect( - await evaluate( - page, - `[...document.querySelectorAll('[role=radio]')].some(el => el.getAttribute('aria-checked') === 'true')`, - ), - ).toBe(false); - - await evaluate(page, clickOption('acme')); - await evaluate(page, clickButton('Continue')); - - expect(await done).toEqual({ provider: 'acme', cancelled: false }); - }, 60_000); -}); - -/** - * The endings, driven the way a real run reaches them: in a CHILD PROCESS that - * ends the moment the page has been handed over. - * - * WHY A CHILD. Every other test in this file holds the server open because the - * test process keeps running. A real `capy rotate --web` does not: it serves - * the page that explains what happened and then ends — `process.exit`, or - * simply the last statement of the command — and the loopback server serving - * that page dies with it. `ScreenServer.start()` resolves when the socket is - * LISTENING, not when a browser has read anything, so the exit won that race - * every time and the pages built for the worst outcomes this command has (a - * live key rotated and pushed with the rollout failed; a connect whose push - * did not land) were served into a socket that closed microseconds later. - * - * These two spawn a child that does exactly that — awaits the ending helper, - * then `process.exit(1)` on the very next line — and then load the URL with a - * real browser. They fail if the helper ever stops waiting for delivery. - */ -describeBrowser('a --web ending outlives the exit code that follows it', () => { - let browser: Browser | null = null; - let profile = ''; - let child: CliChild | null = null; - - afterEach(() => { - browser?.close(); - browser = null; - child?.dispose(); - child = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - const page = await browser.newPage(1280, 820); - await navigate(page, url); - return page; - } - - /** The shared child runner, remembered so `afterEach` can bury it. */ - const spawnEnding = (source: string): CliChild => (child = spawnCliChild(source)); - - test('the rollout-failed page is still there after the command exits 1', async () => { - // The marquee page of this parcel: the keys are live in Capy, every running - // system still holds the old ones, and the instinct is to re-run rotate — - // which fetches a third key. `planAndRotate` reports this state and then - // hands the process the deploy's exit code. - const { url, exit, output } = spawnEnding(` - import { showRotateProgressInBrowser } from ${imp('src/ui/rotateScreens.ts')}; - import { travelledStops } from ${imp('src/commands/rotateCommand.ts')}; - import { rotationPlan } from ${imp('src/commands/connectors/plans.ts')}; - - const steps = [ - { id: 'rotate', label: 'Rotate', state: 'ok', detail: '1/1' }, - { id: 'push', label: 'Push', state: 'ok', detail: 'development' }, - { id: 'deploy', label: 'Deploy', state: 'fail', detail: 'prod' }, - ]; - - await showRotateProgressInBrowser({ - outcome: 'deploy-failed', - projectName: 'mikes-market', - branch: 'development', - all: false, - noPush: false, - devMode: false, - // Exactly what \`reportRun\` sends: the declared route, redrawn as the - // route the run travelled. - stops: travelledStops( - rotationPlan({ - branch: 'development', - varName: 'STRIPE_SECRET_KEY', - needsIntegration: false, - providers: ['stripe'], - authProviders: ['stripe'], - deployDetail: 'ship directly to prod', - }), - steps, - ), - steps, - keys: [ - { name: 'STRIPE_SECRET_KEY', provider: 'stripe', outcome: 'rotated', - pushed: true, mode: 'live', issuedByCapy: true }, - ], - deploy: { targetName: 'prod', targetCount: 1 }, - open: false, - timeoutMs: 45000, - onListen: (u) => console.log('LISTENING ' + u), - }); - process.exit(1); - `); - - const page = await open(await url); - - expect(await evaluate(page, `document.body.textContent.includes('Rollout failed')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('Do not run')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('rk_live_')`)).toBe(false); - - // The rail agrees with the step log beside it: two stops travelled, and - // the run standing on the one that failed. The plan handed over untouched - // drew all three as still ahead of it. - const li = (cls: string, label: string): string => - `[...document.querySelectorAll('li')].some(el => - el.className.includes(${JSON.stringify(cls)}) && el.textContent.includes(${JSON.stringify(label)}))`; - expect(await evaluate(page, li('done', 'Rotate'))).toBe(true); - expect(await evaluate(page, li('done', 'Push'))).toBe(true); - expect(await evaluate(page, li('current', 'Deploy'))).toBe(true); - expect(await evaluate(page, li('upcoming', 'Deploy'))).toBe(false); - - // The code the deploy failed with, delivered after the page rather than - // instead of it. - expect(await exit).toBe(1); - // And the address was written down, because `open()` resolves on spawn and - // under CAPY_WEB_NO_OPEN nothing is spawned at all. - expect(output()).toContain('What this rotation did, in your browser:'); - }, 90_000); - - test('the push-failed page is still there after the command exits 1', async () => { - // `writeAndSync` has no try/catch around `pushSecrets`, so this used to be - // a stack trace: the key is in .env, encrypted, and nobody else has it — - // and the user could not tell that from "nothing happened". - const { url, exit, output } = spawnEnding(` - import { showConnectResultInBrowser } from ${imp('src/ui/connectScreens.ts')}; - import { connectPlan } from ${imp('src/commands/connectors/plans.ts')}; - - await showConnectResultInBrowser({ - outcome: 'push-failed', - provider: 'stripe', - projectName: 'mikes-market', - branch: 'development', - varName: 'STRIPE_SECRET_KEY', - mode: 'test', - accountId: 'acct_1234', - keyPrefix: 'rk_test_', - fingerprint: 'rk_…xyz', - detail: 'connect ECONNREFUSED 127.0.0.1:8787', - stops: connectPlan({ - provider: 'stripe', - branch: 'development', - requiresTool: 'stripe', - requiresAuth: true, - standing: null, - varName: 'STRIPE_SECRET_KEY', - mode: 'test', - account: 'acct_1234', - signedIn: true, - push: true, - pushOutcome: 'failed', - }), - open: false, - timeoutMs: 45000, - onListen: (u) => console.log('LISTENING ' + u), - }); - process.exit(1); - `); - - const page = await open(await url); - - expect( - await evaluate(page, `document.body.textContent.includes('Written here, not shared')`), - ).toBe(true); - // The rail on a finished run agrees with the body of the page it sits on: - // Push is where the run stopped, not a stop still ahead of it. - expect( - await evaluate(page, `document.body.textContent.includes('did not reach Capy')`), - ).toBe(true); - expect( - await evaluate( - page, - `[...document.querySelectorAll('li')].some(li => - li.className.includes('current') && li.textContent.includes('Push'))`, - ), - ).toBe(true); - // Sign in happened — the run got a key — so the rail says so rather than - // drawing it as a stop still to come. - expect( - await evaluate( - page, - `[...document.querySelectorAll('li')].some(li => - li.className.includes('done') && li.textContent.includes('Sign in'))`, - ), - ).toBe(true); - // A prefix and a fingerprint. Never a key. - expect(await evaluate(page, `document.body.textContent.includes('rk_test_…')`)).toBe(true); - - expect(await exit).toBe(1); - expect(output()).toContain('What this run did, in your browser:'); - }, 90_000); -}); - -describeBrowser('the first run, driven by a real browser', () => { - let browser: Browser | null = null; - let profile = ''; - - afterEach(() => { - browser?.close(); - browser = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - const page = await browser.newPage(1280, 820); - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - return page; - } - - /** - * Type into a field the way a person does. - * - * `.value` alone is invisible to the screen — `bind:value` listens for - * `input` — and `:not(.filter)` because a choice list's own search box is - * also an input on some of these steps. - */ - const type = (value: string): string => - `(() => { const el = document.querySelector('input:not(.filter)'); - el.value = ${JSON.stringify(value)}; - el.dispatchEvent(new Event('input', { bubbles: true })); })()`; - - const clickButton = (text: string): string => - `[...document.querySelectorAll('button')] - .find(b => b.textContent.includes(${JSON.stringify(text)})).click()`; - - const clickOption = (label: string): string => - `[...document.querySelectorAll('[role=radio]')] - .find(el => el.querySelector('.label').textContent.trim() === ${JSON.stringify(label)}).click()`; - - /** - * Answer the step, and wait for the one that replaces it. - * - * The wait is on the page's own load event, then on a control only the NEXT - * step draws. Every stop's label is on the rail of every page — that is the - * point of the rail — so waiting for a label would pass without the page - * having moved at all, and the next `evaluate` would then land in an - * execution context that was being torn down. - */ - async function advance(page: CdpSession, click: string, button: string, what: string): Promise { - const reloaded = page.once('Page.loadEventFired', 20_000); - await evaluate(page, click); - await reloaded; - await until( - page, - `[...document.querySelectorAll('button')].some(b => b.textContent.includes(${JSON.stringify(button)}))`, - what, - ); - } - - const ORGS = [ - { id: 'org-1', name: 'mikes-market-hq', isCurrent: true }, - { id: 'org-2', name: 'side-project-labs', isCurrent: false }, - ]; - - const LOCAL_ENV = { count: 2, names: ['STRIPE_SECRET_KEY', 'DATABASE_URL'] }; - const TARGET = { projectName: 'mikes-market', orgName: 'mikes-market-hq', branch: 'development' }; - - test('four stops of the first run are walked in one window, by clicking', async () => { - // The property that replaced six unrelated pages: answering a stop RELOADS - // this same address, and what comes back is the step the CLI actually - // reached after doing the work that answer unlocked. Three inventions this - // session died on exactly this. - const { InitWizardSession } = await import('../../src/ui/initWizardScreen'); - let url = ''; - const session = new InitWizardSession({ open: false, onListen: (u) => (url = u) }); - - const done = (async () => { - session.record({ signedInAs: 'mike@market.example', orgCount: 2 }); - const org = await session.askOrganization(ORGS); - // The work that answer unlocked: scope the session, check the key, look - // for projects. This org has none, so that stop is never asked. - session.record({ hasOrgKey: true, projectCount: 0 }); - const project = await session.askProjectName('mikes-market'); - const branch = await session.askBranchChoice(); - session.record({ localEnvCount: 2 }); - const encrypt = await session.askEncrypt(LOCAL_ENV, TARGET); - await session.finish(); - return { org, project, branch, encrypt }; - })(); - - const page = await open(await waitForUrl(() => url)); - - // The rail is the CLI's plan, drawn whole before anything was answered — - // including the consent gate three stops away, and the fork this run has - // not reached yet. - expect(await evaluate(page, `document.body.textContent.includes('Encrypt and push')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('Recovery phrase')`)).toBe(true); - - await evaluate(page, clickOption('side-project-labs')); - // Stop two arrives by navigation, at the same address. - await advance(page, clickButton('Use this organization'), 'Create project', 'the project name stop'); - - // …with stop one settled on the rail, under the name the CLI resolved. - expect(await evaluate(page, `document.body.textContent.includes('side-project-labs')`)).toBe(true); - // The four stops the CLI has since worked out this run does not need are - // no longer stations on it, while the ones still ahead are. Which is - // which was decided by the plan, not by the page. - const stations = `[...document.querySelectorAll('nav.route li')].map(li => li.textContent).join(' ')`; - expect(await evaluate(page, `!(${stations}).includes('Redeem a code')`)).toBe(true); - expect(await evaluate(page, `(${stations}).includes('Encrypt and push')`)).toBe(true); - - await evaluate(page, type('mikes-market')); - await advance(page, clickButton('Create project'), 'Use this branch', 'the branch stop'); - - await evaluate(page, clickOption('development')); - // The consent gate names what it is asking about: variable NAMES, and - // never a value — they are still plaintext on disk and this is the - // question of whether they may stop being. - await advance(page, clickButton('Use this branch'), 'Encrypt and push', 'the consent gate'); - - expect(await evaluate(page, `document.body.textContent.includes('STRIPE_SECRET_KEY')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('DATABASE_URL')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('sk_live')`)).toBe(false); - // It says where this is going, which the terminal's one-line confirm does - // in prose and this run must not get wrong. - expect(await evaluate(page, `document.body.textContent.includes('mikes-market-hq')`)).toBe(true); - - await evaluate(page, clickButton('Encrypt and push')); - - expect(await done).toEqual({ - org: 'org-2', - project: 'mikes-market', - branch: 'development', - encrypt: true, - }); - }, 90_000); - - /** - * Answer the step and wait for the page that replaces it, whatever it is. - * - * Tolerant of the reload never coming, on purpose: a run that ends by - * rendering "Done" in place is exactly the defect these two tests are about, - * and swallowing the wait here lets them fail on what the page SAYS rather - * than on a protocol timeout that names nothing. - */ - async function reloadAfter(page: CdpSession, click: string): Promise { - const reloaded = page.once('Page.loadEventFired', 20_000).catch(() => undefined); - await evaluate(page, click); - await reloaded; - } - - const body = `document.body.textContent`; - - test('a run that stops between two stops says so, and never says Done', async () => { - // The teammate case, which is the most common way this run stops: the - // organization was answered, and the CLI then finds no key for it on this - // device and throws. The browser is holding that answer's POST at that - // moment, and the compiled screen draws its ending from the button that - // was pressed — so ending the flow with `{ done }` renders a green check - // and "Done. You can close this tab." over a run that just died. - const { InitWizardSession } = await import('../../src/ui/initWizardScreen'); - const { CapyError, ERROR_CODES } = await import('../../src/types'); - let url = ''; - const session = new InitWizardSession({ - open: false, - finalGraceMs: 8_000, - onListen: (u) => (url = u), - }); - - const done = (async () => { - session.record({ signedInAs: 'mike@market.example', orgCount: 2 }); - await session.askOrganization(ORGS); - // The work that answer unlocked: scope the session to that org, and - // check whether this device holds its key. It does not. - session.record({ hasOrgKey: false }); - session.willBlock( - 'redeem', - { - code: ERROR_CODES.AUTH_FAILED, - title: 'This device does not hold this organization\'s key', - detail: - 'You have access to the organization, but the shared encryption key has never been transferred to this device.', - remedy: 'capy redeem ', - }, - { facts: [{ label: 'Organization', value: 'side-project-labs' }] }, - ); - await session.abort(new CapyError('no key on this device', ERROR_CODES.AUTH_FAILED)); - })(); - - const page = await open(await waitForUrl(() => url)); - await evaluate(page, clickOption('side-project-labs')); - await reloadAfter(page, clickButton('Use this organization')); - - // Nothing anywhere on it says this worked. - expect(await evaluate(page, `${body}.includes('Done. You can close this tab.')`)).toBe(false); - // The stop that blocked it, on the rail it was drawn on from the start. - await until(page, `${body}.includes('does not hold this organization')`, 'the blocked page'); - // The way out, as something to copy — not mined out of the error's prose. - expect(await evaluate(page, `${body}.includes('capy redeem ')`)).toBe(true); - expect(await evaluate(page, `${body}.includes('side-project-labs')`)).toBe(true); - - await done; - }, 90_000); - - test('the clock stops while the CLI works between two stops', async () => { - // One window for the whole run put every stop under ONE budget, and the - // budget has to cover what the CLI does BETWEEN stops — answering - // "Create new organization" hands off to a flow that opens two more - // windows and asks somebody to write down 24 words, which is not five - // minutes of anything but is easily five minutes of a person. - // - // Here that work takes longer than the whole per-question budget. Before - // the clock was made to stop for it, the wizard died mid-work and the next - // question threw "The setup window has already closed." — with the - // organization already created and its recovery phrase already shown once. - const { InitWizardSession } = await import('../../src/ui/initWizardScreen'); - let url = ''; - const session = new InitWizardSession({ open: false, timeoutMs: 6_000, onListen: (u) => (url = u) }); - - const done = (async () => { - session.record({ signedInAs: 'mike@market.example', orgCount: 2 }); - const org = await session.askOrganization(ORGS); - // createNewOrganization: name it, mint the master key, show 24 words. - await new Promise((r) => setTimeout(r, 8_000)); - session.record({ hasOrgKey: true, projectCount: 0, recoveryShown: true }); - const project = await session.askProjectName('mikes-market'); - await session.finish(); - return { org, project }; - })(); - - const page = await open(await waitForUrl(() => url)); - await evaluate(page, clickOption('side-project-labs')); - await reloadAfter(page, clickButton('Use this organization')); - - // The window survived the work and is serving the next stop. - await until( - page, - `[...document.querySelectorAll('button')].some(b => b.textContent.includes('Create project'))`, - 'the project name stop', - ); - await evaluate(page, type('mikes-market')); - await evaluate(page, clickButton('Create project')); - - expect(await done).toEqual({ org: 'org-2', project: 'mikes-market' }); - }, 90_000); - - test('a push that fails after consent does not render as Done', async () => { - // The one failure that lands AFTER the last question. The terminal path - // swallows it, prints "You can run capy again", and carries on — so under - // --web the run reached `finish()` and the page drew a green check over a - // push that never happened, on a directory whose .env is still plaintext. - const { InitWizardSession } = await import('../../src/ui/initWizardScreen'); - let url = ''; - const session = new InitWizardSession({ - open: false, - finalGraceMs: 8_000, - onListen: (u) => (url = u), - }); - - const done = (async () => { - session.record({ - signedInAs: 'mike@market.example', - orgCount: 1, - organization: { kind: 'existing', name: 'mikes-market-hq' }, - hasOrgKey: true, - projectCount: 0, - project: { kind: 'new', name: 'mikes-market' }, - branchChoice: 'development', - localEnvCount: 2, - }); - const yes = await session.askEncrypt(LOCAL_ENV, TARGET); - // pushSecrets threw. Nothing was stored, nothing was backed up, and the - // .env in this directory was never rewritten. - await session.reportEncryptFailure({ - code: 'SERVICE_ERROR', - reason: 'Keep did not answer (503).', - envRewritten: false, - backupWritten: false, - pushed: false, - }); - return yes; - })(); - - const page = await open(await waitForUrl(() => url)); - await until(page, `${body}.includes('STRIPE_SECRET_KEY')`, 'the consent gate'); - await reloadAfter(page, clickButton('Encrypt and push')); - - expect(await evaluate(page, `${body}.includes('Done. You can close this tab.')`)).toBe(false); - await until(page, `${body}.includes('The push failed')`, 'the failure page'); - // The fact that decides what to do next, and the only one the message - // cannot be trusted for: what is on disk right now. - expect(await evaluate(page, `${body}.includes('still plaintext')`)).toBe(true); - // The consent stop is where this run is STANDING, not a stop it ticked - // off: the rail must not draw a station done because the question on it - // was answered, when the thing the station describes then failed. - expect( - await evaluate( - page, - `[...document.querySelectorAll('nav.route li')] - .some(li => li.textContent.includes('Encrypt and push') && li.textContent.includes('you are here'))`, - ), - ).toBe(true); - - expect(await done).toBe(true); - }, 90_000); - - test('a refusal the CLI makes lands on the compiled screen, and the step stays live', async () => { - // Five of the wizard's verdicts are inline refusals, and until now no test - // drove one against a STANDALONE compiled screen — the shell's `#status` - // line, which the other refusal test reads, does not exist on one of these - // pages. This is the org list refusing an id it cannot reach, which is - // what a page whose list has moved under it submits. - const { buildInitWizardData } = await import('../../src/ui/initWizardScreen'); - const { renderScreen } = await import('../../src/ui/screens/serve'); - let url = ''; - let attempts = 0; - const render = (nonce: string): string => - renderScreen( - 'init-wizard', - buildInitWizardData({ step: 'organization', input: { signedInAs: 'mike@market.example', orgCount: 2 }, orgs: ORGS }, nonce), - ); - - const done = runBrowserWizard( - { - title: 'Set up this directory', - firstScreen: { html: '', standalone: true }, - open: false, - timeoutMs: 30_000, - onListen: (u) => (url = u), - renderFirst: render, - }, - async (_step, payload) => { - attempts += 1; - if (attempts === 1) return { error: 'That organization is not one this session can reach.' }; - return { done: true, result: payload }; - }, - ); - - const page = await open(await waitForUrl(() => url)); - await evaluate(page, clickOption('side-project-labs')); - await evaluate(page, clickButton('Use this organization')); - - // The CLI's sentence, on the page, in the screen's own status line. - await until(page, `${body}.includes('not one this session can reach')`, 'the refusal'); - // Still this step: the rows are there and the button is live again. - expect(await evaluate(page, `!!document.querySelector('[role=radio]')`)).toBe(true); - await until( - page, - `![...document.querySelectorAll('button')].find(b => b.textContent.includes('Use this organization')).disabled`, - 'the button to go live again', - ); - - // Answering again finishes it — a refusal is not an ending. - await evaluate(page, clickButton('Use this organization')); - expect(await done).toEqual({ __action: 'submit', organizationId: 'org-2' }); - expect(attempts).toBe(2); - }, 90_000); - - test('cancelling the consent gate is a NO, and nothing is encrypted', async () => { - // `confirmEncrypt = chosen === 'yes'` already meant this. After this step - // the .env in the directory is ciphertext, so the refusal has to come back - // as a refusal rather than as an error the caller might treat as a retry. - const { InitWizardSession } = await import('../../src/ui/initWizardScreen'); - let url = ''; - const session = new InitWizardSession({ open: false, onListen: (u) => (url = u) }); - const done = (async () => { - session.record({ signedInAs: 'mike@market.example', orgCount: 0, localEnvCount: 2 }); - return session.askEncrypt(LOCAL_ENV, TARGET); - })(); - - const page = await open(await waitForUrl(() => url)); - await until(page, `document.body.textContent.includes('STRIPE_SECRET_KEY')`, 'the consent gate'); - - await evaluate(page, clickButton('Cancel')); - - expect(await done).toBe(false); - }, 90_000); - - test('closing the window on the consent gate encrypts nothing', async () => { - // An unanswered gate has not been agreed to. Leaving must not resolve as - // consent, and it must not resolve at all. - const { InitWizardSession } = await import('../../src/ui/initWizardScreen'); - let url = ''; - let settled = false; - const session = new InitWizardSession({ open: false, timeoutMs: 1_500, onListen: (u) => (url = u) }); - const done = session.askEncrypt(LOCAL_ENV, TARGET); - void done.then(() => (settled = true)).catch(() => undefined); - - const page = await open(await waitForUrl(() => url)); - await page.send('Page.navigate', { url: 'about:blank' }); - - await new Promise((r) => setTimeout(r, 400)); - expect(settled).toBe(false); - - // It ends by timing out, not by treating the close as an answer. - await done.catch(() => undefined); - expect(settled).toBe(false); - }, 90_000); -}); - -describeBrowser('the sync reports, driven by a real browser', () => { - // A report has nothing to click, which is exactly why it needs loading in a - // real browser: a payload the screen cannot read renders a blank page and - // every server-side test still passes. These open the page the CLI serves - // and read what a person would read off it. - let browser: Browser | null = null; - let profile = ''; - - afterEach(() => { - browser?.close(); - browser = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - const page = await browser.newPage(1280, 820); - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - return page; - } - - test('capy status renders its report, and no value is on it', async () => { - // The `json` field is `capy status --json` VERBATIM, and this test used to - // hand it a two-key stub — which made its own "no hash on the page" - // assertion vacuous, because the hashes live in that object's `diffs`. - // This is the real thing statusCommand builds. - const { showSyncStatusInBrowser } = await import('../../src/ui/syncScreens'); - const diffs = [ - { variable: 'STRIPE_SECRET_KEY', type: 'changed' as const, pinned: 'a1b2c3d4e5f60718', local: '0718f6e5d4c3b2a1', remote: 'a1b2c3d4e5f60718' }, - { variable: 'DATABASE_URL', type: 'new' as const, local: 'ffeeddccbbaa9988' }, - ]; - const report = { - projectName: 'mikes-market', - branch: 'development', - totalSecrets: 14, - inSync: false, - localMatchesPinned: false, - remoteMatchesPinned: false, - remoteFailure: null, - diffs, - }; - const url = await showSyncStatusInBrowser({ - projectName: 'mikes-market', - branch: 'development', - totalSecrets: 14, - localMatchesPinned: false, - remoteMatchesPinned: false, - hasRemote: true, - diffs, - expiring: [{ variable: 'STRIPE_SECRET_KEY', expiresInDays: 2 }], - json: JSON.stringify(report, null, 2), - open: false, - timeoutMs: 20_000, - }); - - const page = await open(url); - - expect(await evaluate(page, `document.body.textContent.includes('mikes-market')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('STRIPE_SECRET_KEY')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('DATABASE_URL')`)).toBe(true); - // No value, ever. - expect(await evaluate(page, `document.body.textContent.includes('sk_live')`)).toBe(false); - - // The rows the screen renders carry a name, a type and which side moved. - // The hashes `compareSecrets` compared on are dropped on the way in. - expect( - await evaluate( - page, - `window.__CAPY_DATA__.diffs.every(d => - Object.keys(d).sort().join(',') === 'side,type,variable')`, - ), - ).toBe(true); - - // The hashes DO appear once each on this page, and only inside the copy of - // `capy status --json` — the same object the same command prints to stdout - // on the same machine. Every occurrence in the document is accounted for - // by that string: nothing else on the page renders one. - const hashHits = `(document.body.textContent.match(/a1b2c3d4e5f60718/g) || []).length`; - const jsonHits = (JSON.stringify(report, null, 2).match(/a1b2c3d4e5f60718/g) || []).length; - expect(jsonHits).toBe(2); - expect(await evaluate(page, hashHits)).toBe(jsonHits); - - // The command that moves this forward, which the terminal also offers. - expect(await evaluate(page, `document.body.textContent.includes('capy')`)).toBe(true); - }, 90_000); - - test('the end of a run renders what moved, and which way', async () => { - const { showSyncResultInBrowser } = await import('../../src/ui/syncScreens'); - const url = await showSyncResultInBrowser({ - projectName: 'mikes-market', - branch: 'development', - outcome: 'synced', - pulled: [{ variable: 'DATABASE_URL', type: 'changed' }], - pushed: [{ variable: 'STRIPE_SECRET_KEY', type: 'new' }], - envRewritten: true, - open: false, - timeoutMs: 20_000, - }); - - const page = await open(url); - - expect(await evaluate(page, `document.body.textContent.includes('DATABASE_URL')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('STRIPE_SECRET_KEY')`)).toBe(true); - expect(await evaluate(page, `document.body.textContent.includes('sk_live')`)).toBe(false); - }, 90_000); -}); - -describeBrowser('org and onboarding, driven by a real browser', () => { - /** - * ONE headless shell for the whole block, with a fresh tab per test. - * - * The blocks above launch and kill a browser per test, which is fine at four - * tests and starts costing seconds and the occasional early-exit at sixteen — - * the profile directory is removed the instant SIGTERM is sent, so a shell - * that has not finished shutting down is racing a deletion. Nothing here - * needs a clean profile: these pages are loopback documents with no storage, - * no cookies and no service worker, and each test navigates a tab it opened - * itself. - */ - let browser: Browser | null = null; - let session: CdpSession | null = null; - let profile = ''; - - beforeAll(async () => { - profile = mkdtempSync(join(tmpdir(), 'capy-e2e-')); - browser = await Browser.launch(profile); - }); - - afterEach(() => { - session?.close(); - session = null; - }); - - afterAll(() => { - browser?.close(); - browser = null; - if (profile) rmSync(profile, { recursive: true, force: true }); - profile = ''; - }); - - async function open(url: string): Promise { - const page = await browser!.newPage(1280, 820); - session = page; - const loaded = page.once('Page.loadEventFired', 20_000); - await page.send('Page.navigate', { url }); - await loaded; - return page; - } - - /** - * Type into a field the way a person does. - * - * Assigning `.value` alone is invisible to the screen: `bind:value` listens - * for `input`, so a test that only sets the property proves the CLI can read - * a field nobody could have filled in. - */ - const type = (selector: string, value: string): string => - `(() => { const el = document.querySelector(${JSON.stringify(selector)}); - el.value = ${JSON.stringify(value)}; - el.dispatchEvent(new Event('input', { bubbles: true })); })()`; - - /** Click the button whose visible text contains `text`. */ - const clickButton = (text: string): string => - `[...document.querySelectorAll('button')] - .find(b => b.textContent.includes(${JSON.stringify(text)})).click()`; - - /** Click the option row whose label is exactly `label`. */ - const clickOption = (label: string): string => - `[...document.querySelectorAll('[role=radio]')] - .find(el => el.querySelector('.label').textContent.trim() === ${JSON.stringify(label)}).click()`; - - /** Uncover the phrase grid and tick the write-it-down consent. */ - const REVEAL = `document.querySelector('.reveal').click()`; - const CONSENT = `document.querySelector('[role=checkbox]').click()`; - - /** - * One row of the rail, by the label the CLI put on it. - * - * The rail is a `