From 7f3478303a4ba8c0c86f8117c376e40323af188e Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 28 Aug 2026 16:17:03 -0700 Subject: [PATCH] fix(web): connect refusals reach the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last file in the refusal class, and I was wrong that it needed a decision. I had reported connect as not-contained because `execute` returns Promise<{ linked: boolean }> and recurses into itself, so wrapping it in a handler that returns `never` would change the contract. That was the wrong shape to reach for. rotateCommand already documents the right one, and it does not wrap anything: `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 Called directly and followed by `return { linked: false }`, the signature is untouched and the recursion is untouched. Three refusals converted: dev-mode live firewall (x2) PERMISSION_DENIED unknown provider INVALID_FORMAT The unknown-provider one is the reachable-under-web case: the browser picker is offered first, and this is where the run ends when the picker is declined or cannot be shown. It ended on a stream with nobody on it. The firewall sites are dev-binary only, but `capy-dev --web --live` reaches them, so they get the same treatment. Nothing about the refusal itself is weakened — same condition, same exit code, one more surface it reaches. capy-cli now has zero bare `console.error` + `process.exit(1)` refusals in the command layer that are reachable under --web. Suite: 1851 pass, 0 fail. --- src/commands/connectCommand.ts | 52 ++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/src/commands/connectCommand.ts b/src/commands/connectCommand.ts index 7f1841d9..5935f777 100644 --- a/src/commands/connectCommand.ts +++ b/src/commands/connectCommand.ts @@ -11,6 +11,7 @@ import type { ConnectResultData, } from '../ui/screens/contract'; import type { AuthService } from '../auth/authService'; +import { CapyError, ERROR_CODES } from '../types/index'; const B = (s: string) => `\x1b[1m${s}\x1b[0m`; @@ -141,9 +142,20 @@ export class ConnectCommand { async execute(provider: string, opts: ConnectOpts): Promise<{ linked: boolean }> { // Live-mode firewall: capy-dev never touches a live key. if (this.devMode && opts.live) { - console.error('\n Live mode is not allowed in dev mode.'); - console.error(' Use the production `capy` binary against your real Capy service.\n'); - process.exit(1); + // `await displayErrorAndExit(...); return ...` — the pattern rotateCommand + // documents. It serves the command-error page under `--web`, holds the + // process open until the browser has fetched it, still prints to the + // terminal, and exits 1. The `return` is not decoration: the function is + // Promise<{ linked: boolean }> and TypeScript does not narrow across an + // awaited never, so it is what keeps the signature honest. + const { displayErrorAndExit } = await import('../ui/errorScreen'); + await displayErrorAndExit( + new CapyError( + 'Live mode is not allowed in dev mode.\nUse the production `capy` binary against your real Capy service.', + ERROR_CODES.PERMISSION_DENIED, + ), + ); + return { linked: false }; } // The mode question needs to know it is running under capy-dev so it can @@ -169,9 +181,20 @@ export class ConnectCommand { return 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); + // `await displayErrorAndExit(...); return ...` — the pattern rotateCommand + // documents. It serves the command-error page under `--web`, holds the + // process open until the browser has fetched it, still prints to the + // terminal, and exits 1. The `return` is not decoration: the function is + // Promise<{ linked: boolean }> and TypeScript does not narrow across an + // awaited never, so it is what keeps the signature honest. + const { displayErrorAndExit } = await import('../ui/errorScreen'); + await displayErrorAndExit( + new CapyError( + `${(err as Error).message}\nRun \`capy connect\` to see available providers.`, + ERROR_CODES.INVALID_FORMAT, + ), + ); + return { linked: false }; } if (mod.precheck) mod.precheck(); @@ -182,9 +205,20 @@ export class ConnectCommand { // Belt-and-suspenders: if a provider returned mode:'live' (e.g. via an // interactive prompt rather than --live), still refuse in dev mode. if (this.devMode && entry.mode === 'live') { - console.error('\n Live mode is not allowed in dev mode.'); - console.error(' Use the production `capy` binary against your real Capy service.\n'); - process.exit(1); + // `await displayErrorAndExit(...); return ...` — the pattern rotateCommand + // documents. It serves the command-error page under `--web`, holds the + // process open until the browser has fetched it, still prints to the + // terminal, and exits 1. The `return` is not decoration: the function is + // Promise<{ linked: boolean }> and TypeScript does not narrow across an + // awaited never, so it is what keeps the signature honest. + const { displayErrorAndExit } = await import('../ui/errorScreen'); + await displayErrorAndExit( + new CapyError( + 'Live mode is not allowed in dev mode.\nUse the production `capy` binary against your real Capy service.', + ERROR_CODES.PERMISSION_DENIED, + ), + ); + return { linked: false }; } // Confirmation gate for live mode in prod: a human typing the account ID.