From 1bd8187e27dd965ce4fc06a8770b323e2133429e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 01:15:52 +1000 Subject: [PATCH 1/8] feat(cli): add non-interactive / agent-friendly auth + region flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive escape hatches so `stash init` and `stash auth login` can run without a TTY (agents, CI, pipes). Nothing changes for interactive humans — the region picker still renders in a real terminal. - `--region ` + `STASH_REGION` on `auth login` and `init` skip the interactive region picker. An unknown or missing region in a non-TTY context now exits with an actionable message instead of hanging on the picker (previously the only prompt with no escape hatch). - `stash auth login --json` emits newline-delimited device-code events. The first event (`authorization_required`) carries the verification URL so an agent can *trigger* auth and hand the browser step to a human — only a human completes it in the browser. `--no-open` suppresses the browser launch for headless contexts. - Region resolution mirrors the DATABASE_URL resolver (TTY + !CI gate) and lives in a native-free module (`src/commands/auth/region.ts`) so its pure helpers and policy have fast unit coverage. New E2E covers the non-interactive paths; the interactive-cancel E2E now overrides CI to keep exercising the picker. Docs updated in packages/cli README + AGENTS.md. --- packages/cli/AGENTS.md | 11 +- packages/cli/README.md | 36 +++- packages/cli/src/bin/main.ts | 20 +- .../commands/auth/__tests__/region.test.ts | 185 ++++++++++++++++++ packages/cli/src/commands/auth/index.ts | 23 ++- packages/cli/src/commands/auth/login.ts | 160 ++++++++++----- packages/cli/src/commands/auth/region.ts | 133 +++++++++++++ packages/cli/src/commands/init/index.ts | 12 +- .../src/commands/init/steps/authenticate.ts | 7 +- packages/cli/src/commands/init/types.ts | 3 + packages/cli/src/messages.ts | 15 ++ .../tests/e2e/auth-login-cancel.e2e.test.ts | 7 +- .../e2e/auth-non-interactive.e2e.test.ts | 94 +++++++++ 13 files changed, 642 insertions(+), 64 deletions(-) create mode 100644 packages/cli/src/commands/auth/__tests__/region.test.ts create mode 100644 packages/cli/src/commands/auth/region.ts create mode 100644 packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c4dc360f..c3ba4d68 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -62,9 +62,18 @@ exercise the same code paths. test run; if not, manually `chmod +x` the helper under `node_modules/.pnpm/node-pty@*/node_modules/node-pty/prebuilds//spawn-helper`. - **Don't broaden the cancel test target.** `auth login` was chosen because - `selectRegion()` runs before any network I/O. Don't move the cancel + the region picker runs before any network I/O. Don't move the cancel assertion to a command that hits the auth server or DB before the first prompt — flaky. +- **Region resolution is CI-aware.** `resolveRegion` (in + `src/commands/auth/region.ts`) mirrors the `DATABASE_URL` resolver: it only + renders the interactive picker when `stdin.isTTY` **and** `CI` is unset, and + otherwise honours `--region` / `STASH_REGION` or exits with an actionable + error (JSON in `--json` mode). Because the pty harness defaults `CI=true`, + the interactive-cancel test passes `env: { CI: '' }` to force the picker to + render. The pure helpers (`normalizeRegion`, `regionSlugs`) and the resolver + policy have unit coverage in `src/commands/auth/__tests__/region.test.ts` — + the module imports no native code, so it runs under the fast unit config. - **Use `src/messages.ts` for assertion-stable strings.** The module is a single typed `as const` object grouping copy by area (`cli`, `auth`, `db`). Prod call sites import the same constants the tests do, so a copy diff --git a/packages/cli/README.md b/packages/cli/README.md index 1e8e9b45..446c8bee 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -72,13 +72,14 @@ Commands that consume `stash.config.ts`: `eql install`, `eql upgrade`, `db push` Set up CipherStash end-to-end: authenticate, introspect your database, install dependencies, install EQL, and hand off the rest to your local coding agent. ```bash -npx stash init [--supabase] [--drizzle] +npx stash init [--supabase] [--drizzle] [--region ] ``` | Flag | Description | |------|-------------| | `--supabase` | Use the Supabase-specific setup flow | | `--drizzle` | Use the Drizzle-specific setup flow | +| `--region ` | Region to authenticate against (e.g. `us-east-1`). Skips the interactive region picker. Also settable via `STASH_REGION`. | What `init` does, in order: @@ -93,6 +94,12 @@ The full pipeline state — integration, columns, env-key names, paths, versions `CIPHERSTASH_WIZARD_URL` overrides the gateway endpoint for the rulebook fetch. Useful for local-dev against a wizard gateway running on `localhost`. +**Running `init` non-interactively** (CI, agents, pipes): every prompt has an escape hatch, so `init` never blocks waiting on a TTY. Provide the region up front (`--region` / `STASH_REGION`) if you aren't already logged in, the database URL (`--database-url` / `DATABASE_URL`), the proxy choice (`--proxy` / `--no-proxy`), and — for the closing agent handoff — nothing is required (init exits at a clean checkpoint and points you at `stash plan --target …`). When a required value is missing in a non-TTY context the command exits non-zero with an actionable message rather than hanging. + +```bash +STASH_REGION=us-east-1 DATABASE_URL=postgres://… npx stash init --no-proxy +``` + --- ### `npx stash auth login` @@ -100,11 +107,36 @@ The full pipeline state — integration, columns, env-key names, paths, versions Authenticate with CipherStash using a browser-based device code flow. ```bash -npx stash auth login +npx stash auth login [--region ] [--json] [--no-open] ``` +| Flag | Description | +|------|-------------| +| `--region ` | Region to authenticate against (e.g. `us-east-1`). Skips the interactive region picker. Also settable via `STASH_REGION`. | +| `--json` | Emit newline-delimited JSON events instead of prose (see below). Implies non-interactive — never renders the region picker. | +| `--no-open` | Don't auto-open the verification URL in a browser. | +| `--supabase` / `--drizzle` | Track the integration as the referrer. | + Saves the token to `~/.cipherstash/auth.json`. Database-touching commands check for this file before running. +#### Triggering auth from an agent (device-code flow) + +The device-code flow is designed so an **agent can trigger** authentication but only a **human completes** it in the browser. Run `auth login --json` in the background and read the first line — `authorization_required` carries the verification URL to hand to the user: + +```bash +npx stash auth login --region us-east-1 --json --no-open +``` + +```jsonc +// stdout is newline-delimited JSON, one event per line: +{"status":"authorization_required","userCode":"ABCD-1234","verificationUri":"https://…/activate","verificationUriComplete":"https://…/activate?user_code=ABCD-1234","expiresIn":899} +// … the process then blocks polling until the human authorizes in the browser … +{"status":"authorized","expiresAt":1751990400,"expiresAtIso":"2025-07-08T12:00:00.000Z"} +{"status":"device_bound"} +``` + +Errors are emitted as `{"status":"error","code":"…","message":"…"}` and exit non-zero. In a non-TTY context without `--region`/`STASH_REGION` the command exits immediately with `code: "region_required"` instead of hanging on the picker. + --- ### `npx stash wizard` diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index fafce716..a8933225 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -120,6 +120,20 @@ Init Flags: --prisma-next Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migration apply) --proxy Query encrypted data via CipherStash Proxy --no-proxy Query encrypted data directly via the SDK (default) + --region Region to authenticate against (e.g. us-east-1). Skips the + interactive region picker. Also settable via STASH_REGION. + Required for non-interactive init when not already logged in. + +Auth Flags: + --region Region to authenticate against (e.g. us-east-1). Skips the + interactive region picker. Also settable via STASH_REGION. + --json Emit newline-delimited JSON events instead of prose. The + first event (authorization_required) carries the device + verification URL for a human to open. Implies no prompt — + an agent can trigger auth non-interactively; only a human + can complete it in the browser. Run it in the background, + read the URL from the first line, then hand it to the user. + --no-open Don't auto-open the verification URL in a browser. Plan Flags: --complete-rollout Plan the entire encryption lifecycle (schema-add through drop) @@ -165,12 +179,14 @@ Examples: ${STASH} init ${STASH} init --supabase ${STASH} init --prisma-next + ${STASH} init --region us-east-1 # non-interactive: skip the region picker ${STASH} plan ${STASH} impl ${STASH} impl --continue-without-plan ${STASH} impl --target claude-code ${STASH} status ${STASH} auth login + ${STASH} auth login --region us-east-1 --json # agent triggers; human finishes in browser ${STASH} wizard ${STASH} eql install ${STASH} db push @@ -465,7 +481,7 @@ export async function run() { switch (command) { case 'init': - await initCommand(flags) + await initCommand(flags, values) break case 'plan': await planCommand(flags, values) @@ -482,7 +498,7 @@ export async function run() { break case 'auth': { const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs - await authCommand(authArgs, flags) + await authCommand(authArgs, flags, values) break } case 'eql': diff --git a/packages/cli/src/commands/auth/__tests__/region.test.ts b/packages/cli/src/commands/auth/__tests__/region.test.ts new file mode 100644 index 00000000..1859972b --- /dev/null +++ b/packages/cli/src/commands/auth/__tests__/region.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { messages } from '../../../messages.js' + +// region.ts imports `* as p from '@clack/prompts'` (no native `@cipherstash/auth` +// dependency — that's the whole reason the region logic lives in its own +// module). Mock the prompt seam so `selectRegion` is observable and never +// actually blocks on a TTY. +const clack = vi.hoisted(() => ({ + select: vi.fn(), + isCancel: vi.fn(() => false), + cancel: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, +})) +vi.mock('@clack/prompts', () => ({ + select: clack.select, + isCancel: clack.isCancel, + cancel: clack.cancel, + log: clack.log, +})) + +const { normalizeRegion, regionSlugs, resolveRegion, REGION_ENV_VAR } = + await import('../region.js') + +let originalRegionEnv: string | undefined +let originalCi: string | undefined +let originalIsTty: boolean | undefined + +function setTty(value: boolean | undefined) { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + }) +} + +/** Spy process.exit so the code-under-test unwinds via a throw we can assert on. */ +function spyExit() { + return vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) +} + +beforeEach(() => { + originalRegionEnv = process.env[REGION_ENV_VAR] + originalCi = process.env.CI + originalIsTty = process.stdin.isTTY + // biome-ignore lint/performance/noDelete: `env.X = undefined` stores the string "undefined". + delete process.env[REGION_ENV_VAR] + // biome-ignore lint/performance/noDelete: ditto. + delete process.env.CI +}) + +afterEach(() => { + if (originalRegionEnv === undefined) { + // biome-ignore lint/performance/noDelete: see above. + delete process.env[REGION_ENV_VAR] + } else { + process.env[REGION_ENV_VAR] = originalRegionEnv + } + if (originalCi === undefined) { + // biome-ignore lint/performance/noDelete: see above. + delete process.env.CI + } else { + process.env.CI = originalCi + } + setTty(originalIsTty) + vi.restoreAllMocks() +}) + +describe('normalizeRegion', () => { + it('accepts a short slug and returns the canonical .aws form', () => { + expect(normalizeRegion('us-east-1')).toBe('us-east-1.aws') + }) + + it('accepts the canonical .aws form unchanged', () => { + expect(normalizeRegion('ap-southeast-2.aws')).toBe('ap-southeast-2.aws') + }) + + it('is case-insensitive and trims whitespace', () => { + expect(normalizeRegion(' US-East-1 ')).toBe('us-east-1.aws') + }) + + it('returns null for an unknown region', () => { + expect(normalizeRegion('moon-base-1')).toBeNull() + expect(normalizeRegion('us-east-9')).toBeNull() + }) + + it('returns null for empty / whitespace input', () => { + expect(normalizeRegion('')).toBeNull() + expect(normalizeRegion(' ')).toBeNull() + }) +}) + +describe('regionSlugs', () => { + it('lists the short slugs without the .aws suffix', () => { + const slugs = regionSlugs() + expect(slugs).toContain('us-east-1') + expect(slugs).toContain('ap-southeast-2') + expect(slugs.every((s) => !s.endsWith('.aws'))).toBe(true) + }) +}) + +describe('resolveRegion — explicit region', () => { + it('returns the normalized region from the flag and never prompts', async () => { + await expect(resolveRegion({ regionFlag: 'us-west-2' })).resolves.toBe( + 'us-west-2.aws', + ) + expect(clack.select).not.toHaveBeenCalled() + }) + + it('reads STASH_REGION when no flag is passed', async () => { + process.env[REGION_ENV_VAR] = 'eu-west-1' + await expect(resolveRegion()).resolves.toBe('eu-west-1.aws') + expect(clack.select).not.toHaveBeenCalled() + }) + + it('prefers the flag over STASH_REGION', async () => { + process.env[REGION_ENV_VAR] = 'eu-west-1' + await expect(resolveRegion({ regionFlag: 'us-east-2' })).resolves.toBe( + 'us-east-2.aws', + ) + }) + + it('exits 1 with an actionable error on an unknown explicit region', async () => { + const exitSpy = spyExit() + await expect(resolveRegion({ regionFlag: 'moon-base-1' })).rejects.toThrow( + 'process.exit', + ) + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.log.error).toHaveBeenCalledWith( + messages.auth.regionInvalid('moon-base-1', regionSlugs()), + ) + }) + + it('emits a JSON error (not clack) on an unknown region in json mode', async () => { + const exitSpy = spyExit() + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + await expect( + resolveRegion({ regionFlag: 'moon-base-1', json: true }), + ).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.log.error).not.toHaveBeenCalled() + const payload = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(payload).toMatchObject({ status: 'error', code: 'region_invalid' }) + }) +}) + +describe('resolveRegion — no explicit region', () => { + it('prompts interactively when stdin is a TTY and not CI', async () => { + setTty(true) + clack.select.mockResolvedValueOnce('us-east-1.aws') + await expect(resolveRegion()).resolves.toBe('us-east-1.aws') + expect(clack.select).toHaveBeenCalledTimes(1) + }) + + it('exits 1 (no hang) in a non-TTY context without a region', async () => { + setTty(undefined) + const exitSpy = spyExit() + await expect(resolveRegion()).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.log.error).toHaveBeenCalledWith( + messages.auth.regionMissingNonInteractive, + ) + expect(clack.select).not.toHaveBeenCalled() + }) + + it('exits 1 in a TTY when CI is set (CI is not interactive)', async () => { + setTty(true) + process.env.CI = 'true' + const exitSpy = spyExit() + await expect(resolveRegion()).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.select).not.toHaveBeenCalled() + }) + + it('never prompts in json mode even on a TTY, emitting a JSON error', async () => { + setTty(true) + const exitSpy = spyExit() + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + await expect(resolveRegion({ json: true })).rejects.toThrow('process.exit') + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.select).not.toHaveBeenCalled() + const payload = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(payload).toMatchObject({ status: 'error', code: 'region_required' }) + }) +}) diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index efe3faae..700b7fcd 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -1,6 +1,6 @@ import { messages } from '../../messages.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' -import { bindDevice, login, selectRegion } from './login.js' +import { bindDevice, login, resolveRegion } from './login.js' const STASH_AUTH = runnerCommand(detectPackageManager(), 'stash auth') @@ -11,12 +11,21 @@ Commands: login Authenticate with CipherStash Options: - --supabase Track Supabase as the referrer - --drizzle Track Drizzle as the referrer + --region Region to authenticate against (e.g. us-east-1). Skips the + interactive picker. Also settable via STASH_REGION. + --json Emit newline-delimited JSON events instead of prose. The + first event (authorization_required) carries the device + verification URL for a human to open; implies no prompt. + --no-open Don't auto-open the verification URL in a browser. + --supabase Track Supabase as the referrer + --drizzle Track Drizzle as the referrer Examples: ${STASH_AUTH} login + ${STASH_AUTH} login --region us-east-1 ${STASH_AUTH} login --supabase + # Agent triggers auth; a human completes it in the browser: + ${STASH_AUTH} login --region us-east-1 --json `.trim() function referrerFromFlags(flags: Record): string | undefined { @@ -29,6 +38,7 @@ function referrerFromFlags(flags: Record): string | undefined { export async function authCommand( args: string[], flags: Record, + values: Record = {}, ) { const subcommand = args[0] @@ -38,13 +48,14 @@ export async function authCommand( } const referrer = referrerFromFlags(flags) + const json = flags.json ?? false switch (subcommand) { case 'login': { - const region = await selectRegion() - await login(region, referrer) - await bindDevice() + const region = await resolveRegion({ regionFlag: values.region, json }) + await login(region, referrer, { json, open: !flags['no-open'] }) + await bindDevice({ json }) } break default: diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 1a7e375c..96f75574 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,82 +1,144 @@ import auth from '@cipherstash/auth' import * as p from '@clack/prompts' -import { messages } from '../../messages.js' +import { emitJsonError, emitJsonEvent } from './events.js' const { beginDeviceCodeFlow, bindClientDevice } = auth -// TODO: pull from the CTS API -export const regions = [ - { value: 'us-east-1.aws', label: 'us-east-1 (Virginia, USA)' }, - { value: 'us-east-2.aws', label: 'us-east-2 (Ohio, USA)' }, - { value: 'us-west-1.aws', label: 'us-west-1 (California, USA)' }, - { value: 'us-west-2.aws', label: 'us-west-2 (Oregon, USA)' }, - { value: 'ap-southeast-2.aws', label: 'ap-southeast-2 (Sydney, Australia)' }, - { value: 'eu-central-1.aws', label: 'eu-central-1 (Frankfurt, Germany)' }, - { value: 'eu-west-1.aws', label: 'eu-west-1 (Dublin, Ireland)' }, -] - -export async function selectRegion(): Promise { - const region = await p.select({ - message: messages.auth.selectRegion, - options: regions, - }) - - if (p.isCancel(region)) { - p.cancel(messages.auth.cancelled) - process.exit(0) - } - - return region +export interface LoginOptions { + /** + * Emit newline-delimited JSON events instead of pretty clack output, so an + * agent can capture the device-code URL as data and trigger the flow for a + * human to complete. Events (one JSON object per line, all on stdout): + * { status: 'authorization_required', userCode, verificationUri, + * verificationUriComplete, expiresIn } — emitted immediately + * { status: 'authorized', expiresAt, expiresAtIso } — on success + * { status: 'error', code?, message } — on failure + */ + json?: boolean + /** + * Whether to auto-open the verification URL in the user's browser. Defaults + * to true in interactive mode, but false when `json` is set: a `--json` run + * is the agent-trigger path, where the human — not the agent host — opens the + * URL, so auto-opening a browser on the agent's machine is wrong. `--no-open` + * forces it false in interactive mode too. + */ + open?: boolean } -export async function login(region: string, _referrer: string | undefined) { - const s = p.spinner() +export async function login( + region: string, + _referrer: string | undefined, + opts: LoginOptions = {}, +) { + const json = opts.json ?? false + // Default: open in interactive mode, never in json/agent mode. `--no-open` + // (open === false) overrides in either mode. + const openBrowser = opts.open ?? !json + + // Spinner and pretty logs would corrupt the JSON stream — suppress in json mode. + const s = json ? null : p.spinner() // Must be 'cli' — it's the only OAuth client_id registered with CTS. // Passing anything else (e.g. `cli-supabase`) causes INVALID_CLIENT. // As of `@cipherstash/auth` `0.41`, the device-code flow returns - // `Result` instead of throwing — unwrap each step. + // `Result` instead of throwing — unwrap each step, and + // surface the failure `type` (machine-readable) + message on the JSON stream. const pending = await beginDeviceCodeFlow(region, 'cli') if (pending.failure) { - p.log.error(pending.failure.error.message) + if (json) { + emitJsonError( + pending.failure.type ?? 'begin_failed', + pending.failure.error.message, + ) + } else { + p.log.error(pending.failure.error.message) + } process.exit(1) } const flow = pending.data - p.log.info(`Your code is: ${flow.userCode}`) - p.log.info(`Visit: ${flow.verificationUriComplete}`) - p.log.info(`Code expires in: ${flow.expiresIn}s`) + if (json) { + // The "trigger" event — an agent surfaces verificationUriComplete to the + // human, who completes authorization in their browser. + emitJsonEvent({ + status: 'authorization_required', + userCode: flow.userCode, + verificationUri: flow.verificationUri, + verificationUriComplete: flow.verificationUriComplete, + expiresIn: flow.expiresIn, + }) + } else { + p.log.info(`Your code is: ${flow.userCode}`) + p.log.info(`Visit: ${flow.verificationUriComplete}`) + p.log.info(`Code expires in: ${flow.expiresIn}s`) + } - const opened = flow.openInBrowser() - if (opened.failure || !opened.data) { - p.log.warn('Could not open browser — please visit the URL above manually.') + if (openBrowser) { + const opened = flow.openInBrowser() + if ((opened.failure || !opened.data) && !json) { + p.log.warn( + 'Could not open browser — please visit the URL above manually.', + ) + } } - s.start('Waiting for authorization...') - const auth = await flow.pollForToken() - if (auth.failure) { - s.stop('Authentication failed!') - p.log.error(auth.failure.error.message) + s?.start('Waiting for authorization...') + const authResult = await flow.pollForToken() + if (authResult.failure) { + s?.stop('Authorization failed.') + if (json) { + emitJsonError( + authResult.failure.type ?? 'poll_failed', + authResult.failure.error.message, + ) + } else { + p.log.error(authResult.failure.error.message) + } process.exit(1) } - s.stop('Authenticated!') + s?.stop('Authenticated!') - p.log.info( - `Token expires at: ${new Date(auth.data.expiresAt * 1000).toISOString()}`, - ) + const expiresAtIso = new Date(authResult.data.expiresAt * 1000).toISOString() + if (json) { + emitJsonEvent({ + status: 'authorized', + expiresAt: authResult.data.expiresAt, + expiresAtIso, + }) + } else { + p.log.info(`Token expires at: ${expiresAtIso}`) + } +} + +export interface BindDeviceOptions { + /** Emit a JSON event instead of pretty clack output. */ + json?: boolean } -export async function bindDevice() { - const s = p.spinner() - s.start('Binding device to the default Keyset...') +export async function bindDevice(opts: BindDeviceOptions = {}) { + const json = opts.json ?? false + const s = json ? null : p.spinner() + s?.start('Binding device to the default Keyset...') // `bindClientDevice()` returns `Result` as of // `@cipherstash/auth` `0.41` — a failure no longer throws. const result = await bindClientDevice() if (result.failure) { - s.stop('Failed to bind your device to the default Keyset!') - p.log.error(result.failure.error.message) + if (json) { + emitJsonError( + result.failure.type ?? 'bind_failed', + result.failure.error.message, + ) + } else { + s?.stop('Failed to bind your device to the default Keyset!') + p.log.error(result.failure.error.message) + } process.exit(1) } - s.stop('Your device has been bound to the default Keyset!') + + if (json) { + emitJsonEvent({ status: 'device_bound' }) + } else { + s?.stop('Your device has been bound to the default Keyset!') + } } diff --git a/packages/cli/src/commands/auth/region.ts b/packages/cli/src/commands/auth/region.ts new file mode 100644 index 00000000..894fd512 --- /dev/null +++ b/packages/cli/src/commands/auth/region.ts @@ -0,0 +1,133 @@ +/** + * Region selection — the one interactive prompt that every auth flow (and, + * transitively, `stash init`) hits before any network I/O. + * + * This module deliberately imports **no** native code (`@cipherstash/auth`). + * The pure helpers (`normalizeRegion`, `regionSlugs`) and the resolution + * policy (`resolveRegion`) can therefore be unit-tested under the fast + * `vitest.config.ts` suite without loading a platform binary. `login.ts` + * re-exports everything here so existing `import … from '../auth/login.js'` + * call sites keep working unchanged. + */ +import * as p from '@clack/prompts' +import { messages } from '../../messages.js' + +/** Env var an agent / CI job can set to skip the interactive region picker. */ +export const REGION_ENV_VAR = 'STASH_REGION' + +// TODO: pull from the CTS API. `value` is the canonical `.aws` form the +// auth server expects; `label` is the human-facing picker entry. +export const regions = [ + { value: 'us-east-1.aws', label: 'us-east-1 (Virginia, USA)' }, + { value: 'us-east-2.aws', label: 'us-east-2 (Ohio, USA)' }, + { value: 'us-west-1.aws', label: 'us-west-1 (California, USA)' }, + { value: 'us-west-2.aws', label: 'us-west-2 (Oregon, USA)' }, + { value: 'ap-southeast-2.aws', label: 'ap-southeast-2 (Sydney, Australia)' }, + { value: 'eu-central-1.aws', label: 'eu-central-1 (Frankfurt, Germany)' }, + { value: 'eu-west-1.aws', label: 'eu-west-1 (Dublin, Ireland)' }, +] + +/** The short slugs (`us-east-1`, …) — what a human types and what we echo. */ +export function regionSlugs(): string[] { + return regions.map((r) => r.value.replace(/\.aws$/, '')) +} + +/** + * Normalize a user-supplied region to the canonical `.aws` value the + * auth server expects, or return `null` when it isn't a known region. + * + * Accepts both the short slug (`us-east-1`) and the canonical form + * (`us-east-1.aws`), case-insensitively and whitespace-trimmed, so a value + * copied from the picker label or an env var both resolve. + */ +export function normalizeRegion(input: string): string | null { + const trimmed = input.trim().toLowerCase() + if (!trimmed) return null + const candidate = trimmed.endsWith('.aws') ? trimmed : `${trimmed}.aws` + return regions.some((r) => r.value === candidate) ? candidate : null +} + +/** True when `CI` is set to a truthy spelling. Mirrors the DATABASE_URL resolver. */ +function isCiEnv(): boolean { + const ciVar = process.env.CI?.trim() + return ciVar !== undefined && /^(1|true)$/i.test(ciVar) +} + +/** + * The interactive region picker. Unchanged behaviour — kept as its own + * export because the E2E cancel test targets this prompt (it runs before + * any network I/O, so it's a deterministic assertion point). + */ +export async function selectRegion(): Promise { + const region = await p.select({ + message: messages.auth.selectRegion, + options: regions, + }) + + if (p.isCancel(region)) { + p.cancel(messages.auth.cancelled) + process.exit(0) + } + + return region +} + +export interface ResolveRegionOptions { + /** Value of `--region ` if the user passed one. */ + regionFlag?: string + /** + * Machine-readable mode. When true we never prompt (a picker would corrupt + * the JSON stream) and errors are emitted as a single JSON object rather + * than pretty clack output. + */ + json?: boolean +} + +/** Report a region resolution failure as JSON or human copy, then the caller exits. */ +function reportRegionError(json: boolean, code: string, message: string): void { + if (json) { + console.log(JSON.stringify({ status: 'error', code, message })) + } else { + p.log.error(message) + } +} + +/** + * Resolve the auth region without forcing an interactive prompt. + * + * Precedence: + * 1. `--region ` flag (explicit override). + * 2. `STASH_REGION` env var. + * 3. Interactive picker — only when we have a real TTY, aren't in CI, and + * aren't in `--json` mode. + * 4. Clean `exit(1)` with an actionable message (never a hang). + * + * An explicit-but-unknown region is a hard error (exit 1) in every mode. + */ +export async function resolveRegion( + opts: ResolveRegionOptions = {}, +): Promise { + const json = opts.json ?? false + const explicit = (opts.regionFlag ?? process.env[REGION_ENV_VAR])?.trim() + + if (explicit) { + const normalized = normalizeRegion(explicit) + if (normalized) return normalized + reportRegionError( + json, + 'region_invalid', + messages.auth.regionInvalid(explicit, regionSlugs()), + ) + process.exit(1) + } + + const isInteractive = !json && Boolean(process.stdin.isTTY) && !isCiEnv() + if (isInteractive) return selectRegion() + + reportRegionError( + json, + 'region_required', + messages.auth.regionMissingNonInteractive, + ) + process.exit(1) +} diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 0134f245..1bb19aaa 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -66,7 +66,10 @@ function resolveProvider(flags: Record): InitProvider { return provider } -export async function initCommand(flags: Record) { +export async function initCommand( + flags: Record, + values: Record = {}, +) { const provider = resolveProvider(flags) p.intro('CipherStash Stack Setup') @@ -74,6 +77,13 @@ export async function initCommand(flags: Record) { let state: InitState = {} + // Thread `--region ` through to the authenticate step so init can run + // non-interactively (STASH_REGION works even without this, via the env + // fallback in resolveRegion). + if (values.region) { + state.regionFlag = values.region + } + // Parse --proxy and --no-proxy flags; --proxy wins if both are set if (flags.proxy) { state.usesProxy = true diff --git a/packages/cli/src/commands/init/steps/authenticate.ts b/packages/cli/src/commands/init/steps/authenticate.ts index 9e2a1004..670e35e7 100644 --- a/packages/cli/src/commands/init/steps/authenticate.ts +++ b/packages/cli/src/commands/init/steps/authenticate.ts @@ -1,6 +1,6 @@ import auth from '@cipherstash/auth' import * as p from '@clack/prompts' -import { bindDevice, login, regions, selectRegion } from '../../auth/login.js' +import { bindDevice, login, regions, resolveRegion } from '../../auth/login.js' import type { InitProvider, InitState, InitStep } from '../types.js' const { AutoStrategy } = auth @@ -52,7 +52,10 @@ export const authenticateStep: InitStep = { return { ...state, authenticated: true } } - const region = await selectRegion() + // Honour `--region` / `STASH_REGION` so `stash init` can run + // non-interactively; falls back to the interactive picker in a TTY, or a + // clean error (no hang) in an agent / CI context without a region set. + const region = await resolveRegion({ regionFlag: state.regionFlag }) await login(region, provider.name) await bindDevice() return { ...state, authenticated: true } diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 48d1a21b..cd15ab7a 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -31,6 +31,9 @@ export type InitMode = 'plan' | 'implement' export interface InitState { authenticated?: boolean + /** Region passed via `--region` / `STASH_REGION`. Consumed by the + * authenticate step to skip the interactive region picker. */ + regionFlag?: string /** Resolved DATABASE_URL. Set by resolve-database; threaded into every * downstream step that needs DB access. Never logged or echoed. */ databaseUrl?: string diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 9c21a2ea..4f03cc4b 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -34,6 +34,21 @@ export const messages = { unknownSubcommand: 'Unknown auth command', selectRegion: 'Select a region', cancelled: 'Cancelled.', + /** + * Shown when `--region` / `STASH_REGION` names a region that isn't in + * the known list. `Unknown region` is the stable leader tests assert on; + * the offending value and the valid slugs are appended for the human. + */ + regionInvalid: (value: string, validSlugs: readonly string[]) => + `Unknown region: ${value}. Valid regions: ${validSlugs.join(', ')}.`, + /** + * Shown when no region can be resolved and we're not in an interactive + * TTY (agent / CI / piped stdin, or `--json`). Naming both the flag and + * the env var lets automation discover the escape hatch instead of + * hanging on the picker. + */ + regionMissingNonInteractive: + 'Cannot resolve a region without a prompt. Pass --region or set STASH_REGION (e.g. STASH_REGION=us-east-1).', }, eql: { unknownSubcommand: 'Unknown eql subcommand', diff --git a/packages/cli/tests/e2e/auth-login-cancel.e2e.test.ts b/packages/cli/tests/e2e/auth-login-cancel.e2e.test.ts index 2a25ae2b..1ae28e45 100644 --- a/packages/cli/tests/e2e/auth-login-cancel.e2e.test.ts +++ b/packages/cli/tests/e2e/auth-login-cancel.e2e.test.ts @@ -4,7 +4,12 @@ import { render } from '../helpers/pty.js' describe('stash auth login — interactive cancel', () => { it('shows the region prompt, cancels on ctrl-c, and exits 0', async () => { - const r = render(['auth', 'login']) + // The pty harness defaults `CI=true` so most tests hit the CLI's + // non-interactive code paths. This test is the exception — it exercises + // the interactive region picker, which (like the DATABASE_URL resolver) + // only renders when we're a real TTY *and* not in CI. Override CI back to + // empty so `resolveRegion` takes the interactive branch. + const r = render(['auth', 'login'], { env: { CI: '' } }) // First clack prompt — `selectRegion()` runs synchronously before any // network activity, so this is a deterministic assertion target. diff --git a/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts b/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts new file mode 100644 index 00000000..504efac2 --- /dev/null +++ b/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { messages } from '../../src/messages.js' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * Non-interactive auth. These cases all resolve the region *before* any + * network I/O (an invalid or missing region fails in `resolveRegion`), so + * they're deterministic and never touch the auth server. The happy path + * (valid region → device-code flow) is intentionally not exercised here — + * it would hit the network and block on a human completing the browser step. + * + * The core regression these guard against: in a non-TTY context the region + * picker used to be the only prompt with no escape hatch, so an agent + * running `stash auth login` would hang. It must now exit cleanly instead. + */ + +/** Find the first stdout line that parses as a JSON object, or undefined. */ +function firstJsonLine(stdout: string): Record | undefined { + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed.startsWith('{')) continue + try { + return JSON.parse(trimmed) as Record + } catch { + // not JSON — keep scanning + } + } + return undefined +} + +describe('stash auth login — non-interactive region resolution', () => { + it('exits 1 (no hang) in a non-TTY context with no region', async () => { + const r = await runPiped(['auth', 'login'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + expect(r.stdout + r.stderr).toContain( + messages.auth.regionMissingNonInteractive, + ) + }) + + it('--json with no region emits a JSON region_required error and exits 1', async () => { + const r = await runPiped(['auth', 'login', '--json'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const payload = firstJsonLine(r.stdout) + expect(payload).toMatchObject({ + status: 'error', + code: 'region_required', + }) + }) + + it('--json with an unknown region emits a JSON region_invalid error and exits 1', async () => { + const r = await runPiped( + ['auth', 'login', '--region', 'moon-base-1', '--json'], + { timeoutMs: 8000 }, + ) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const payload = firstJsonLine(r.stdout) + expect(payload).toMatchObject({ + status: 'error', + code: 'region_invalid', + }) + }) + + it('an unknown region (no --json) prints an actionable error and exits 1', async () => { + const r = await runPiped(['auth', 'login', '--region', 'us-east-9'], { + timeoutMs: 8000, + }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + // Stable leader from messages.auth.regionInvalid + the offending value. + expect(r.stdout + r.stderr).toContain('Unknown region') + expect(r.stdout + r.stderr).toContain('us-east-9') + }) +}) + +describe('stash CLI help — non-interactive auth flags are documented', () => { + it('top-level --help lists --region and --json', async () => { + const r = await runPiped(['--help']) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain('--region') + expect(r.stdout).toContain('--json') + expect(r.stdout).toContain('STASH_REGION') + }) + + it('auth --help lists --region, --json and --no-open', async () => { + const r = await runPiped(['auth', '--help']) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain('--region') + expect(r.stdout).toContain('--json') + expect(r.stdout).toContain('--no-open') + }) +}) From 910e2ac24c134cbde9ce945fa88d2eb2f731edd8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 10:38:07 +1000 Subject: [PATCH 2/8] feat(cli): add `stash auth regions` to list available regions First-contact affordance so an agent (or human) can discover valid `--region` / `STASH_REGION` values up front instead of learning them reactively from an error. Grouped under `auth` (region is an auth concern, next to `stash auth login`), matching how AWS/gcloud/Fly group region lists under their domain. - `stash auth regions` prints the region labels (plain stdout, pipe-friendly). - `stash auth regions --json` emits `[{ slug, label }]` for agents. - Backed by a new `regionList()` in the native-free region module; unit + E2E covered. Docs + help updated. --- packages/cli/README.md | 18 ++++++++++ packages/cli/src/bin/main.ts | 1 + .../commands/auth/__tests__/region.test.ts | 30 +++++++++++++++-- packages/cli/src/commands/auth/index.ts | 25 +++++++++++++- packages/cli/src/commands/auth/region.ts | 19 +++++++++++ .../e2e/auth-non-interactive.e2e.test.ts | 33 +++++++++++++++++++ 6 files changed, 123 insertions(+), 3 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 446c8bee..50929b16 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -139,6 +139,24 @@ Errors are emitted as `{"status":"error","code":"…","message":"…"}` and exit --- +### `npx stash auth regions` + +List the regions you can authenticate against — a first-contact affordance so you (or an agent) can discover valid `--region` / `STASH_REGION` values up front instead of learning them from an error. + +```bash +npx stash auth regions # human-readable list +npx stash auth regions --json # machine-readable [{ "slug": "…", "label": "…" }] +``` + +```jsonc +// --json output: +[{"slug":"us-east-1","label":"us-east-1 (Virginia, USA)"}, {"slug":"us-east-2","label":"us-east-2 (Ohio, USA)"}, …] +``` + +> The region list is currently maintained in the CLI. The intended long-term source of truth is the CipherStash region API (tracked by a `TODO` in `src/commands/auth/region.ts`); when that lands, this command and the runtime SDK should both read from it. + +--- + ### `npx stash wizard` Launch the CipherStash AI wizard. Thin wrapper around [`@cipherstash/wizard`](https://www.npmjs.com/package/@cipherstash/wizard) — the wizard ships as a separate npm package so the agent SDK stays out of the `stash` bundle, but you don't need to remember a second tool name. diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index a8933225..2e067b84 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -186,6 +186,7 @@ Examples: ${STASH} impl --target claude-code ${STASH} status ${STASH} auth login + ${STASH} auth regions # list regions valid for --region ${STASH} auth login --region us-east-1 --json # agent triggers; human finishes in browser ${STASH} wizard ${STASH} eql install diff --git a/packages/cli/src/commands/auth/__tests__/region.test.ts b/packages/cli/src/commands/auth/__tests__/region.test.ts index 1859972b..db958442 100644 --- a/packages/cli/src/commands/auth/__tests__/region.test.ts +++ b/packages/cli/src/commands/auth/__tests__/region.test.ts @@ -18,8 +18,13 @@ vi.mock('@clack/prompts', () => ({ log: clack.log, })) -const { normalizeRegion, regionSlugs, resolveRegion, REGION_ENV_VAR } = - await import('../region.js') +const { + normalizeRegion, + regionList, + regionSlugs, + resolveRegion, + REGION_ENV_VAR, +} = await import('../region.js') let originalRegionEnv: string | undefined let originalCi: string | undefined @@ -99,6 +104,27 @@ describe('regionSlugs', () => { }) }) +describe('regionList', () => { + it('returns { slug, label } pairs for every region', () => { + const list = regionList() + expect(list.length).toBe(regionSlugs().length) + for (const entry of list) { + expect(entry.slug).not.toMatch(/\.aws$/) + // Each slug must normalize back to a real region. + expect(normalizeRegion(entry.slug)).toBe(`${entry.slug}.aws`) + // Label is human copy that leads with the slug. + expect(entry.label.startsWith(entry.slug)).toBe(true) + } + }) + + it('includes a known region', () => { + expect(regionList()).toContainEqual({ + slug: 'us-east-1', + label: 'us-east-1 (Virginia, USA)', + }) + }) +}) + describe('resolveRegion — explicit region', () => { it('returns the normalized region from the flag and never prompts', async () => { await expect(resolveRegion({ regionFlag: 'us-west-2' })).resolves.toBe( diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index 700b7fcd..333c78ba 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -1,6 +1,6 @@ import { messages } from '../../messages.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' -import { bindDevice, login, resolveRegion } from './login.js' +import { bindDevice, login, regionList, resolveRegion } from './login.js' const STASH_AUTH = runnerCommand(detectPackageManager(), 'stash auth') @@ -9,6 +9,7 @@ ${messages.auth.usagePrefix}${STASH_AUTH} [options] Commands: login Authenticate with CipherStash + regions List the regions you can authenticate against Options: --region Region to authenticate against (e.g. us-east-1). Skips the @@ -24,6 +25,8 @@ Examples: ${STASH_AUTH} login ${STASH_AUTH} login --region us-east-1 ${STASH_AUTH} login --supabase + ${STASH_AUTH} regions # list available regions + ${STASH_AUTH} regions --json # machine-readable [{slug,label}] # Agent triggers auth; a human completes it in the browser: ${STASH_AUTH} login --region us-east-1 --json `.trim() @@ -35,6 +38,23 @@ function referrerFromFlags(flags: Record): string | undefined { return parts.length > 0 ? parts.join('-') : undefined } +/** + * `stash auth regions` — enumerate the regions valid for `--region` / + * `STASH_REGION`. Plain stdout (no clack chrome) so it pipes cleanly; + * `--json` emits the structured `[{ slug, label }]` an agent can consume. + */ +function printRegions(json: boolean): void { + const list = regionList() + if (json) { + console.log(JSON.stringify(list)) + return + } + console.log('Available regions (pass one to --region or set STASH_REGION):\n') + for (const r of list) { + console.log(` ${r.label}`) + } +} + export async function authCommand( args: string[], flags: Record, @@ -58,6 +78,9 @@ export async function authCommand( await bindDevice({ json }) } break + case 'regions': + printRegions(json) + break default: console.error(`${messages.auth.unknownSubcommand}: ${subcommand}\n`) console.log(HELP) diff --git a/packages/cli/src/commands/auth/region.ts b/packages/cli/src/commands/auth/region.ts index 894fd512..f01afbb5 100644 --- a/packages/cli/src/commands/auth/region.ts +++ b/packages/cli/src/commands/auth/region.ts @@ -32,6 +32,25 @@ export function regionSlugs(): string[] { return regions.map((r) => r.value.replace(/\.aws$/, '')) } +export interface RegionInfo { + /** The value passed to `--region` / `STASH_REGION` (e.g. `us-east-1`). */ + slug: string + /** Human-friendly label, including the location (e.g. `us-east-1 (Virginia, USA)`). */ + label: string +} + +/** + * The full region list as structured data. Backs `stash auth regions` — a + * first-contact affordance so an agent (or human) can discover valid + * `--region` values instead of learning them reactively from an error. + */ +export function regionList(): RegionInfo[] { + return regions.map((r) => ({ + slug: r.value.replace(/\.aws$/, ''), + label: r.label, + })) +} + /** * Normalize a user-supplied region to the canonical `.aws` value the * auth server expects, or return `null` when it isn't a known region. diff --git a/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts b/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts index 504efac2..564bbb3c 100644 --- a/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts +++ b/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts @@ -75,6 +75,39 @@ describe('stash auth login — non-interactive region resolution', () => { }) }) +describe('stash auth regions — list available regions', () => { + it('prints the region labels and exits 0', async () => { + const r = await runPiped(['auth', 'regions']) + expect(r.exitCode).toBe(0) + // A couple of known regions should appear in the human output. + expect(r.stdout).toContain('us-east-1') + expect(r.stdout).toContain('ap-southeast-2') + }) + + it('--json emits an array of { slug, label } objects', async () => { + const r = await runPiped(['auth', 'regions', '--json']) + expect(r.exitCode).toBe(0) + const parsed = JSON.parse(r.stdout.trim()) as Array<{ + slug: string + label: string + }> + expect(Array.isArray(parsed)).toBe(true) + expect(parsed.length).toBeGreaterThan(0) + for (const entry of parsed) { + expect(typeof entry.slug).toBe('string') + expect(typeof entry.label).toBe('string') + expect(entry.slug).not.toMatch(/\.aws$/) + } + expect(parsed.map((e) => e.slug)).toContain('us-east-1') + }) + + it('is listed in `auth --help`', async () => { + const r = await runPiped(['auth', '--help']) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain('regions') + }) +}) + describe('stash CLI help — non-interactive auth flags are documented', () => { it('top-level --help lists --region and --json', async () => { const r = await runPiped(['--help']) From 72a33563436ef95df01a9802cebb82c3b4773a5b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 10:44:35 +1000 Subject: [PATCH 3/8] chore: add changeset for non-interactive / agent-friendly CLI (stash minor) --- .changeset/stash-non-interactive-agent-cli.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/stash-non-interactive-agent-cli.md diff --git a/.changeset/stash-non-interactive-agent-cli.md b/.changeset/stash-non-interactive-agent-cli.md new file mode 100644 index 00000000..9e941c11 --- /dev/null +++ b/.changeset/stash-non-interactive-agent-cli.md @@ -0,0 +1,19 @@ +--- +"stash": minor +--- + +Add non-interactive / agent-friendly affordances so `stash init` and +`stash auth login` can run without a TTY (agents, CI, pipes). All changes are +additive — interactive behaviour in a real terminal is unchanged. + +- `--region ` / `STASH_REGION` on `stash auth login` and `stash init` + skip the interactive region picker. An unknown or missing region in a + non-TTY context now exits with an actionable message instead of hanging on + the picker (region resolution mirrors the `DATABASE_URL` resolver's + `TTY && !CI` gate). +- `stash auth login --json` emits newline-delimited device-code events. The + first event (`authorization_required`) carries the verification URL, so an + agent can trigger auth and hand the browser step to a human — only a human + completes it in the browser. `--no-open` suppresses the browser launch. +- `stash auth regions` lists the regions valid for `--region` / `STASH_REGION`; + `stash auth regions --json` emits `[{ slug, label }]` for programmatic use. From 56d5ac0ad2ab60bdfc8a6a3a631735502d15638e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 13:21:58 +1000 Subject: [PATCH 4/8] refactor(cli): address review + test-gap feedback on agent-friendly auth Code review fixes: - --json no longer auto-opens a browser on the agent host (open defaults to !json; help/README updated) - valueless --region now fails with an actionable message instead of a misleading region_required - empty/whitespace --region falls back to STASH_REGION (matches init's guard) - extract shared isCiEnv() into config/tty.ts (dedup vs database-url resolver) - remove login.ts re-export shim; import region symbols from region.js directly - share the NDJSON error envelope via commands/auth/events.ts - derive regionSlugs() from regionList() Test gaps: - new login.test.ts covers login/bindDevice json paths + authErrorCode branches - unit coverage for the empty-flag fallback; e2e coverage for the bare --region guard --- packages/cli/README.md | 6 +- packages/cli/src/bin/main.ts | 12 +- .../src/commands/auth/__tests__/login.test.ts | 173 ++++++++++++++++++ .../commands/auth/__tests__/region.test.ts | 22 +++ packages/cli/src/commands/auth/events.ts | 22 +++ packages/cli/src/commands/auth/index.ts | 17 +- packages/cli/src/commands/auth/region.ts | 42 ++--- .../src/commands/init/steps/authenticate.ts | 3 +- packages/cli/src/config/database-url.ts | 10 +- packages/cli/src/config/tty.ts | 15 ++ packages/cli/src/messages.ts | 7 + .../e2e/auth-non-interactive.e2e.test.ts | 19 ++ 12 files changed, 309 insertions(+), 39 deletions(-) create mode 100644 packages/cli/src/commands/auth/__tests__/login.test.ts create mode 100644 packages/cli/src/commands/auth/events.ts create mode 100644 packages/cli/src/config/tty.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 50929b16..3f723007 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -113,8 +113,8 @@ npx stash auth login [--region ] [--json] [--no-open] | Flag | Description | |------|-------------| | `--region ` | Region to authenticate against (e.g. `us-east-1`). Skips the interactive region picker. Also settable via `STASH_REGION`. | -| `--json` | Emit newline-delimited JSON events instead of prose (see below). Implies non-interactive — never renders the region picker. | -| `--no-open` | Don't auto-open the verification URL in a browser. | +| `--json` | Emit newline-delimited JSON events instead of prose (see below). Implies non-interactive — never renders the region picker, and never auto-opens a browser (the human opens the URL you hand them). | +| `--no-open` | Don't auto-open the verification URL in a browser (already implied by `--json`). | | `--supabase` / `--drizzle` | Track the integration as the referrer. | Saves the token to `~/.cipherstash/auth.json`. Database-touching commands check for this file before running. @@ -124,7 +124,7 @@ Saves the token to `~/.cipherstash/auth.json`. Database-touching commands check The device-code flow is designed so an **agent can trigger** authentication but only a **human completes** it in the browser. Run `auth login --json` in the background and read the first line — `authorization_required` carries the verification URL to hand to the user: ```bash -npx stash auth login --region us-east-1 --json --no-open +npx stash auth login --region us-east-1 --json ``` ```jsonc diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 2e067b84..4214d45e 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -129,11 +129,13 @@ Auth Flags: interactive region picker. Also settable via STASH_REGION. --json Emit newline-delimited JSON events instead of prose. The first event (authorization_required) carries the device - verification URL for a human to open. Implies no prompt — - an agent can trigger auth non-interactively; only a human - can complete it in the browser. Run it in the background, - read the URL from the first line, then hand it to the user. - --no-open Don't auto-open the verification URL in a browser. + verification URL for a human to open. Implies no prompt + and no browser auto-open — an agent can trigger auth + non-interactively; only a human can complete it in the + browser. Run it in the background, read the URL from the + first line, then hand it to the user. + --no-open Don't auto-open the verification URL in a browser + (already implied by --json). Plan Flags: --complete-rollout Plan the entire encryption lifecycle (schema-add through drop) diff --git a/packages/cli/src/commands/auth/__tests__/login.test.ts b/packages/cli/src/commands/auth/__tests__/login.test.ts new file mode 100644 index 00000000..fb428e3d --- /dev/null +++ b/packages/cli/src/commands/auth/__tests__/login.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// login.ts imports the native `@cipherstash/auth` binary. Replacing the module +// before load keeps it out of the fast unit suite (mirrors how region.test.ts +// mocks `@clack/prompts`) so the pure json-mode logic is testable in isolation. +const authMock = vi.hoisted(() => ({ + beginDeviceCodeFlow: vi.fn(), + bindClientDevice: vi.fn(), +})) +vi.mock('@cipherstash/auth', () => ({ default: authMock })) +vi.mock('@clack/prompts', () => ({ + spinner: () => ({ start: vi.fn(), stop: vi.fn() }), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, +})) + +const { login, bindDevice } = await import('../login.js') + +/** A device-code `pending` handle with overridable fields. */ +function makePending(over: Record = {}) { + return { + userCode: 'ABCD-1234', + verificationUri: 'https://cs.test/device', + verificationUriComplete: 'https://cs.test/device?code=ABCD-1234', + expiresIn: 900, + openInBrowser: vi.fn(() => true), + pollForToken: vi.fn(async () => ({ expiresAt: 1_700_000_000 })), + ...over, + } +} + +/** Capture NDJSON lines written to stdout as parsed objects. */ +function captureJsonLines(): { lines: () => Record[] } { + const raw: string[] = [] + vi.spyOn(console, 'log').mockImplementation((l) => { + raw.push(l as string) + }) + return { lines: () => raw.map((l) => JSON.parse(l)) } +} + +/** Spy process.exit so the code-under-test unwinds via a throw we can assert. */ +function spyExit() { + return vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('login — json mode', () => { + it('emits authorization_required then authorized', async () => { + const pending = makePending() + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + const out = captureJsonLines() + + await login('us-east-1.aws', undefined, { json: true, open: false }) + + const [first, second] = out.lines() + expect(first).toMatchObject({ + status: 'authorization_required', + userCode: 'ABCD-1234', + verificationUri: 'https://cs.test/device', + verificationUriComplete: 'https://cs.test/device?code=ABCD-1234', + expiresIn: 900, + }) + expect(second).toMatchObject({ + status: 'authorized', + expiresAt: 1_700_000_000, + expiresAtIso: new Date(1_700_000_000 * 1000).toISOString(), + }) + }) + + it('does not auto-open a browser in json mode by default', async () => { + const pending = makePending() + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + captureJsonLines() + + // No `open` passed — json mode must default to not opening (the human, + // not the agent host, opens the URL). + await login('us-east-1.aws', undefined, { json: true }) + + expect(pending.openInBrowser).not.toHaveBeenCalled() + }) + + it('maps a begin failure to a JSON error event (code from AuthError) and exits 1', async () => { + authMock.beginDeviceCodeFlow.mockRejectedValueOnce( + Object.assign(new Error('bad client'), { code: 'INVALID_CLIENT' }), + ) + const exit = spyExit() + const out = captureJsonLines() + + await expect( + login('us-east-1.aws', undefined, { json: true }), + ).rejects.toThrow('process.exit') + + expect(exit).toHaveBeenCalledWith(1) + expect(out.lines()[0]).toMatchObject({ + status: 'error', + code: 'INVALID_CLIENT', + message: 'bad client', + }) + }) + + it('falls back to begin_failed when the error has no code', async () => { + authMock.beginDeviceCodeFlow.mockRejectedValueOnce( + new Error('network down'), + ) + spyExit() + const out = captureJsonLines() + + await expect( + login('us-east-1.aws', undefined, { json: true }), + ).rejects.toThrow('process.exit') + + expect(out.lines()[0]).toMatchObject({ + status: 'error', + code: 'begin_failed', + message: 'network down', + }) + }) + + it('maps a poll failure to a poll_failed JSON error and exits 1', async () => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce( + makePending({ + pollForToken: vi.fn(async () => { + throw new Error('timed out') + }), + }), + ) + const exit = spyExit() + const out = captureJsonLines() + + await expect( + login('us-east-1.aws', undefined, { json: true, open: false }), + ).rejects.toThrow('process.exit') + + expect(exit).toHaveBeenCalledWith(1) + // First line is the trigger event; the error follows on the next line. + const lines = out.lines() + expect(lines[0]).toMatchObject({ status: 'authorization_required' }) + expect(lines[1]).toMatchObject({ status: 'error', code: 'poll_failed' }) + }) +}) + +describe('bindDevice — json mode', () => { + it('emits device_bound on success', async () => { + authMock.bindClientDevice.mockResolvedValueOnce(undefined) + const out = captureJsonLines() + + await bindDevice({ json: true }) + + expect(out.lines()[0]).toEqual({ status: 'device_bound' }) + }) + + it('emits a bind_failed error and exits 1 on failure', async () => { + authMock.bindClientDevice.mockRejectedValueOnce( + new Error('keyset unreachable'), + ) + const exit = spyExit() + const out = captureJsonLines() + + await expect(bindDevice({ json: true })).rejects.toThrow('process.exit') + + expect(exit).toHaveBeenCalledWith(1) + expect(out.lines()[0]).toMatchObject({ + status: 'error', + code: 'bind_failed', + message: 'keyset unreachable', + }) + }) +}) diff --git a/packages/cli/src/commands/auth/__tests__/region.test.ts b/packages/cli/src/commands/auth/__tests__/region.test.ts index db958442..838bfac6 100644 --- a/packages/cli/src/commands/auth/__tests__/region.test.ts +++ b/packages/cli/src/commands/auth/__tests__/region.test.ts @@ -146,6 +146,28 @@ describe('resolveRegion — explicit region', () => { ) }) + it('treats an empty / whitespace flag as absent and falls back to STASH_REGION', async () => { + process.env[REGION_ENV_VAR] = 'eu-west-1' + // `--region ""` / `--region " "` must not shadow the env var — matches + // the `if (values.region)` guard init uses, so both entry points agree. + await expect(resolveRegion({ regionFlag: ' ' })).resolves.toBe( + 'eu-west-1.aws', + ) + expect(clack.select).not.toHaveBeenCalled() + }) + + it('empty flag with no env in a non-TTY context exits 1 (region_required)', async () => { + setTty(undefined) + const exitSpy = spyExit() + await expect(resolveRegion({ regionFlag: '' })).rejects.toThrow( + 'process.exit', + ) + expect(exitSpy).toHaveBeenCalledWith(1) + expect(clack.log.error).toHaveBeenCalledWith( + messages.auth.regionMissingNonInteractive, + ) + }) + it('exits 1 with an actionable error on an unknown explicit region', async () => { const exitSpy = spyExit() await expect(resolveRegion({ regionFlag: 'moon-base-1' })).rejects.toThrow( diff --git a/packages/cli/src/commands/auth/events.ts b/packages/cli/src/commands/auth/events.ts new file mode 100644 index 00000000..ceef85a4 --- /dev/null +++ b/packages/cli/src/commands/auth/events.ts @@ -0,0 +1,22 @@ +/** + * The newline-delimited JSON (NDJSON) event stream that `--json` auth flows + * write to stdout. Centralised here so every emitter — `login`, `bindDevice`, + * and the region resolver — shares one envelope shape; an agent parsing the + * stream sees a consistent contract no matter which stage produced the event. + * + * Native-free by design, so `region.ts` can depend on it without pulling in the + * `@cipherstash/auth` binary and staying unit-testable under the fast suite. + */ + +/** Emit one NDJSON event (one JSON object per line) to stdout. */ +export function emitJsonEvent(event: Record): void { + console.log(JSON.stringify(event)) +} + +/** + * Emit the shared `{ status: 'error', code, message }` envelope. The single + * source of truth for how a failure surfaces on the NDJSON stream. + */ +export function emitJsonError(code: string, message: string): void { + emitJsonEvent({ status: 'error', code, message }) +} diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index 333c78ba..e1f4c52c 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -1,6 +1,7 @@ import { messages } from '../../messages.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' -import { bindDevice, login, regionList, resolveRegion } from './login.js' +import { bindDevice, login } from './login.js' +import { failRegion, regionList, resolveRegion } from './region.js' const STASH_AUTH = runnerCommand(detectPackageManager(), 'stash auth') @@ -16,7 +17,8 @@ Options: interactive picker. Also settable via STASH_REGION. --json Emit newline-delimited JSON events instead of prose. The first event (authorization_required) carries the device - verification URL for a human to open; implies no prompt. + verification URL for a human to open; implies no prompt and + no browser auto-open. --no-open Don't auto-open the verification URL in a browser. --supabase Track Supabase as the referrer --drizzle Track Drizzle as the referrer @@ -73,8 +75,17 @@ export async function authCommand( switch (subcommand) { case 'login': { + // `--region` with no value: parseArgs booleanises a valueless flag, so + // it lands in `flags`, not `values`. Fail with a precise message rather + // than silently falling through to the generic "region required". + if (flags.region) { + failRegion(json, 'region_invalid', messages.auth.regionFlagNeedsValue) + } const region = await resolveRegion({ regionFlag: values.region, json }) - await login(region, referrer, { json, open: !flags['no-open'] }) + await login(region, referrer, { + json, + open: !json && !flags['no-open'], + }) await bindDevice({ json }) } break diff --git a/packages/cli/src/commands/auth/region.ts b/packages/cli/src/commands/auth/region.ts index f01afbb5..739c484d 100644 --- a/packages/cli/src/commands/auth/region.ts +++ b/packages/cli/src/commands/auth/region.ts @@ -5,12 +5,13 @@ * This module deliberately imports **no** native code (`@cipherstash/auth`). * The pure helpers (`normalizeRegion`, `regionSlugs`) and the resolution * policy (`resolveRegion`) can therefore be unit-tested under the fast - * `vitest.config.ts` suite without loading a platform binary. `login.ts` - * re-exports everything here so existing `import … from '../auth/login.js'` - * call sites keep working unchanged. + * `vitest.config.ts` suite without loading a platform binary. Auth call sites + * (`auth/index.ts`, the init authenticate step) import directly from here. */ import * as p from '@clack/prompts' +import { isCiEnv } from '../../config/tty.js' import { messages } from '../../messages.js' +import { emitJsonError } from './events.js' /** Env var an agent / CI job can set to skip the interactive region picker. */ export const REGION_ENV_VAR = 'STASH_REGION' @@ -29,7 +30,7 @@ export const regions = [ /** The short slugs (`us-east-1`, …) — what a human types and what we echo. */ export function regionSlugs(): string[] { - return regions.map((r) => r.value.replace(/\.aws$/, '')) + return regionList().map((r) => r.slug) } export interface RegionInfo { @@ -66,12 +67,6 @@ export function normalizeRegion(input: string): string | null { return regions.some((r) => r.value === candidate) ? candidate : null } -/** True when `CI` is set to a truthy spelling. Mirrors the DATABASE_URL resolver. */ -function isCiEnv(): boolean { - const ciVar = process.env.CI?.trim() - return ciVar !== undefined && /^(1|true)$/i.test(ciVar) -} - /** * The interactive region picker. Unchanged behaviour — kept as its own * export because the E2E cancel test targets this prompt (it runs before @@ -102,13 +97,18 @@ export interface ResolveRegionOptions { json?: boolean } -/** Report a region resolution failure as JSON or human copy, then the caller exits. */ -function reportRegionError(json: boolean, code: string, message: string): void { +/** Report a region resolution failure as JSON or human copy, then exit non-zero. */ +export function failRegion( + json: boolean, + code: string, + message: string, +): never { if (json) { - console.log(JSON.stringify({ status: 'error', code, message })) + emitJsonError(code, message) } else { p.log.error(message) } + process.exit(1) } /** @@ -127,26 +127,24 @@ export async function resolveRegion( opts: ResolveRegionOptions = {}, ): Promise { const json = opts.json ?? false - const explicit = (opts.regionFlag ?? process.env[REGION_ENV_VAR])?.trim() + // Treat an empty / whitespace `--region` as "not provided" so the + // STASH_REGION fallback still applies — matches the `if (values.region)` + // guard `init` uses, so both entry points agree. + const flag = opts.regionFlag?.trim() + const explicit = flag || process.env[REGION_ENV_VAR]?.trim() if (explicit) { const normalized = normalizeRegion(explicit) if (normalized) return normalized - reportRegionError( + failRegion( json, 'region_invalid', messages.auth.regionInvalid(explicit, regionSlugs()), ) - process.exit(1) } const isInteractive = !json && Boolean(process.stdin.isTTY) && !isCiEnv() if (isInteractive) return selectRegion() - reportRegionError( - json, - 'region_required', - messages.auth.regionMissingNonInteractive, - ) - process.exit(1) + failRegion(json, 'region_required', messages.auth.regionMissingNonInteractive) } diff --git a/packages/cli/src/commands/init/steps/authenticate.ts b/packages/cli/src/commands/init/steps/authenticate.ts index 670e35e7..624f860a 100644 --- a/packages/cli/src/commands/init/steps/authenticate.ts +++ b/packages/cli/src/commands/init/steps/authenticate.ts @@ -1,6 +1,7 @@ import auth from '@cipherstash/auth' import * as p from '@clack/prompts' -import { bindDevice, login, regions, resolveRegion } from '../../auth/login.js' +import { bindDevice, login } from '../../auth/login.js' +import { regions, resolveRegion } from '../../auth/region.js' import type { InitProvider, InitState, InitStep } from '../types.js' const { AutoStrategy } = auth diff --git a/packages/cli/src/config/database-url.ts b/packages/cli/src/config/database-url.ts index 2e9c293d..94d66da6 100644 --- a/packages/cli/src/config/database-url.ts +++ b/packages/cli/src/config/database-url.ts @@ -41,6 +41,7 @@ import * as p from '@clack/prompts' import { detectSupabaseProject } from '../commands/db/detect.js' import { detectPackageManager, runnerCommand } from '../commands/init/utils.js' import { messages } from '../messages.js' +import { isCiEnv } from './tty.js' export interface ResolveDatabaseUrlOptions { /** Value of `--database-url` if the user passed one. */ @@ -204,11 +205,10 @@ export async function resolveDatabaseUrl( } } - // 4. Interactive prompt — skipped in CI / non-TTY. - // Accept the common CI-truthy spellings (`true`, `1`, case-insensitive) - // since not every CI provider sets `CI=true` exactly. - const ciVar = process.env.CI?.trim() - const isCi = ciVar !== undefined && /^(1|true)$/i.test(ciVar) + // 4. Interactive prompt — skipped in CI / non-TTY. `isCiEnv` accepts the + // common CI-truthy spellings (`true`, `1`, case-insensitive) since not every + // CI provider sets `CI=true` exactly; shared with the region resolver. + const isCi = isCiEnv() const isInteractive = Boolean(process.stdin.isTTY) && !isCi if (isInteractive) { const fromPrompt = await promptForUrl(cwd) diff --git a/packages/cli/src/config/tty.ts b/packages/cli/src/config/tty.ts new file mode 100644 index 00000000..e8c6cf44 --- /dev/null +++ b/packages/cli/src/config/tty.ts @@ -0,0 +1,15 @@ +/** + * Non-interactive context detection, shared so every resolver gates the same + * way instead of each re-deriving it. + */ + +/** + * True when `CI` is set to a common truthy spelling (`true`/`1`, + * case-insensitive) — not every CI provider sets `CI=true` exactly. Shared by + * the region resolver (`commands/auth/region.ts`) and the DATABASE_URL resolver + * (`config/database-url.ts`) so their non-interactive gating stays identical. + */ +export function isCiEnv(): boolean { + const ciVar = process.env.CI?.trim() + return ciVar !== undefined && /^(1|true)$/i.test(ciVar) +} diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 4f03cc4b..9195e6b7 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -49,6 +49,13 @@ export const messages = { */ regionMissingNonInteractive: 'Cannot resolve a region without a prompt. Pass --region or set STASH_REGION (e.g. STASH_REGION=us-east-1).', + /** + * Shown when `--region` is passed with no value (arg parsing turns a + * valueless flag into a boolean). Distinguishes "you forgot the value" + * from "no region anywhere" so the fix is obvious. + */ + regionFlagNeedsValue: + 'The --region flag needs a value, e.g. --region us-east-1. Run `stash auth regions` to list valid slugs.', }, eql: { unknownSubcommand: 'Unknown eql subcommand', diff --git a/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts b/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts index 564bbb3c..0a7ef108 100644 --- a/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts +++ b/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts @@ -73,6 +73,25 @@ describe('stash auth login — non-interactive region resolution', () => { expect(r.stdout + r.stderr).toContain('Unknown region') expect(r.stdout + r.stderr).toContain('us-east-9') }) + + it('a valueless --region exits 1 with a "needs a value" message (not a hang)', async () => { + // `--region` with nothing after it is booleanised by the arg parser; the + // command must call it out rather than silently treating it as "no region". + const r = await runPiped(['auth', 'login', '--region'], { timeoutMs: 8000 }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + expect(r.stdout + r.stderr).toContain(messages.auth.regionFlagNeedsValue) + }) + + it('--json with a valueless --region emits a JSON region_invalid error and exits 1', async () => { + const r = await runPiped(['auth', 'login', '--region', '--json'], { + timeoutMs: 8000, + }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const payload = firstJsonLine(r.stdout) + expect(payload).toMatchObject({ status: 'error', code: 'region_invalid' }) + }) }) describe('stash auth regions — list available regions', () => { From eba50cdedd621187f0ddb49748d700e9b75aef0d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 14:22:19 +1000 Subject: [PATCH 5/8] test(cli): close auth/init test gaps from review (PR #569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 5 gaps Lindsay flagged: - login: interactive (non-json) browser-open branch — opens once, respects open:false, warns when openInBrowser() returns false - login: non-json rethrow arms for begin + poll failures propagate the original error and do NOT call process.exit - bindDevice: code-present error branch (authErrorCode pass-through), matching login's code-present/absent coverage - authCommand: new index.test.ts asserts values.region / json / open: !json && !--no-open / referrer are forwarded into resolveRegion, login, bindDevice (+ valueless --region fails fast before login) - initCommand: new init-command.test.ts asserts state.regionFlag is seeded from values.region before the authenticate step runs login.test.ts's clack mock is hoisted so the spinner + p.log.warn are observable. --- .../src/commands/auth/__tests__/index.test.ts | 92 +++++++++++++++++ .../src/commands/auth/__tests__/login.test.ts | 98 ++++++++++++++++++- .../init/__tests__/init-command.test.ts | 72 ++++++++++++++ 3 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/auth/__tests__/index.test.ts create mode 100644 packages/cli/src/commands/init/__tests__/init-command.test.ts diff --git a/packages/cli/src/commands/auth/__tests__/index.test.ts b/packages/cli/src/commands/auth/__tests__/index.test.ts new file mode 100644 index 00000000..1b1b9bf3 --- /dev/null +++ b/packages/cli/src/commands/auth/__tests__/index.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock the two seams `authCommand` forwards into. region.js and login.js are +// replaced so we can assert exactly what the `login` subcommand hands them — +// the E2E suite only covers the negative resolveRegion paths (which fail before +// any network I/O), so the success-path forwarding is otherwise untested. +const regionMock = vi.hoisted(() => ({ + resolveRegion: vi.fn(async () => 'us-east-1.aws'), + failRegion: vi.fn(() => { + throw new Error('failRegion') + }), + regionList: vi.fn(() => [{ slug: 'us-east-1', label: 'us-east-1 (…)' }]), +})) +vi.mock('../region.js', () => regionMock) + +const loginMock = vi.hoisted(() => ({ + login: vi.fn(async () => {}), + bindDevice: vi.fn(async () => {}), +})) +vi.mock('../login.js', () => loginMock) + +const { authCommand } = await import('../index.js') + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('authCommand login — option forwarding', () => { + it('forwards values.region + json into resolveRegion and login/bindDevice', async () => { + await authCommand(['login'], {}, { region: 'us-east-1' }) + + expect(regionMock.resolveRegion).toHaveBeenCalledWith({ + regionFlag: 'us-east-1', + json: false, + }) + // Resolved region + referrer (none) + open true (not json, no --no-open). + expect(loginMock.login).toHaveBeenCalledWith('us-east-1.aws', undefined, { + json: false, + open: true, + }) + expect(loginMock.bindDevice).toHaveBeenCalledWith({ json: false }) + }) + + it('threads --json through and suppresses the browser open', async () => { + await authCommand(['login'], { json: true }, {}) + + expect(regionMock.resolveRegion).toHaveBeenCalledWith({ + regionFlag: undefined, + json: true, + }) + // json ⇒ open must be false regardless of --no-open. + expect(loginMock.login).toHaveBeenCalledWith('us-east-1.aws', undefined, { + json: true, + open: false, + }) + expect(loginMock.bindDevice).toHaveBeenCalledWith({ json: true }) + }) + + it('honours --no-open on the interactive path (open: false)', async () => { + await authCommand(['login'], { 'no-open': true }, {}) + + expect(loginMock.login).toHaveBeenCalledWith('us-east-1.aws', undefined, { + json: false, + open: false, + }) + }) + + it('derives the referrer from --supabase / --drizzle', async () => { + await authCommand(['login'], { drizzle: true, supabase: true }, {}) + + expect(loginMock.login).toHaveBeenCalledWith( + 'us-east-1.aws', + 'drizzle-supabase', + expect.objectContaining({ json: false }), + ) + }) + + it('fails fast on a valueless --region without calling login', async () => { + // `--region` with no value booleanises into flags; guard must fire first. + await expect(authCommand(['login'], { region: true }, {})).rejects.toThrow( + 'failRegion', + ) + + expect(regionMock.failRegion).toHaveBeenCalledWith( + false, + 'region_invalid', + expect.any(String), + ) + expect(regionMock.resolveRegion).not.toHaveBeenCalled() + expect(loginMock.login).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/commands/auth/__tests__/login.test.ts b/packages/cli/src/commands/auth/__tests__/login.test.ts index fb428e3d..2de59483 100644 --- a/packages/cli/src/commands/auth/__tests__/login.test.ts +++ b/packages/cli/src/commands/auth/__tests__/login.test.ts @@ -8,9 +8,20 @@ const authMock = vi.hoisted(() => ({ bindClientDevice: vi.fn(), })) vi.mock('@cipherstash/auth', () => ({ default: authMock })) + +// Hoisted so the interactive (non-json) path — spinner + `p.log.warn` — is +// observable; a single spinner instance is returned from every `p.spinner()`. +const clack = vi.hoisted(() => { + const spinnerInstance = { start: vi.fn(), stop: vi.fn() } + return { + spinnerInstance, + spinner: vi.fn(() => spinnerInstance), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + } +}) vi.mock('@clack/prompts', () => ({ - spinner: () => ({ start: vi.fn(), stop: vi.fn() }), - log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + spinner: clack.spinner, + log: clack.log, })) const { login, bindDevice } = await import('../login.js') @@ -170,4 +181,87 @@ describe('bindDevice — json mode', () => { message: 'keyset unreachable', }) }) + + it('carries the AuthError .code when the bind failure has one', async () => { + // The code-present branch of `authErrorCode(error) ?? 'bind_failed'` — the + // fallback above covers code-absent; this pins the pass-through. + authMock.bindClientDevice.mockRejectedValueOnce( + Object.assign(new Error('keyset locked'), { code: 'KEYSET_LOCKED' }), + ) + const exit = spyExit() + const out = captureJsonLines() + + await expect(bindDevice({ json: true })).rejects.toThrow('process.exit') + + expect(exit).toHaveBeenCalledWith(1) + expect(out.lines()[0]).toMatchObject({ + status: 'error', + code: 'KEYSET_LOCKED', + message: 'keyset locked', + }) + }) +}) + +describe('login — interactive (non-json) browser open', () => { + it('opens the browser exactly once on the interactive path', async () => { + const pending = makePending() + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + + // No `open` passed: interactive mode (json:false) defaults to opening. + await login('us-east-1.aws', undefined, { json: false }) + + expect(pending.openInBrowser).toHaveBeenCalledTimes(1) + expect(clack.log.warn).not.toHaveBeenCalled() + }) + + it('does not open the browser when open: false on the interactive path', async () => { + const pending = makePending() + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + + await login('us-east-1.aws', undefined, { json: false, open: false }) + + expect(pending.openInBrowser).not.toHaveBeenCalled() + }) + + it('warns (interactive only) when the browser could not be opened', async () => { + const pending = makePending({ openInBrowser: vi.fn(() => false) }) + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + + await login('us-east-1.aws', undefined, { json: false }) + + expect(pending.openInBrowser).toHaveBeenCalledTimes(1) + expect(clack.log.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not open browser'), + ) + }) +}) + +describe('login — interactive (non-json) error propagation', () => { + it('rethrows a begin failure and does NOT call process.exit', async () => { + authMock.beginDeviceCodeFlow.mockRejectedValueOnce(new Error('begin boom')) + const exit = spyExit() + + // Asserting the original message (not 'process.exit') proves the + // interactive path propagated the error instead of exiting. + await expect( + login('us-east-1.aws', undefined, { json: false }), + ).rejects.toThrow('begin boom') + expect(exit).not.toHaveBeenCalled() + }) + + it('rethrows a poll failure and does NOT call process.exit', async () => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce( + makePending({ + pollForToken: vi.fn(async () => { + throw new Error('poll boom') + }), + }), + ) + const exit = spyExit() + + await expect( + login('us-east-1.aws', undefined, { json: false, open: false }), + ).rejects.toThrow('poll boom') + expect(exit).not.toHaveBeenCalled() + }) }) diff --git a/packages/cli/src/commands/init/__tests__/init-command.test.ts b/packages/cli/src/commands/init/__tests__/init-command.test.ts new file mode 100644 index 00000000..893ca718 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/init-command.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { InitState } from '../types.js' + +// `--region` is the non-interactive escape hatch for `stash init`; it must land +// on `state.regionFlag` before the authenticate step runs (that step calls +// `resolveRegion({ regionFlag: state.regionFlag })`). Mock every init step so +// the pipeline is inert and observable — `authenticateStep.run` is the spy we +// assert on; the rest just pass state through. Also keeps native-loading steps +// (`authenticate`, `install-deps`) out of the fast suite. +const authRun = vi.hoisted(() => vi.fn(async (state: InitState) => state)) +const passthrough = { run: async (s: InitState) => s } + +vi.mock('../steps/authenticate.js', () => ({ + authenticateStep: { id: 'authenticate', name: 'Authenticate', run: authRun }, +})) +vi.mock('../steps/resolve-database.js', () => ({ + resolveDatabaseStep: { id: 'resolve-database', ...passthrough }, +})) +vi.mock('../steps/resolve-proxy-choice.js', () => ({ + resolveProxyChoiceStep: { id: 'resolve-proxy-choice', ...passthrough }, +})) +vi.mock('../steps/build-schema.js', () => ({ + buildSchemaStep: { id: 'build-schema', ...passthrough }, +})) +vi.mock('../steps/install-deps.js', () => ({ + installDepsStep: { id: 'install-deps', ...passthrough }, +})) +vi.mock('../steps/install-eql.js', () => ({ + installEqlStep: { id: 'install-eql', ...passthrough }, +})) +vi.mock('../steps/gather-context.js', () => ({ + gatherContextStep: { id: 'gather-context', ...passthrough }, +})) +// `initCommand` may chain into `stash plan`; keep it inert (never reached in the +// non-TTY test env, but mocked so its module graph never loads). +vi.mock('../../plan/index.js', () => ({ planCommand: vi.fn(async () => {}) })) +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + confirm: vi.fn(), + isCancel: vi.fn(() => false), + cancel: vi.fn(), + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +const { initCommand } = await import('../index.js') + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('initCommand — region threading', () => { + it('seeds state.regionFlag from values.region before authenticate runs', async () => { + await initCommand({}, { region: 'us-east-1' }) + + expect(authRun).toHaveBeenCalledTimes(1) + // The authenticate step (first in the pipeline) must see the region. + expect(authRun).toHaveBeenCalledWith( + expect.objectContaining({ regionFlag: 'us-east-1' }), + expect.anything(), + ) + }) + + it('leaves regionFlag unset when --region is absent', async () => { + await initCommand({}, {}) + + expect(authRun).toHaveBeenCalledTimes(1) + const [stateArg] = authRun.mock.calls[0] + expect(stateArg.regionFlag).toBeUndefined() + }) +}) From 5e23384b04bb74e9e9b2f12533cae4ba709083d8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 13:38:06 +1000 Subject: [PATCH 6/8] feat(cli): command-descriptor registry + `stash manifest --json` (CIP-3431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of docs/plans/cli-help-and-manifest.md: a single source of truth for command metadata so help/docs can't drift from the real command set. - src/cli/registry.ts: Flag/CommandDescriptor/CommandGroup types + the full command surface (groups, summaries, flags; long/examples for key commands), populated from the existing HELP surface and per-command flag parsing. - src/cli/manifest.ts: buildManifest(version) → { name, version, groups[] }, the exact contract the docs generator (cipherstash/docs#45) targets. version comes from the CLI's package.json. - `stash manifest --json` emits the structured surface; `stash manifest` prints a grouped human-readable list. Pure metadata — no native binary required. Additive and non-breaking; the top-level HELP string is untouched. Rendering help from the registry (phases 2–4) is the documented follow-on. Tests: unit coverage for buildManifest shape/version/hidden-exclusion; e2e for `manifest --json`, `manifest`, and `--help` listing the command. --- .changeset/stash-cli-manifest.md | 17 + packages/cli/src/bin/main.ts | 8 + .../cli/src/cli/__tests__/manifest.test.ts | 77 +++ packages/cli/src/cli/manifest.ts | 57 ++ packages/cli/src/cli/registry.ts | 525 ++++++++++++++++++ packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/manifest/index.ts | 34 ++ packages/cli/tests/e2e/manifest.e2e.test.ts | 59 ++ 8 files changed, 778 insertions(+) create mode 100644 .changeset/stash-cli-manifest.md create mode 100644 packages/cli/src/cli/__tests__/manifest.test.ts create mode 100644 packages/cli/src/cli/manifest.ts create mode 100644 packages/cli/src/cli/registry.ts create mode 100644 packages/cli/src/commands/manifest/index.ts create mode 100644 packages/cli/tests/e2e/manifest.e2e.test.ts diff --git a/.changeset/stash-cli-manifest.md b/.changeset/stash-cli-manifest.md new file mode 100644 index 00000000..4d9bbe14 --- /dev/null +++ b/.changeset/stash-cli-manifest.md @@ -0,0 +1,17 @@ +--- +"stash": minor +--- + +Add a command-descriptor registry and `stash manifest --json` — a structured, +versioned command surface for the docs generator and agents to consume instead +of scraping `--help`. + +- `stash manifest --json` emits `{ name, version, groups[] }`, where each command + carries its summary, optional long description, examples, and flags. `version` + comes from the CLI's own `package.json`, so a page generated from the manifest + is always stamped with the version it describes. +- `stash manifest` (no flag) prints a grouped, human-readable command list. +- The registry (`src/cli/registry.ts`) is the single source of truth for command + metadata. This is phase 1 of `docs/plans/cli-help-and-manifest.md`; rendering + the top-level and per-command `--help` from the same registry is the + documented follow-on. diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 4214d45e..a0b01f33 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -27,6 +27,7 @@ import { implCommand, initCommand, installCommand, + manifestCommand, planCommand, statusCommand, testConnectionCommand, @@ -89,6 +90,7 @@ Commands: auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) doctor Diagnose install problems (native binaries, runtime) + manifest Print the structured, versioned command surface (--json for docs/agents) eql install Scaffold stash.config.ts (if missing) and install EQL extensions eql upgrade Upgrade EQL extensions to the latest version @@ -195,6 +197,7 @@ Examples: ${STASH} db push ${STASH} schema build ${STASH} doctor + ${STASH} manifest --json # structured command surface for docs / agents `.trim() interface ParsedArgs { @@ -519,6 +522,11 @@ export async function run() { case 'env': await envCommand({ write: flags.write }) break + case 'manifest': + // Pure metadata (no native code) — safe to run anywhere, including when + // the native binary is missing. + manifestCommand({ json: flags.json, version: pkg.version }) + break case 'wizard': { // Forward everything after `stash wizard` verbatim. The wizard package // owns its own flag parsing; we don't try to interpret its surface diff --git a/packages/cli/src/cli/__tests__/manifest.test.ts b/packages/cli/src/cli/__tests__/manifest.test.ts new file mode 100644 index 00000000..1fd44763 --- /dev/null +++ b/packages/cli/src/cli/__tests__/manifest.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { buildManifest } from '../manifest.js' +import { registry } from '../registry.js' + +describe('buildManifest', () => { + it('stamps the name and the passed-in version', () => { + const m = buildManifest('9.9.9') + expect(m.name).toBe('stash') + expect(m.version).toBe('9.9.9') + }) + + it('emits every non-hidden command across all groups', () => { + const m = buildManifest('0.0.0') + const registryCount = registry + .flatMap((g) => g.commands) + .filter((c) => !c.hidden).length + const manifestCount = m.groups.flatMap((g) => g.commands).length + expect(manifestCount).toBe(registryCount) + expect(m.groups.length).toBe(registry.length) + }) + + it('excludes hidden commands', () => { + // No hidden command should ever surface in the manifest. + const hidden = registry + .flatMap((g) => g.commands) + .filter((c) => c.hidden) + .map((c) => c.name) + const names = buildManifest('0.0.0') + .groups.flatMap((g) => g.commands) + .map((c) => c.name) + for (const h of hidden) expect(names).not.toContain(h) + }) + + it('gives every command a non-empty name and summary', () => { + for (const group of buildManifest('0.0.0').groups) { + expect(group.title.length).toBeGreaterThan(0) + for (const cmd of group.commands) { + expect(cmd.name.length).toBeGreaterThan(0) + expect(cmd.summary.length).toBeGreaterThan(0) + } + } + }) + + it('includes the worked-example auth login descriptor with its flags', () => { + const cmds = buildManifest('0.0.0').groups.flatMap((g) => g.commands) + const authLogin = cmds.find((c) => c.name === 'auth login') + expect(authLogin).toBeDefined() + expect(authLogin?.long).toContain('device authorization flow') + const flagNames = authLogin?.flags?.map((f) => f.name) ?? [] + expect(flagNames).toContain('--region') + expect(flagNames).toContain('--json') + expect(flagNames).toContain('--no-open') + }) + + it('surfaces the shared --database-url flag with its env var', () => { + const eqlInstall = buildManifest('0.0.0') + .groups.flatMap((g) => g.commands) + .find((c) => c.name === 'eql install') + const dbUrl = eqlInstall?.flags?.find((f) => f.name === '--database-url') + expect(dbUrl?.env).toBe('DATABASE_URL') + expect(dbUrl?.value).toBe('') + }) + + it('drops undefined optionals so the JSON round-trips cleanly', () => { + const m = buildManifest('1.2.3') + const json = JSON.stringify(m) + expect(JSON.parse(json)).toEqual(m) + // A summary-only command must not carry empty long/examples/flags keys. + const wizard = m.groups + .flatMap((g) => g.commands) + .find((c) => c.name === 'wizard') + expect(wizard).toEqual({ + name: 'wizard', + summary: 'AI-guided encryption setup (reads your codebase)', + }) + }) +}) diff --git a/packages/cli/src/cli/manifest.ts b/packages/cli/src/cli/manifest.ts new file mode 100644 index 00000000..18fbead8 --- /dev/null +++ b/packages/cli/src/cli/manifest.ts @@ -0,0 +1,57 @@ +/** + * `stash manifest --json` — the structured, versioned command surface the docs + * generator (cipherstash/docs#45) and agents consume instead of scraping + * `--help`. Built from the command-descriptor registry so it can't drift from + * the real command set. See `docs/plans/cli-help-and-manifest.md`. + */ +import { type CommandDescriptor, type Flag, registry } from './registry.js' + +/** A flag as it appears in the manifest (registry `Flag`, unchanged shape). */ +export type ManifestFlag = Flag + +/** A command as it appears in the manifest — the descriptor minus `hidden`. */ +export interface ManifestCommand { + name: string + summary: string + long?: string + examples?: string[] + flags?: ManifestFlag[] +} + +export interface ManifestGroup { + title: string + commands: ManifestCommand[] +} + +export interface Manifest { + name: string + version: string + groups: ManifestGroup[] +} + +/** Drop `hidden` and undefined optionals so the JSON is clean and stable. */ +function toManifestCommand(cmd: CommandDescriptor): ManifestCommand { + const out: ManifestCommand = { name: cmd.name, summary: cmd.summary } + if (cmd.long !== undefined) out.long = cmd.long + if (cmd.examples !== undefined) out.examples = cmd.examples + if (cmd.flags !== undefined) out.flags = cmd.flags + return out +} + +/** + * Build the manifest for a given CLI version. `version` is threaded in from the + * CLI's own `package.json`, so a page generated from the manifest is always + * stamped with the exact version it describes. Hidden commands are excluded. + */ +export function buildManifest(version: string): Manifest { + return { + name: 'stash', + version, + groups: registry.map((group) => ({ + title: group.title, + commands: group.commands + .filter((cmd) => !cmd.hidden) + .map(toManifestCommand), + })), + } +} diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts new file mode 100644 index 00000000..5d2a01c4 --- /dev/null +++ b/packages/cli/src/cli/registry.ts @@ -0,0 +1,525 @@ +/** + * The command-descriptor registry — one descriptor per `stash` command, grouped + * for display. This is the single source of truth for command metadata that the + * `stash manifest --json` surface (and, in later phases, `--help` rendering) is + * generated from, so help/docs can't drift from the real command set. + * + * See `docs/plans/cli-help-and-manifest.md`. Phase 1 (this file) is metadata + * only — dispatch in `bin/main.ts` is unchanged; a later phase renders the + * top-level and per-command help from these descriptors and wires `run`. + */ + +/** A single flag/option on a command. */ +export interface Flag { + /** Long form including dashes, e.g. `--supabase`. */ + name: string + /** Value placeholder for value-taking flags, e.g. ``; omit for booleans. */ + value?: string + description: string + /** Default value, when the flag has one worth surfacing. */ + default?: string + /** Env var that also sets this, e.g. `DATABASE_URL`. */ + env?: string +} + +/** A command's metadata. `name` is the full path, e.g. `"auth login"`. */ +export interface CommandDescriptor { + /** Full command path without the `stash` prefix, e.g. `"eql install"`. */ + name: string + /** One-line summary (the `Commands:` text in `--help`). */ + summary: string + /** Rich multi-paragraph help (cobra `Long`). */ + long?: string + /** Curated per-command examples, each WITHOUT the runner prefix, e.g. `"auth login"`. */ + examples?: string[] + flags?: Flag[] + /** Deprecated aliases / commands hidden from help + manifest (e.g. old `db install`). */ + hidden?: boolean +} + +/** A display group of related commands. */ +export interface CommandGroup { + title: string + commands: CommandDescriptor[] +} + +/** + * Shared `--database-url` flag — every db/eql/schema command accepts it, so it's + * declared once and referenced, not copy-pasted. + */ +const DATABASE_URL_FLAG: Flag = { + name: '--database-url', + value: '', + description: + 'Override DATABASE_URL for this run only — never written to disk.', + env: 'DATABASE_URL', +} + +const REGION_FLAG: Flag = { + name: '--region', + value: '', + description: + 'Region to authenticate against (e.g. us-east-1). Skips the interactive region picker.', + env: 'STASH_REGION', +} + +/** + * The registry. Ordered as it should render in help. Populated from the existing + * `HELP` surface in `bin/main.ts` plus the per-command flag parsing in the + * command modules, so it is authoritative for the current CLI. + */ +export const registry: CommandGroup[] = [ + { + title: 'Setup & workflow', + commands: [ + { + name: 'init', + summary: 'Initialize CipherStash for your project', + long: [ + 'Set up CipherStash end-to-end: authenticate, introspect your database,', + 'install dependencies, install EQL, and hand off the rest to your local', + 'coding agent. Every prompt has a non-interactive escape hatch, so init', + 'never blocks waiting on a TTY (CI, agents, pipes).', + ].join('\n'), + examples: [ + 'init', + 'init --supabase', + 'init --prisma-next', + 'init --region us-east-1', + ], + flags: [ + { + name: '--supabase', + description: 'Use Supabase-specific setup flow.', + }, + { + name: '--drizzle', + description: 'Use Drizzle-specific setup flow.', + }, + { + name: '--prisma-next', + description: + 'Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migration apply).', + }, + { + name: '--proxy', + description: 'Query encrypted data via CipherStash Proxy.', + }, + { + name: '--no-proxy', + description: 'Query encrypted data directly via the SDK.', + default: 'true', + }, + { + ...REGION_FLAG, + description: + 'Region to authenticate against (e.g. us-east-1). Skips the interactive region picker. Required for non-interactive init when not already logged in.', + }, + ], + }, + { + name: 'plan', + summary: 'Draft a reviewable encryption plan at .cipherstash/plan.md', + examples: ['plan', 'plan --target claude-code'], + flags: [ + { + name: '--complete-rollout', + description: + 'Plan the entire encryption lifecycle (schema-add through drop) in one document. Skips the production-deploy gate; only safe when this database is not backing a deployed application.', + }, + { + name: '--target', + value: '', + description: + 'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts.', + }, + ], + }, + { + name: 'impl', + summary: 'Execute the plan with a local agent', + examples: [ + 'impl', + 'impl --continue-without-plan', + 'impl --target claude-code', + ], + flags: [ + { + name: '--continue-without-plan', + description: + 'Skip planning and go straight to implementation (interactively confirms before proceeding).', + }, + { + name: '--target', + value: '', + description: + 'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts.', + }, + ], + }, + { + name: 'status', + summary: 'Displays implementation status', + flags: [ + { + name: '--quest', + description: + 'Force the quest-log output (emoji + progress bars) even in non-TTY contexts.', + }, + { + name: '--plain', + description: 'Force the plain-text output even in TTY contexts.', + }, + { + name: '--json', + description: 'Emit a structured JSON document instead.', + }, + ], + }, + { + name: 'wizard', + summary: 'AI-guided encryption setup (reads your codebase)', + }, + { + name: 'doctor', + summary: 'Diagnose install problems (native binaries, runtime)', + }, + { + name: 'manifest', + summary: 'Print the structured, versioned command surface', + long: [ + 'Emit the CLI command surface as data. `--json` produces the machine-', + 'readable manifest the docs generator and agents consume; without it a', + 'grouped command list is printed. The manifest is stamped with the CLI', + 'version, so a page generated from it always names the version it describes.', + ].join('\n'), + examples: ['manifest --json', 'manifest'], + flags: [ + { + name: '--json', + description: + 'Emit the structured JSON manifest instead of a text list.', + }, + ], + }, + ], + }, + { + title: 'Auth', + commands: [ + { + name: 'auth login', + summary: 'Authenticate with CipherStash', + long: [ + 'Runs the OAuth 2.0 device authorization flow:', + '1. Pick a region for your workspace.', + '2. Approve in the browser — the URL is printed, so it works over SSH/headless.', + '3. The CLI polls until you approve, then stores a short-lived token.', + "4. Your device is bound to the workspace's default keyset, so later", + ' commands authenticate without a fresh login.', + ].join('\n'), + examples: [ + 'auth login', + 'auth login --region us-east-1', + 'auth login --supabase', + 'auth login --region us-east-1 --json', + ], + flags: [ + REGION_FLAG, + { + name: '--json', + description: + 'Emit newline-delimited JSON events instead of prose. The first event (authorization_required) carries the device verification URL for a human to open. Implies no prompt and no browser auto-open.', + }, + { + name: '--no-open', + description: + "Don't auto-open the verification URL in a browser (already implied by --json).", + }, + { + name: '--supabase', + description: 'Track Supabase as the referrer.', + }, + { name: '--drizzle', description: 'Track Drizzle as the referrer.' }, + ], + }, + { + name: 'auth regions', + summary: 'List the regions you can authenticate against', + examples: ['auth regions', 'auth regions --json'], + flags: [ + { + name: '--json', + description: + 'Emit machine-readable [{ slug, label }] instead of a text list.', + }, + ], + }, + ], + }, + { + title: 'EQL', + commands: [ + { + name: 'eql install', + summary: + 'Scaffold stash.config.ts (if missing) and install EQL extensions', + flags: [ + { + name: '--force', + description: 'Reinstall / overwrite even if already installed.', + }, + { + name: '--dry-run', + description: 'Show what would happen without making changes.', + }, + { + name: '--supabase', + description: + 'Use Supabase-compatible mode (auto-detected from DATABASE_URL).', + }, + { + name: '--drizzle', + description: + 'Generate a Drizzle migration instead of direct install (auto-detected from project).', + }, + { + name: '--migration', + description: + 'Write a Supabase migration file instead of running SQL directly (requires --supabase).', + }, + { + name: '--direct', + description: + 'Run the SQL directly against the database (requires --supabase; mutually exclusive with --migration).', + }, + { + name: '--migrations-dir', + value: '', + description: + 'Override the Supabase migrations directory (requires --supabase).', + default: 'supabase/migrations', + }, + { + name: '--exclude-operator-family', + description: 'Skip operator family creation.', + }, + { + name: '--eql-version', + value: '<2|3>', + description: + 'EQL generation to target. v3 is the native eql_v3.* domain schema (direct install only for now).', + default: '2', + }, + { + name: '--latest', + description: 'Fetch the latest EQL from GitHub (v2 only).', + }, + DATABASE_URL_FLAG, + ], + }, + { + name: 'eql upgrade', + summary: 'Upgrade EQL extensions to the latest version', + flags: [ + { + name: '--dry-run', + description: 'Show what would happen without making changes.', + }, + { + name: '--supabase', + description: 'Use Supabase-compatible mode.', + }, + { + name: '--exclude-operator-family', + description: 'Skip operator family creation.', + }, + { + name: '--eql-version', + value: '<2|3>', + description: 'EQL generation to target.', + default: '2', + }, + { + name: '--latest', + description: 'Fetch the latest EQL from GitHub (v2 only).', + }, + DATABASE_URL_FLAG, + ], + }, + { + name: 'eql status', + summary: 'Show EQL installation status', + flags: [DATABASE_URL_FLAG], + }, + ], + }, + { + title: 'Database', + commands: [ + { + name: 'db push', + summary: + 'Push encryption schema (writes pending if active config already exists)', + flags: [ + { + name: '--dry-run', + description: 'Show what would happen without making changes.', + }, + DATABASE_URL_FLAG, + ], + }, + { + name: 'db activate', + summary: + 'Promote pending → active without renames (use after additive db push)', + flags: [DATABASE_URL_FLAG], + }, + { + name: 'db validate', + summary: 'Validate encryption schema', + flags: [ + { + name: '--supabase', + description: 'Use Supabase-compatible mode.', + }, + { + name: '--exclude-operator-family', + description: 'Skip operator family creation.', + }, + DATABASE_URL_FLAG, + ], + }, + { + name: 'db migrate', + summary: 'Run pending encrypt config migrations', + flags: [DATABASE_URL_FLAG], + }, + { + name: 'db test-connection', + summary: 'Test database connectivity', + flags: [DATABASE_URL_FLAG], + }, + ], + }, + { + title: 'Schema', + commands: [ + { + name: 'schema build', + summary: 'Build an encryption schema from your database', + flags: [ + { + name: '--supabase', + description: 'Use Supabase-compatible mode.', + }, + DATABASE_URL_FLAG, + ], + }, + ], + }, + { + title: 'Encrypt', + commands: [ + { + name: 'encrypt status', + summary: 'Show per-column migration status (phase, progress, drift)', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + ], + }, + { + name: 'encrypt plan', + summary: 'Diff intent (.cipherstash/migrations.json) vs observed state', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + ], + }, + { + name: 'encrypt backfill', + summary: 'Resumably encrypt plaintext into the encrypted column', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + { + name: '--pk-column', + value: '', + description: 'Primary-key column used to page through rows.', + }, + { + name: '--chunk-size', + value: '', + description: 'Rows encrypted per batch.', + }, + { + name: '--encrypted-column', + value: '', + description: 'Destination encrypted column.', + }, + { + name: '--schema-column-key', + value: '', + description: 'Schema key identifying the column config.', + }, + { + name: '--confirm-dual-writes-deployed', + description: + 'Assert the app is dual-writing before backfilling (safety gate).', + }, + { + name: '--force', + description: 'Proceed past non-fatal safety checks.', + }, + ], + }, + { + name: 'encrypt cutover', + summary: 'Rename swap encrypted → primary column', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + { + name: '--proxy-url', + value: '', + description: 'Proxy URL to verify against.', + }, + { + name: '--migrations-dir', + value: '', + description: 'Directory to write the rename migration into.', + }, + ], + }, + { + name: 'encrypt drop', + summary: 'Generate a migration to drop the plaintext column', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + { + name: '--migrations-dir', + value: '', + description: 'Directory to write the drop migration into.', + }, + ], + }, + ], + }, + { + title: 'Experimental', + commands: [ + { + name: 'env', + summary: '(experimental) Print production env vars for deployment', + flags: [ + { + name: '--write', + description: 'Write the vars to a file instead of printing them.', + }, + ], + }, + ], + }, +] diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 6225b683..9069fe67 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -6,6 +6,7 @@ export { upgradeCommand } from './db/upgrade.js' export { envCommand } from './env/index.js' export { implCommand } from './impl/index.js' export { initCommand } from './init/index.js' +export { manifestCommand } from './manifest/index.js' export { planCommand } from './plan/index.js' export { statusCommand } from './status/index.js' export { wizardCommand } from './wizard/index.js' diff --git a/packages/cli/src/commands/manifest/index.ts b/packages/cli/src/commands/manifest/index.ts new file mode 100644 index 00000000..fbaabf05 --- /dev/null +++ b/packages/cli/src/commands/manifest/index.ts @@ -0,0 +1,34 @@ +import { buildManifest } from '../../cli/manifest.js' + +export interface ManifestCommandOptions { + /** Emit the structured JSON manifest instead of the human-readable list. */ + json?: boolean + /** CLI version, threaded from the caller's package.json. */ + version: string +} + +/** + * `stash manifest` — print the CLI's command surface. + * + * `--json` emits the machine-readable manifest (docs generator / agents); + * without it a grouped, human-readable command list is printed. Pure metadata: + * loads no native code, so it runs anywhere `stash` does. + */ +export function manifestCommand(opts: ManifestCommandOptions): void { + const manifest = buildManifest(opts.version) + + if (opts.json) { + console.log(JSON.stringify(manifest, null, 2)) + return + } + + console.log(`stash ${manifest.version}\n`) + for (const group of manifest.groups) { + console.log(`${group.title}:`) + for (const cmd of group.commands) { + console.log(` ${cmd.name.padEnd(20)} ${cmd.summary}`) + } + console.log('') + } + console.log('Run `stash manifest --json` for the machine-readable surface.') +} diff --git a/packages/cli/tests/e2e/manifest.e2e.test.ts b/packages/cli/tests/e2e/manifest.e2e.test.ts new file mode 100644 index 00000000..449b0969 --- /dev/null +++ b/packages/cli/tests/e2e/manifest.e2e.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * `stash manifest` is pure metadata (no network, no native binary), so these run + * fast and deterministically. They guard the contract the docs generator and + * agents depend on: a versioned, grouped, machine-readable command surface. + */ + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const pkg = JSON.parse( + readFileSync(join(__dirname, '../../package.json'), 'utf-8'), +) as { version: string } + +describe('stash manifest --json', () => { + it('emits a versioned, grouped manifest and exits 0', async () => { + const r = await runPiped(['manifest', '--json']) + expect(r.exitCode).toBe(0) + const m = JSON.parse(r.stdout) as { + name: string + version: string + groups: Array<{ + title: string + commands: Array<{ name: string; summary: string }> + }> + } + expect(m.name).toBe('stash') + // Stamped with the CLI's own version, so generated docs name it correctly. + expect(m.version).toBe(pkg.version) + expect(Array.isArray(m.groups)).toBe(true) + expect(m.groups.length).toBeGreaterThan(0) + + const names = m.groups.flatMap((g) => g.commands.map((c) => c.name)) + expect(names).toContain('auth login') + expect(names).toContain('eql install') + expect(names).toContain('manifest') + }) +}) + +describe('stash manifest (no --json)', () => { + it('prints a grouped human-readable list and exits 0', async () => { + const r = await runPiped(['manifest']) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain(`stash ${pkg.version}`) + expect(r.stdout).toContain('auth login') + expect(r.stdout).toContain('--json') + }) +}) + +describe('stash --help', () => { + it('lists the manifest command', async () => { + const r = await runPiped(['--help']) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain('manifest') + }) +}) From 4a523f1cca1dc000cec0cf4e1d174951c9fa0cb4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 14:50:18 +1000 Subject: [PATCH 7/8] fix(cli): correct manifest drift + close test gaps (PR #572 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry-vs-reality fixes (the drift this PR exists to prevent): - encrypt status/plan: drop --table/--column — both dispatch to zero-arg commands (statusCommand()/planCommand()) that ignore them - eql install: add the real --name/--out flags (forwarded to the drizzle path) - db migrate: mark summary "(not yet implemented)" and drop the --database-url flag it never reads (dispatch only prints a not-implemented warning) Cleanup / correctness: - manifest: toManifestCommand now defensively copies examples/flags so the manifest can't alias the registry's shared flag singletons (mutation safety) - registry: extract TABLE/COLUMN/DRY_RUN/EXCLUDE_OPERATOR_FAMILY/SUPABASE_COMPAT shared flag consts (were copy-pasted 3-5x), matching the DATABASE_URL_FLAG pattern - manifest: drop the single-use `ManifestFlag = Flag` alias - soften the "single source of truth" claim (registry docstring + changeset): it's additive; HELP stays authoritative for --help until phase 2 Tests: - buildManifest gains a `groups` injection seam so the hidden-command filter is driven by a stub (was vacuously green against the real registry) - add positive assertions for examples passthrough and the defensive-copy fix --- .changeset/stash-cli-manifest.md | 9 +- .../cli/src/cli/__tests__/manifest.test.ts | 48 +++++-- packages/cli/src/cli/manifest.ts | 34 +++-- packages/cli/src/cli/registry.ts | 131 +++++++++--------- 4 files changed, 135 insertions(+), 87 deletions(-) diff --git a/.changeset/stash-cli-manifest.md b/.changeset/stash-cli-manifest.md index 4d9bbe14..e8221594 100644 --- a/.changeset/stash-cli-manifest.md +++ b/.changeset/stash-cli-manifest.md @@ -11,7 +11,8 @@ of scraping `--help`. comes from the CLI's own `package.json`, so a page generated from the manifest is always stamped with the version it describes. - `stash manifest` (no flag) prints a grouped, human-readable command list. -- The registry (`src/cli/registry.ts`) is the single source of truth for command - metadata. This is phase 1 of `docs/plans/cli-help-and-manifest.md`; rendering - the top-level and per-command `--help` from the same registry is the - documented follow-on. +- The registry (`src/cli/registry.ts`) is intended to become the single source of + truth for command metadata. This is phase 1 of + `docs/plans/cli-help-and-manifest.md`; it is additive — `bin/main.ts` still + hand-maintains the `HELP` string that renders `--help`, so until the documented + follow-on renders `--help` from the registry the two are kept in sync by hand. diff --git a/packages/cli/src/cli/__tests__/manifest.test.ts b/packages/cli/src/cli/__tests__/manifest.test.ts index 1fd44763..51d91b7d 100644 --- a/packages/cli/src/cli/__tests__/manifest.test.ts +++ b/packages/cli/src/cli/__tests__/manifest.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { buildManifest } from '../manifest.js' -import { registry } from '../registry.js' +import { type CommandGroup, registry } from '../registry.js' describe('buildManifest', () => { it('stamps the name and the passed-in version', () => { @@ -19,16 +19,46 @@ describe('buildManifest', () => { expect(m.groups.length).toBe(registry.length) }) - it('excludes hidden commands', () => { - // No hidden command should ever surface in the manifest. - const hidden = registry - .flatMap((g) => g.commands) - .filter((c) => c.hidden) - .map((c) => c.name) - const names = buildManifest('0.0.0') + it('drops commands marked hidden', () => { + // Drive the hidden filter with a stub so coverage doesn't depend on the + // live registry happening to contain a hidden command (it currently + // contains none — a real-registry assertion would be vacuously green). + const groups: CommandGroup[] = [ + { + title: 'T', + commands: [ + { name: 'shown', summary: 's' }, + { name: 'gone', summary: 'g', hidden: true }, + ], + }, + ] + const names = buildManifest('0.0.0', groups) .groups.flatMap((g) => g.commands) .map((c) => c.name) - for (const h of hidden) expect(names).not.toContain(h) + expect(names).toEqual(['shown']) + }) + + it('carries examples through into the manifest', () => { + // The one optional-field passthrough not covered by the auth-login (long + + // flags) or wizard (all-undefined) cases. + const init = buildManifest('0.0.0') + .groups.flatMap((g) => g.commands) + .find((c) => c.name === 'init') + expect(init?.examples).toContain('init --supabase') + }) + + it('defensively copies flags so a consumer cannot corrupt the registry', () => { + const dbUrlOf = (m: ReturnType) => + m.groups + .flatMap((g) => g.commands) + .flatMap((c) => c.flags ?? []) + .find((f) => f.name === '--database-url') + + const first = dbUrlOf(buildManifest('0.0.0')) + expect(first).toBeDefined() + // Mutate a manifest flag; the shared registry singleton must be untouched. + ;(first as { description: string }).description = 'MUTATED' + expect(dbUrlOf(buildManifest('0.0.0'))?.description).not.toBe('MUTATED') }) it('gives every command a non-empty name and summary', () => { diff --git a/packages/cli/src/cli/manifest.ts b/packages/cli/src/cli/manifest.ts index 18fbead8..5df78f08 100644 --- a/packages/cli/src/cli/manifest.ts +++ b/packages/cli/src/cli/manifest.ts @@ -4,10 +4,12 @@ * `--help`. Built from the command-descriptor registry so it can't drift from * the real command set. See `docs/plans/cli-help-and-manifest.md`. */ -import { type CommandDescriptor, type Flag, registry } from './registry.js' - -/** A flag as it appears in the manifest (registry `Flag`, unchanged shape). */ -export type ManifestFlag = Flag +import { + type CommandDescriptor, + type CommandGroup, + type Flag, + registry, +} from './registry.js' /** A command as it appears in the manifest — the descriptor minus `hidden`. */ export interface ManifestCommand { @@ -15,7 +17,7 @@ export interface ManifestCommand { summary: string long?: string examples?: string[] - flags?: ManifestFlag[] + flags?: Flag[] } export interface ManifestGroup { @@ -29,12 +31,17 @@ export interface Manifest { groups: ManifestGroup[] } -/** Drop `hidden` and undefined optionals so the JSON is clean and stable. */ +/** + * Drop `hidden` and undefined optionals, and defensively copy `examples` / + * `flags` so the manifest never aliases the registry's arrays or its shared + * flag singletons (`DATABASE_URL_FLAG`, …) — a consumer that mutates a manifest + * entry must not corrupt the registry or other commands sharing that object. + */ function toManifestCommand(cmd: CommandDescriptor): ManifestCommand { const out: ManifestCommand = { name: cmd.name, summary: cmd.summary } if (cmd.long !== undefined) out.long = cmd.long - if (cmd.examples !== undefined) out.examples = cmd.examples - if (cmd.flags !== undefined) out.flags = cmd.flags + if (cmd.examples !== undefined) out.examples = [...cmd.examples] + if (cmd.flags !== undefined) out.flags = cmd.flags.map((f) => ({ ...f })) return out } @@ -42,12 +49,19 @@ function toManifestCommand(cmd: CommandDescriptor): ManifestCommand { * Build the manifest for a given CLI version. `version` is threaded in from the * CLI's own `package.json`, so a page generated from the manifest is always * stamped with the exact version it describes. Hidden commands are excluded. + * + * `groups` defaults to the real `registry`; it's an injection seam so tests can + * drive the hidden-command filter with a stub instead of depending on whether + * the live registry happens to contain a hidden command. */ -export function buildManifest(version: string): Manifest { +export function buildManifest( + version: string, + groups: CommandGroup[] = registry, +): Manifest { return { name: 'stash', version, - groups: registry.map((group) => ({ + groups: groups.map((group) => ({ title: group.title, commands: group.commands .filter((cmd) => !cmd.hidden) diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 5d2a01c4..3be476c4 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -1,12 +1,13 @@ /** * The command-descriptor registry — one descriptor per `stash` command, grouped - * for display. This is the single source of truth for command metadata that the - * `stash manifest --json` surface (and, in later phases, `--help` rendering) is - * generated from, so help/docs can't drift from the real command set. + * for display. It backs the `stash manifest --json` surface and is *intended* to + * become the single source of truth for command metadata. * - * See `docs/plans/cli-help-and-manifest.md`. Phase 1 (this file) is metadata - * only — dispatch in `bin/main.ts` is unchanged; a later phase renders the - * top-level and per-command help from these descriptors and wires `run`. + * ⚠️ Phase 1 (this file) is additive: `bin/main.ts` still hand-maintains the + * `HELP` string that renders `--help`, so until a later phase renders `--help` + * from these descriptors, the two are maintained separately and MUST be kept in + * sync — a command/flag/summary edit belongs in both places or `--help` and + * `stash manifest` will diverge. See `docs/plans/cli-help-and-manifest.md`. */ /** A single flag/option on a command. */ @@ -63,6 +64,32 @@ const REGION_FLAG: Flag = { env: 'STASH_REGION', } +// Flags shared verbatim across several commands — declared once so a description +// edit lands in one place and can't drift. (`--supabase` only shares the +// "compatible mode" spelling; `init`/`auth` use different wording, kept inline.) +const DRY_RUN_FLAG: Flag = { + name: '--dry-run', + description: 'Show what would happen without making changes.', +} +const EXCLUDE_OPERATOR_FAMILY_FLAG: Flag = { + name: '--exclude-operator-family', + description: 'Skip operator family creation.', +} +const SUPABASE_COMPAT_FLAG: Flag = { + name: '--supabase', + description: 'Use Supabase-compatible mode.', +} +const TABLE_FLAG: Flag = { + name: '--table', + value: '', + description: 'Target table.', +} +const COLUMN_FLAG: Flag = { + name: '--column', + value: '', + description: 'Target column.', +} + /** * The registry. Ordered as it should render in help. Populated from the existing * `HELP` surface in `bin/main.ts` plus the per-command flag parsing in the @@ -269,10 +296,7 @@ export const registry: CommandGroup[] = [ name: '--force', description: 'Reinstall / overwrite even if already installed.', }, - { - name: '--dry-run', - description: 'Show what would happen without making changes.', - }, + DRY_RUN_FLAG, { name: '--supabase', description: @@ -300,10 +324,7 @@ export const registry: CommandGroup[] = [ 'Override the Supabase migrations directory (requires --supabase).', default: 'supabase/migrations', }, - { - name: '--exclude-operator-family', - description: 'Skip operator family creation.', - }, + EXCLUDE_OPERATOR_FAMILY_FLAG, { name: '--eql-version', value: '<2|3>', @@ -315,6 +336,18 @@ export const registry: CommandGroup[] = [ name: '--latest', description: 'Fetch the latest EQL from GitHub (v2 only).', }, + { + name: '--name', + value: '', + description: + 'With --drizzle: name for the generated migration (defaults to a scaffold name).', + }, + { + name: '--out', + value: '', + description: + 'With --drizzle: directory to write the generated migration into.', + }, DATABASE_URL_FLAG, ], }, @@ -322,18 +355,9 @@ export const registry: CommandGroup[] = [ name: 'eql upgrade', summary: 'Upgrade EQL extensions to the latest version', flags: [ - { - name: '--dry-run', - description: 'Show what would happen without making changes.', - }, - { - name: '--supabase', - description: 'Use Supabase-compatible mode.', - }, - { - name: '--exclude-operator-family', - description: 'Skip operator family creation.', - }, + DRY_RUN_FLAG, + SUPABASE_COMPAT_FLAG, + EXCLUDE_OPERATOR_FAMILY_FLAG, { name: '--eql-version', value: '<2|3>', @@ -361,13 +385,7 @@ export const registry: CommandGroup[] = [ name: 'db push', summary: 'Push encryption schema (writes pending if active config already exists)', - flags: [ - { - name: '--dry-run', - description: 'Show what would happen without making changes.', - }, - DATABASE_URL_FLAG, - ], + flags: [DRY_RUN_FLAG, DATABASE_URL_FLAG], }, { name: 'db activate', @@ -379,21 +397,17 @@ export const registry: CommandGroup[] = [ name: 'db validate', summary: 'Validate encryption schema', flags: [ - { - name: '--supabase', - description: 'Use Supabase-compatible mode.', - }, - { - name: '--exclude-operator-family', - description: 'Skip operator family creation.', - }, + SUPABASE_COMPAT_FLAG, + EXCLUDE_OPERATOR_FAMILY_FLAG, DATABASE_URL_FLAG, ], }, { + // Dispatch currently only prints a "not yet implemented" warning and + // reads no flags — describe that rather than advertising a working + // command with a --database-url override it never consumes. name: 'db migrate', - summary: 'Run pending encrypt config migrations', - flags: [DATABASE_URL_FLAG], + summary: 'Run pending encrypt config migrations (not yet implemented)', }, { name: 'db test-connection', @@ -408,13 +422,7 @@ export const registry: CommandGroup[] = [ { name: 'schema build', summary: 'Build an encryption schema from your database', - flags: [ - { - name: '--supabase', - description: 'Use Supabase-compatible mode.', - }, - DATABASE_URL_FLAG, - ], + flags: [SUPABASE_COMPAT_FLAG, DATABASE_URL_FLAG], }, ], }, @@ -422,27 +430,22 @@ export const registry: CommandGroup[] = [ title: 'Encrypt', commands: [ { + // `encrypt status` / `encrypt plan` dispatch to zero-arg commands + // (main.ts calls statusCommand()/planCommand() with no values), so they + // take no flags — don't advertise --table/--column the CLI ignores. name: 'encrypt status', summary: 'Show per-column migration status (phase, progress, drift)', - flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, - ], }, { name: 'encrypt plan', summary: 'Diff intent (.cipherstash/migrations.json) vs observed state', - flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, - ], }, { name: 'encrypt backfill', summary: 'Resumably encrypt plaintext into the encrypted column', flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, + TABLE_FLAG, + COLUMN_FLAG, { name: '--pk-column', value: '', @@ -478,8 +481,8 @@ export const registry: CommandGroup[] = [ name: 'encrypt cutover', summary: 'Rename swap encrypted → primary column', flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, + TABLE_FLAG, + COLUMN_FLAG, { name: '--proxy-url', value: '', @@ -496,8 +499,8 @@ export const registry: CommandGroup[] = [ name: 'encrypt drop', summary: 'Generate a migration to drop the plaintext column', flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, + TABLE_FLAG, + COLUMN_FLAG, { name: '--migrations-dir', value: '', From f55226ac27d8d47d5af18796dd86c49573348655 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 15:19:48 +1000 Subject: [PATCH 8/8] test(cli): update login tests to the 0.41 Result API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase follow-up: login.ts was merged onto @cipherstash/auth 0.41's Result API (begin/poll/openInBrowser/bindClientDevice return { data }/{ failure }, and non-json failures p.log.error + exit(1) instead of throwing). The login tests still used the old throwing-API mocks — rewrite them to the Result shape (failure code from AuthFailure.type) and flip the two non-json cases from rethrow to error+exit(1). --- .../src/commands/auth/__tests__/login.test.ts | 125 ++++++++++-------- 1 file changed, 67 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/commands/auth/__tests__/login.test.ts b/packages/cli/src/commands/auth/__tests__/login.test.ts index 2de59483..d18a9906 100644 --- a/packages/cli/src/commands/auth/__tests__/login.test.ts +++ b/packages/cli/src/commands/auth/__tests__/login.test.ts @@ -3,13 +3,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' // login.ts imports the native `@cipherstash/auth` binary. Replacing the module // before load keeps it out of the fast unit suite (mirrors how region.test.ts // mocks `@clack/prompts`) so the pure json-mode logic is testable in isolation. +// +// As of `@cipherstash/auth` 0.41 the device-code flow returns +// `Result` (`{ data }` on success, `{ failure: { type, error } }` +// on error) instead of throwing — the mocks below mirror that shape. const authMock = vi.hoisted(() => ({ beginDeviceCodeFlow: vi.fn(), bindClientDevice: vi.fn(), })) vi.mock('@cipherstash/auth', () => ({ default: authMock })) -// Hoisted so the interactive (non-json) path — spinner + `p.log.warn` — is +// Hoisted so the interactive (non-json) path — spinner + `p.log.*` — is // observable; a single spinner instance is returned from every `p.spinner()`. const clack = vi.hoisted(() => { const spinnerInstance = { start: vi.fn(), stop: vi.fn() } @@ -26,19 +30,25 @@ vi.mock('@clack/prompts', () => ({ const { login, bindDevice } = await import('../login.js') -/** A device-code `pending` handle with overridable fields. */ -function makePending(over: Record = {}) { +/** A device-code `flow` handle (the `.data` of a successful `beginDeviceCodeFlow`). */ +function makeFlow(over: Record = {}) { return { userCode: 'ABCD-1234', verificationUri: 'https://cs.test/device', verificationUriComplete: 'https://cs.test/device?code=ABCD-1234', expiresIn: 900, - openInBrowser: vi.fn(() => true), - pollForToken: vi.fn(async () => ({ expiresAt: 1_700_000_000 })), + // 0.41: these return Results too. + openInBrowser: vi.fn(() => ({ data: true })), + pollForToken: vi.fn(async () => ({ data: { expiresAt: 1_700_000_000 } })), ...over, } } +/** An AuthFailure Result envelope for the failure arm. */ +function failure(type: string | undefined, message: string) { + return { failure: { type, error: new Error(message) } } +} + /** Capture NDJSON lines written to stdout as parsed objects. */ function captureJsonLines(): { lines: () => Record[] } { const raw: string[] = [] @@ -62,8 +72,8 @@ afterEach(() => { describe('login — json mode', () => { it('emits authorization_required then authorized', async () => { - const pending = makePending() - authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + const flow = makeFlow() + authMock.beginDeviceCodeFlow.mockResolvedValueOnce({ data: flow }) const out = captureJsonLines() await login('us-east-1.aws', undefined, { json: true, open: false }) @@ -84,20 +94,20 @@ describe('login — json mode', () => { }) it('does not auto-open a browser in json mode by default', async () => { - const pending = makePending() - authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + const flow = makeFlow() + authMock.beginDeviceCodeFlow.mockResolvedValueOnce({ data: flow }) captureJsonLines() // No `open` passed — json mode must default to not opening (the human, // not the agent host, opens the URL). await login('us-east-1.aws', undefined, { json: true }) - expect(pending.openInBrowser).not.toHaveBeenCalled() + expect(flow.openInBrowser).not.toHaveBeenCalled() }) - it('maps a begin failure to a JSON error event (code from AuthError) and exits 1', async () => { - authMock.beginDeviceCodeFlow.mockRejectedValueOnce( - Object.assign(new Error('bad client'), { code: 'INVALID_CLIENT' }), + it('maps a begin failure to a JSON error event (code from AuthFailure.type) and exits 1', async () => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce( + failure('INVALID_CLIENT', 'bad client'), ) const exit = spyExit() const out = captureJsonLines() @@ -114,9 +124,9 @@ describe('login — json mode', () => { }) }) - it('falls back to begin_failed when the error has no code', async () => { - authMock.beginDeviceCodeFlow.mockRejectedValueOnce( - new Error('network down'), + it('falls back to begin_failed when the failure carries no type', async () => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce( + failure(undefined, 'network down'), ) spyExit() const out = captureJsonLines() @@ -132,14 +142,12 @@ describe('login — json mode', () => { }) }) - it('maps a poll failure to a poll_failed JSON error and exits 1', async () => { - authMock.beginDeviceCodeFlow.mockResolvedValueOnce( - makePending({ - pollForToken: vi.fn(async () => { - throw new Error('timed out') - }), + it('maps a poll failure to a JSON error (failure type) and exits 1', async () => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce({ + data: makeFlow({ + pollForToken: vi.fn(async () => failure('EXPIRED_TOKEN', 'timed out')), }), - ) + }) const exit = spyExit() const out = captureJsonLines() @@ -151,13 +159,13 @@ describe('login — json mode', () => { // First line is the trigger event; the error follows on the next line. const lines = out.lines() expect(lines[0]).toMatchObject({ status: 'authorization_required' }) - expect(lines[1]).toMatchObject({ status: 'error', code: 'poll_failed' }) + expect(lines[1]).toMatchObject({ status: 'error', code: 'EXPIRED_TOKEN' }) }) }) describe('bindDevice — json mode', () => { it('emits device_bound on success', async () => { - authMock.bindClientDevice.mockResolvedValueOnce(undefined) + authMock.bindClientDevice.mockResolvedValueOnce({ data: undefined }) const out = captureJsonLines() await bindDevice({ json: true }) @@ -165,9 +173,9 @@ describe('bindDevice — json mode', () => { expect(out.lines()[0]).toEqual({ status: 'device_bound' }) }) - it('emits a bind_failed error and exits 1 on failure', async () => { - authMock.bindClientDevice.mockRejectedValueOnce( - new Error('keyset unreachable'), + it('emits a bind_failed error and exits 1 when the failure carries no type', async () => { + authMock.bindClientDevice.mockResolvedValueOnce( + failure(undefined, 'keyset unreachable'), ) const exit = spyExit() const out = captureJsonLines() @@ -182,11 +190,9 @@ describe('bindDevice — json mode', () => { }) }) - it('carries the AuthError .code when the bind failure has one', async () => { - // The code-present branch of `authErrorCode(error) ?? 'bind_failed'` — the - // fallback above covers code-absent; this pins the pass-through. - authMock.bindClientDevice.mockRejectedValueOnce( - Object.assign(new Error('keyset locked'), { code: 'KEYSET_LOCKED' }), + it('carries the AuthFailure type when the bind failure has one', async () => { + authMock.bindClientDevice.mockResolvedValueOnce( + failure('KEYSET_LOCKED', 'keyset locked'), ) const exit = spyExit() const out = captureJsonLines() @@ -204,64 +210,67 @@ describe('bindDevice — json mode', () => { describe('login — interactive (non-json) browser open', () => { it('opens the browser exactly once on the interactive path', async () => { - const pending = makePending() - authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + const flow = makeFlow() + authMock.beginDeviceCodeFlow.mockResolvedValueOnce({ data: flow }) // No `open` passed: interactive mode (json:false) defaults to opening. await login('us-east-1.aws', undefined, { json: false }) - expect(pending.openInBrowser).toHaveBeenCalledTimes(1) + expect(flow.openInBrowser).toHaveBeenCalledTimes(1) expect(clack.log.warn).not.toHaveBeenCalled() }) it('does not open the browser when open: false on the interactive path', async () => { - const pending = makePending() - authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + const flow = makeFlow() + authMock.beginDeviceCodeFlow.mockResolvedValueOnce({ data: flow }) await login('us-east-1.aws', undefined, { json: false, open: false }) - expect(pending.openInBrowser).not.toHaveBeenCalled() + expect(flow.openInBrowser).not.toHaveBeenCalled() }) it('warns (interactive only) when the browser could not be opened', async () => { - const pending = makePending({ openInBrowser: vi.fn(() => false) }) - authMock.beginDeviceCodeFlow.mockResolvedValueOnce(pending) + // openInBrowser resolves `{ data: false }` — the "couldn't open" Result. + const flow = makeFlow({ openInBrowser: vi.fn(() => ({ data: false })) }) + authMock.beginDeviceCodeFlow.mockResolvedValueOnce({ data: flow }) await login('us-east-1.aws', undefined, { json: false }) - expect(pending.openInBrowser).toHaveBeenCalledTimes(1) + expect(flow.openInBrowser).toHaveBeenCalledTimes(1) expect(clack.log.warn).toHaveBeenCalledWith( expect.stringContaining('Could not open browser'), ) }) }) -describe('login — interactive (non-json) error propagation', () => { - it('rethrows a begin failure and does NOT call process.exit', async () => { - authMock.beginDeviceCodeFlow.mockRejectedValueOnce(new Error('begin boom')) +describe('login — interactive (non-json) failure handling', () => { + it('surfaces a begin failure via p.log.error and exits 1 (no throw-through)', async () => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce( + failure('INVALID_CLIENT', 'begin boom'), + ) const exit = spyExit() - // Asserting the original message (not 'process.exit') proves the - // interactive path propagated the error instead of exiting. await expect( login('us-east-1.aws', undefined, { json: false }), - ).rejects.toThrow('begin boom') - expect(exit).not.toHaveBeenCalled() + ).rejects.toThrow('process.exit') + + expect(exit).toHaveBeenCalledWith(1) + expect(clack.log.error).toHaveBeenCalledWith('begin boom') }) - it('rethrows a poll failure and does NOT call process.exit', async () => { - authMock.beginDeviceCodeFlow.mockResolvedValueOnce( - makePending({ - pollForToken: vi.fn(async () => { - throw new Error('poll boom') - }), + it('surfaces a poll failure via p.log.error and exits 1', async () => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce({ + data: makeFlow({ + pollForToken: vi.fn(async () => failure('EXPIRED_TOKEN', 'poll boom')), }), - ) + }) const exit = spyExit() await expect( login('us-east-1.aws', undefined, { json: false, open: false }), - ).rejects.toThrow('poll boom') - expect(exit).not.toHaveBeenCalled() + ).rejects.toThrow('process.exit') + + expect(exit).toHaveBeenCalledWith(1) + expect(clack.log.error).toHaveBeenCalledWith('poll boom') }) })