diff --git a/README.md b/README.md index aa5e7500..49e39cfe 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The PostHog wizard helps you quickly add PostHog to your project using AI. To use the wizard, you can run it directly using: ```bash -npx @posthog/wizard +npx @posthog/wizard@latest ``` Currently the wizard can be used for over 16+ frameworks for frontend, backend, and mobile applications. If you have other integrations you would like the wizard to @@ -43,10 +43,10 @@ Protocol) servers: ```bash # Install PostHog MCP server to supported clients -npx @posthog/wizard mcp add +npx @posthog/wizard@latest mcp add # Remove PostHog MCP server from supported clients -npx @posthog/wizard mcp remove +npx @posthog/wizard@latest mcp remove ``` ## Wizard programs @@ -58,7 +58,7 @@ The wizard's commands are grouped into **programs** — self-contained agentic j Running the wizard with no arguments installs PostHog into your project. It detects your framework, wires up initialization, instruments a starter set of events, and walks you through a first dashboard: ```bash -npx @posthog/wizard +npx @posthog/wizard@latest ``` Powered by the `posthog-integration` program. Most other programs below build on it (they declare `requires: ['posthog-integration']`) and will offer to run it first if PostHog isn't already set up. @@ -68,7 +68,7 @@ Powered by the `posthog-integration` program. Most other programs below build on Autonomously sets up PostHog self-driving end-to-end. It connects GitHub, enables Session Replay and Error Tracking, wires up signal sources, and configures a Signals scout troop that watches your project for you. ```bash -npx @posthog/wizard self-driving +npx @posthog/wizard@latest self-driving ``` If PostHog isn't already installed, the wizard runs the default integration first (composed run) before starting the self-driving setup. @@ -81,16 +81,16 @@ audit (the default); pass a subcommand to run a specific one: ```bash # Runs the events audit (the default) — no subcommand needed -npx @posthog/wizard audit +npx @posthog/wizard@latest audit # Or run a specific audit directly -npx @posthog/wizard audit events # event capture quality + cost (default) -npx @posthog/wizard audit all # comprehensive audit across every area -npx @posthog/wizard audit autocapture # autocapture setup + cost -npx @posthog/wizard audit feature-flags # feature flag usage + cost -npx @posthog/wizard audit identify # your $identify implementation -npx @posthog/wizard audit session-replay # session replay setup -npx @posthog/wizard audit web-analytics # web analytics setup +npx @posthog/wizard@latest audit events # event capture quality + cost (default) +npx @posthog/wizard@latest audit all # comprehensive audit across every area +npx @posthog/wizard@latest audit autocapture # autocapture setup + cost +npx @posthog/wizard@latest audit feature-flags # feature flag usage + cost +npx @posthog/wizard@latest audit identify # your $identify implementation +npx @posthog/wizard@latest audit session-replay # session replay setup +npx @posthog/wizard@latest audit web-analytics # web analytics setup ``` Most audit subcommands resolve at runtime from the published skill registry, so @@ -108,7 +108,7 @@ new audits appear without a wizard release (`web-analytics` is wizard-native). Wire up an existing PostHog + Stripe project for revenue analytics: ```bash -npx @posthog/wizard revenue-analytics +npx @posthog/wizard@latest revenue-analytics ``` Requires PostHog and Stripe SDKs already installed. Supports `--ci` with the @@ -120,7 +120,7 @@ Detect data sources your project already uses (Postgres, MySQL, MongoDB, Snowflake, BigQuery, Stripe, …) and connect them to PostHog's data warehouse: ```bash -npx @posthog/wizard warehouse +npx @posthog/wizard@latest warehouse ``` The wizard scans your dependencies and `.env` key names (never the values) to @@ -132,7 +132,7 @@ OAuth sources open the PostHog app's new-source flow in your browser. Upload JavaScript source maps to PostHog error tracking so stack traces are symbolicated back to your original code: ```bash -npx @posthog/wizard upload-source-maps +npx @posthog/wizard@latest upload-source-maps ``` ### Run skill @@ -141,8 +141,8 @@ Run any context-mill skill directly by name, even if it isn't exposed as its own command: ```bash -npx @posthog/wizard skill list # list every available skill -npx @posthog/wizard skill # run one by name +npx @posthog/wizard@latest skill list # list every available skill +npx @posthog/wizard@latest skill # run one by name ``` ## Wizard ownership @@ -181,7 +181,7 @@ account, uses the returned personal API key to run the normal CI install, and wires PostHog into the project at `--install-dir`: ```bash -npx @posthog/wizard --ci --signup \ +npx @posthog/wizard@latest --ci --signup \ --email you@example.com \ --install-dir . ``` @@ -197,10 +197,10 @@ PostHog yourself — use the `provision` subcommand, which emits a structured ```bash # Human-readable (when stdout is a TTY) -npx @posthog/wizard provision --email user@example.com --region us +npx @posthog/wizard@latest provision --email user@example.com --region us # Machine-readable — auto when stdout is piped, or force with --json -npx @posthog/wizard provision --email user@example.com --region eu --json +npx @posthog/wizard@latest provision --email user@example.com --region eu --json ``` Success prints the full `ProvisioningResult` (`projectApiKey`, `host`, @@ -235,13 +235,13 @@ The following CLI arguments are available: > gateway doesn't yet grant the scopes the wizard needs to personal API keys > for most users, so non-interactive `--ci` runs fail at the gateway. The flag > is disabled in the published package and exits with an error — run the wizard -> in an interactive terminal instead (`npx @posthog/wizard`). The notes below +> in an interactive terminal instead (`npx @posthog/wizard@latest`). The notes below > describe CI mode as it works in development builds. Run the wizard non-interactive executions with `--ci`: ```bash -npx @posthog/wizard --ci --api-key $POSTHOG_PERSONAL_API_KEY --install-dir . +npx @posthog/wizard@latest --ci --api-key $POSTHOG_PERSONAL_API_KEY --install-dir . ``` When running in CI mode (`--ci`): diff --git a/docs/error-catalog.md b/docs/error-catalog.md index 7e90f99d..dc1d90bd 100644 --- a/docs/error-catalog.md +++ b/docs/error-catalog.md @@ -22,7 +22,7 @@ as an API: backends may branch on it. New codes follow the pattern `PHW__` (see `ERROR_CODE_PATTERN` in `codes.ts`). Groups are lowercase module prefixes (`cli`, `args`, `auth`, `env`, -`detect`, `skill`, `agent`, `settings`, `internal`). +`detect`, `skill`, `agent`, `settings`, `gateway`, `internal`). ## How codes propagate @@ -107,6 +107,8 @@ screen, debug log) keep the full detail. | `PHW_AGENT_ORCHESTRATOR_SINK_INVARIANT` | agent | orchestrator plan violates sink coverage invariant | no | | `PHW_SETTINGS_UNFIXABLE_CONFLICT` | settings | Claude settings conflict that cannot be auto-neutralized (managed/unwritable) | no | | `PHW_INTERNAL_UNHANDLED` | internal | catch-all: an unexpected error escaped the pipeline | yes | +| `PHW_GATEWAY_MINT_REFUSED` | gateway | the gateway-token mint refused this run (blocked, throttled, unlisted program, rollout off); the server's reason is shown | no | +| `PHW_GATEWAY_MINT_FAILED` | gateway | the gateway-token mint could not be reached or answered unusably | yes | Retry advice is guidance for automated hosts (sandbox re-run policies), not a guarantee. diff --git a/src/__tests__/wizard-abort.test.ts b/src/__tests__/wizard-abort.test.ts index 8a72e0cf..8699354c 100644 --- a/src/__tests__/wizard-abort.test.ts +++ b/src/__tests__/wizard-abort.test.ts @@ -7,6 +7,7 @@ import { runCleanups, } from '@utils/wizard-abort'; import { analytics } from '@utils/analytics'; +import { ErrorCodes } from '@lib/errors'; import { getUI } from '../ui'; vi.mock('../utils/analytics'); @@ -132,6 +133,23 @@ describe('wizardAbort', () => { }); }); + it('resolves the code from a coded WizardError when the caller passes none', async () => { + // A mint refusal reaches wizardAbort as the error alone; its code must + // still land on the captured exception. + const error = new WizardError( + 'refused', + { status: 403 }, + ErrorCodes.GatewayMintRefused, + ); + + await expect(wizardAbort({ error })).rejects.toThrow('process.exit called'); + + expect(mockAnalytics.captureException).toHaveBeenCalledWith(error, { + status: 403, + error_code: ErrorCodes.GatewayMintRefused, + }); + }); + it('runs registered cleanup functions before analytics and display', async () => { const callOrder: string[] = []; diff --git a/src/lib/__tests__/gateway-session.test.ts b/src/lib/__tests__/gateway-session.test.ts index f5064331..a2cfb977 100644 --- a/src/lib/__tests__/gateway-session.test.ts +++ b/src/lib/__tests__/gateway-session.test.ts @@ -8,8 +8,15 @@ import { resetGatewaySession, } from '@lib/gateway-session'; import type { HostResolution } from '@lib/host-resolution'; +import { ErrorCodes } from '@lib/errors'; +import { WizardError } from '@utils/wizard-abort'; +import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; +vi.mock('@utils/analytics', () => ({ + analytics: { wizardCapture: vi.fn(), captureException: vi.fn() }, +})); + vi.mock('@utils/debug', () => ({ logToFile: vi.fn() })); // logToFile is variadic, so a leak in any argument is a leak. Rendered every way the @@ -37,6 +44,7 @@ describe('gatewayAuth', () => { beforeEach(() => { resetGatewaySession(); fetchMock.mockReset(); + vi.mocked(analytics.wizardCapture).mockClear(); vi.mocked(logToFile).mockClear(); vi.stubGlobal('fetch', fetchMock); }); @@ -288,6 +296,147 @@ describe('gatewayAuth', () => { }, ); + it('reads the outcome from the DRF body code and shows its detail', async () => { + // The exact shape the backend's exception handler writes for a refusal. + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + type: 'permission_denied', + code: 'blocked', + detail: 'This account is blocked. Contact wizard@posthog.com.', + attr: null, + }), + }); + const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(GatewayMintRefused); + expect((err as GatewayMintRefused).outcome).toBe('blocked'); + expect((err as GatewayMintRefused).message).toContain( + 'Contact wizard@posthog.com', + ); + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'gateway mint refused', + { status: 403, outcome: 'blocked', program: 'audit' }, + ); + }); + + it.each([ + [ + 'code wins over outcome', + { code: 'blocked', outcome: 'throttled' }, + 'blocked', + ], + [ + 'outcome carries it when code is absent', + { outcome: 'throttled' }, + 'throttled', + ], + [ + 'an empty code does not shadow outcome', + { code: ' ', outcome: 'throttled' }, + 'throttled', + ], + [ + 'a control-only code does not shadow outcome', + { code: '\u0007', outcome: 'throttled' }, + 'throttled', + ], + ])('resolves the outcome when %s', async (_label, body, want) => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve(body), + }); + const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch( + (e: unknown) => e, + ); + expect((err as GatewayMintRefused).outcome).toBe(want); + }); + + it('captures a refusal with its status, outcome and program', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + detail: 'This account is blocked.', + outcome: 'blocked', + }), + }); + // The backend's own denial event has no run id, so this client event is + // what joins a refusal to the session. + const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch( + (e: unknown) => e, + ); + expect(analytics.wizardCapture).toHaveBeenCalledTimes(1); + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'gateway mint refused', + { status: 403, outcome: 'blocked', program: 'audit' }, + ); + expect((err as GatewayMintRefused).outcome).toBe('blocked'); + }); + + it.each([ + ['absent', () => Promise.resolve({ detail: 'Limit reached.' })], + ['not a string', () => Promise.resolve({ outcome: 429 })], + ['a non-string code', () => Promise.resolve({ code: 403 })], + ['oversized', () => Promise.resolve({ outcome: 'x'.repeat(65) })], + ['unparseable', () => Promise.reject(new SyntaxError('bad json'))], + ])( + 'captures a refusal with no outcome when the body has one that is %s', + async (_label, json) => { + fetchMock.mockResolvedValue({ ok: false, status: 429, json }); + await expect( + gatewayAuth(host, 'pha_oauth', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintRefused); + expect(analytics.wizardCapture).toHaveBeenCalledWith( + 'gateway mint refused', + { status: 429, outcome: undefined, program: 'integration' }, + ); + }, + ); + + it('does not capture a mint failure as a refusal', async () => { + // A 5xx is the mint being unavailable, not a decision about this run. + fetchMock.mockResolvedValue({ ok: false, status: 503 }); + await expect( + gatewayAuth(host, 'pha_oauth', 'integration'), + ).rejects.toBeInstanceOf(GatewayMintFailed); + expect(analytics.wizardCapture).not.toHaveBeenCalled(); + }); + + it('throws coded WizardErrors so the runners can name the failure', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 403, + json: () => Promise.resolve({ outcome: 'blocked' }), + }); + const refused: unknown = await gatewayAuth( + host, + 'pha_oauth', + 'integration', + ).catch((e: unknown) => e); + expect(refused).toBeInstanceOf(WizardError); + expect((refused as WizardError).code).toBe(ErrorCodes.GatewayMintRefused); + // The context is what wizardAbort attaches to the captured exception. + expect((refused as WizardError).context).toEqual({ + status: 403, + outcome: 'blocked', + }); + + fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }); + const failed: unknown = await gatewayAuth( + host, + 'pha_oauth', + 'integration', + ).catch((e: unknown) => e); + expect(failed).toBeInstanceOf(WizardError); + expect((failed as WizardError).code).toBe(ErrorCodes.GatewayMintFailed); + }); + it('surfaces a refusal through the transport catch', async () => { // The refusal is thrown from inside the try that wraps fetch, so a catch that // treats every throw as a transport failure would silently restore fallback. diff --git a/src/lib/errors/__tests__/codes.test.ts b/src/lib/errors/__tests__/codes.test.ts index b7e451e8..0a49a4ac 100644 --- a/src/lib/errors/__tests__/codes.test.ts +++ b/src/lib/errors/__tests__/codes.test.ts @@ -41,6 +41,19 @@ describe('error catalog', () => { } }); + it('files the mint codes under the gateway group', () => { + // A refusal is a per-run decision the user can sometimes act on; a failure + // is the mint being unavailable and clears on its own. + expect(ERROR_CATALOG[ErrorCodes.GatewayMintRefused]).toMatchObject({ + group: 'gateway', + retry: 'case-by-case', + }); + expect(ERROR_CATALOG[ErrorCodes.GatewayMintFailed]).toMatchObject({ + group: 'gateway', + retry: 'yes', + }); + }); + it('every entry carries a group, retry advice, and a description', () => { for (const [code, entry] of Object.entries(ERROR_CATALOG)) { expect(entry.group, `${code} group`).toBeTruthy(); diff --git a/src/lib/errors/__tests__/run-failure.test.ts b/src/lib/errors/__tests__/run-failure.test.ts new file mode 100644 index 00000000..3d9c3009 --- /dev/null +++ b/src/lib/errors/__tests__/run-failure.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { classifyRunFailure } from '../run-failure'; +import { ErrorCodes } from '../codes'; +import { WizardError } from '@utils/wizard-abort'; +import { GatewayMintRefused } from '@lib/gateway-session'; + +vi.mock('@utils/analytics', () => ({ + analytics: { wizardCapture: vi.fn(), captureException: vi.fn() }, +})); + +describe('classifyRunFailure', () => { + it('keeps a mint refusal as its own code and message', () => { + // The runners print this message alone, without the unhandled framing. + const failure = classifyRunFailure( + new GatewayMintRefused(403, 'This account is blocked.', 'blocked'), + ); + expect(failure).toEqual({ + code: ErrorCodes.GatewayMintRefused, + message: 'This account is blocked.', + coded: true, + }); + }); + + it('treats an uncoded WizardError as unhandled', () => { + const failure = classifyRunFailure(new WizardError('no code', {})); + expect(failure.code).toBe(ErrorCodes.InternalUnhandled); + expect(failure.coded).toBe(false); + }); + + it('treats a plain error as unhandled', () => { + expect(classifyRunFailure(new Error('boom'))).toEqual({ + code: ErrorCodes.InternalUnhandled, + message: 'boom', + coded: false, + }); + }); + + it('ignores a code that is not in the catalog', () => { + // A third-party error with its own `code` field (ENOENT, say) is not a + // wizard decision. + const err = Object.assign(new Error('missing'), { code: 'ENOENT' }); + expect(classifyRunFailure(err).code).toBe(ErrorCodes.InternalUnhandled); + }); + + it('stringifies a non-error throw', () => { + expect(classifyRunFailure('nope')).toEqual({ + code: ErrorCodes.InternalUnhandled, + message: 'nope', + coded: false, + }); + }); +}); diff --git a/src/lib/errors/catalog.ts b/src/lib/errors/catalog.ts index 7d2c026d..e49be292 100644 --- a/src/lib/errors/catalog.ts +++ b/src/lib/errors/catalog.ts @@ -232,6 +232,19 @@ export const ERROR_CATALOG: Record = { retry: 'no', description: 'The orchestrator plan failed the sink coverage invariant.', }, + [ErrorCodes.GatewayMintRefused]: { + group: 'gateway', + // 429 clears with the daily window, 401 with a fresh login, 403 never. + retry: 'case-by-case', + description: + 'The PostHog backend refused to mint a gateway token for this run.', + }, + [ErrorCodes.GatewayMintFailed]: { + group: 'gateway', + retry: 'yes', + description: + 'The PostHog backend could not mint a gateway token: unreachable, a 5xx, or an unusable response.', + }, [ErrorCodes.SettingsUnfixableConflict]: { group: 'settings', retry: 'no', diff --git a/src/lib/errors/codes.ts b/src/lib/errors/codes.ts index 6342b79b..a4d5f04d 100644 --- a/src/lib/errors/codes.ts +++ b/src/lib/errors/codes.ts @@ -45,6 +45,10 @@ export const ErrorCodes = { AgentOrchestratorTasksFailed: 'PHW_AGENT_ORCHESTRATOR_TASKS_FAILED', AgentOrchestratorHollowRun: 'PHW_AGENT_ORCHESTRATOR_HOLLOW_RUN', AgentOrchestratorSinkInvariant: 'PHW_AGENT_ORCHESTRATOR_SINK_INVARIANT', + /** The backend answered the mint with a deliberate refusal. */ + GatewayMintRefused: 'PHW_GATEWAY_MINT_REFUSED', + /** The mint was unreachable, errored, or returned an unusable token. */ + GatewayMintFailed: 'PHW_GATEWAY_MINT_FAILED', SettingsUnfixableConflict: 'PHW_SETTINGS_UNFIXABLE_CONFLICT', InternalUnhandled: 'PHW_INTERNAL_UNHANDLED', } as const; diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index 669ea257..5558c794 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -17,3 +17,4 @@ export { type WizardErrorLine, } from './emit'; export { sanitizeErrorDetail } from './sanitize'; +export { classifyRunFailure, type RunFailure } from './run-failure'; diff --git a/src/lib/errors/run-failure.ts b/src/lib/errors/run-failure.ts new file mode 100644 index 00000000..8ad3b0f2 --- /dev/null +++ b/src/lib/errors/run-failure.ts @@ -0,0 +1,23 @@ +import { ErrorCodes, isErrorCode, type ErrorCode } from './codes'; + +export interface RunFailure { + code: ErrorCode; + message: string; + /** True when the error carried its own code: a decision, not a crash. */ + coded: boolean; +} + +/** + * How a run's terminal error is reported. A coded WizardError (a mint refusal, + * say) keeps its own message and code; anything else is unhandled and gets the + * generic framing. Duck-typed on `code` so callers need not load wizard-abort. + */ +export function classifyRunFailure(err: unknown): RunFailure { + const message = err instanceof Error ? err.message : String(err); + const code = + err instanceof Error ? (err as { code?: unknown }).code : undefined; + if (typeof code === 'string' && isErrorCode(code)) { + return { code, message, coded: true }; + } + return { code: ErrorCodes.InternalUnhandled, message, coded: false }; +} diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index f71ac37d..43e9ce89 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -6,6 +6,7 @@ export type ErrorGroup = | 'detect' | 'skill' | 'agent' + | 'gateway' | 'settings' | 'internal'; diff --git a/src/lib/gateway-session.ts b/src/lib/gateway-session.ts index 7bf698b6..647492a2 100644 --- a/src/lib/gateway-session.ts +++ b/src/lib/gateway-session.ts @@ -7,6 +7,9 @@ */ import { logToFile } from '@utils/debug'; +import { analytics } from '@utils/analytics'; +import { WizardError } from '@utils/wizard-abort'; +import { ErrorCodes } from '@lib/errors'; import type { HostResolution } from '@lib/host-resolution'; export interface GatewayAuth { @@ -44,6 +47,8 @@ const REFRESH_AT_FRACTION = 0.8; const MINT_TIMEOUT_MS = 20_000; /** Longer than any refusal the mint writes; a body past this is not a message. */ const MAX_REFUSAL_DETAIL_LENGTH = 500; +/** Outcomes are short snake_case labels; anything longer is not one. */ +const MAX_REFUSAL_OUTCOME_LENGTH = 64; /** Resolve this run's gateway auth, minting and re-minting near expiry. */ export async function gatewayAuth( @@ -165,15 +170,19 @@ interface MintedToken { /** * A deliberate refusal from the mint endpoint, as opposed to the mint being * unavailable. Thrown so the run stops instead of proceeding without the - * controls the refusal was enforcing. + * controls the refusal was enforcing. A WizardError, so the runners print its + * message as-is and `wizardAbort` resolves its code. */ -export class GatewayMintRefused extends Error { +export class GatewayMintRefused extends WizardError { readonly status: number; + /** The backend's refusal outcome (`blocked`, `throttled`, ...), when it sent one. */ + readonly outcome?: string; - constructor(status: number, message: string) { - super(message); + constructor(status: number, message: string, outcome?: string) { + super(message, { status, outcome }, ErrorCodes.GatewayMintRefused); this.name = 'GatewayMintRefused'; this.status = status; + this.outcome = outcome; } } @@ -181,9 +190,9 @@ export class GatewayMintRefused extends Error { * The mint could not produce a usable credential: unreachable, a 5xx, or a * response the client cannot use. */ -export class GatewayMintFailed extends Error { +export class GatewayMintFailed extends WizardError { constructor(message: string) { - super(message); + super(message, undefined, ErrorCodes.GatewayMintFailed); this.name = 'GatewayMintFailed'; } } @@ -204,25 +213,51 @@ function isMintRefusal(status: number): boolean { ); } +interface MintRefusal { + detail?: string; + outcome?: string; +} + /** * The server's own reason for a refusal, when it sent one. DRF answers every - * refusal as `{"detail": "..."}`; the blocklist's detail names the contact - * address, which the fixed messages below cannot. + * refusal as `{"detail": "...", "code": ""}`; the blocklist's detail + * names the contact address, which the fixed messages below cannot. `code` is + * the backend's own label for the refusal (`outcome` on older backends) and + * rides the client event. */ -async function readRefusalDetail(resp: Response): Promise { +function cleanRefusalText(value: unknown): string { + if (typeof value !== 'string') return ''; + // Not because the server sends escapes, but because this string is printed + // straight to a terminal: sanitizing at the boundary means no later message + // can move the cursor or repaint the screen. + // eslint-disable-next-line no-control-regex + return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(); +} + +async function readRefusal(resp: Response): Promise { try { - const body = (await resp.json()) as { detail?: unknown }; - const raw = typeof body?.detail === 'string' ? body.detail : ''; - // Not because the server sends escapes, but because this string is printed - // straight to a terminal: sanitizing at the boundary means no later message - // can move the cursor or repaint the screen. - // eslint-disable-next-line no-control-regex - const detail = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(); - return detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH - ? detail - : undefined; + const body = (await resp.json()) as { + detail?: unknown; + code?: unknown; + outcome?: unknown; + }; + const detail = cleanRefusalText(body?.detail); + // The DRF handler flattens a dict detail, so the outcome rides as `code`. + // A `code` that cleans to nothing does not shadow a usable `outcome`. + const outcome = + cleanRefusalText(body?.code) || cleanRefusalText(body?.outcome); + return { + detail: + detail.length > 0 && detail.length <= MAX_REFUSAL_DETAIL_LENGTH + ? detail + : undefined, + outcome: + outcome.length > 0 && outcome.length <= MAX_REFUSAL_OUTCOME_LENGTH + ? outcome + : undefined, + }; } catch { - return undefined; + return {}; } } @@ -265,12 +300,23 @@ async function mintGatewayToken( }); if (!resp.ok) { if (isMintRefusal(resp.status)) { + const refusal = await readRefusal(resp); logToFile( - `[gateway] mint refused with HTTP ${resp.status}; failing the run`, + `[gateway] mint refused with HTTP ${resp.status} (${ + refusal.outcome ?? 'no outcome' + }); failing the run`, ); + // The terminal denial event for this run. The backend's own event has + // no run id, so this is what joins a refusal to the session. + analytics.wizardCapture('gateway mint refused', { + status: resp.status, + outcome: refusal.outcome, + program, + }); throw new GatewayMintRefused( resp.status, - mintRefusalMessage(resp.status, await readRefusalDetail(resp)), + mintRefusalMessage(resp.status, refusal.detail), + refusal.outcome, ); } logToFile( diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index a26263db..54c2b98f 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -13,7 +13,12 @@ import { resolveNoTelemetry } from './resolve-no-telemetry'; import type { WizardStore } from '@ui/tui/store'; import type { TaskStreamPush } from '@lib/task-stream/task-stream-push'; import { join } from 'node:path'; -import { ErrorCodes, detectErrorCode, emitWizardError } from '@lib/errors'; +import { + ErrorCodes, + classifyRunFailure, + detectErrorCode, + emitWizardError, +} from '@lib/errors'; import type { OutroData, RunPhase as RunPhaseT } from '@lib/wizard-session'; /** @@ -315,14 +320,19 @@ export function runNonInteractive( session.frameworkConfig?.metadata.docsUrl ?? runDef?.docsUrl ?? POSTHOG_DOCS_URL; + // A coded failure is a decision with its own message; anything else is + // unexpected and gets the generic framing. + const failure = classifyRunFailure(error); await settleStream(RunPhase.Error, { kind: OutroKind.Error, message: errorMessage, - errorCode: ErrorCodes.InternalUnhandled, + errorCode: failure.code, }); await wizardAbort({ - code: ErrorCodes.InternalUnhandled, - message: `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${docsUrl} to set up manually.${debugInfo}`, + code: failure.code, + message: failure.coded + ? `${errorMessage}${debugInfo}` + : `Something went wrong: ${errorMessage}\n\nYou can read the documentation at ${docsUrl} to set up manually.${debugInfo}`, error: error as Error, }); } diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index b2d91555..66b6b5f5 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -12,8 +12,7 @@ import type { TaskStreamPush as TaskStreamPushClass } from '@lib/task-stream/tas import { resolveNoTelemetry } from './resolve-no-telemetry'; import { checkLocalServices, getLocalDev } from '@lib/local-dev'; import { runCleanups } from '@utils/wizard-abort'; -import { ErrorCodes } from '@lib/errors'; -import { emitWizardError } from '@lib/errors'; +import { classifyRunFailure, emitWizardError } from '@lib/errors'; import { join } from 'node:path'; const WIZARD_VERSION = VERSION; @@ -277,15 +276,20 @@ export function runWizard( // ignore } } - // Print after unmount — anything printed into the alt screen is wiped. - // eslint-disable-next-line no-console - console.error('Wizard run failed:', err); + // Print after unmount: anything printed into the alt screen is wiped. + // A coded failure is a decision with its own message; anything else is + // unexpected and goes out whole. + const failure = classifyRunFailure(err); + if (failure.coded) { + // eslint-disable-next-line no-console + console.error(failure.message); + } else { + // eslint-disable-next-line no-console + console.error('Wizard run failed:', err); + } // eslint-disable-next-line no-console console.error(`Full logs: ${getLogFilePath()}`); - emitWizardError({ - code: ErrorCodes.InternalUnhandled, - message: err instanceof Error ? err.message : String(err), - }); + emitWizardError({ code: failure.code, message: failure.message }); process.exit(1); } })();