Skip to content

Commit f188c7a

Browse files
authored
feat(cli): make stash env mint real deployment credentials (rc.2 B3) (#682)
* feat(cli): make `stash env` mint real deployment credentials The command was a gated stub (STASH_EXPERIMENTAL_ENV_CMD) waiting on "the CTS mint endpoint" — which turned out to already exist. It now mints working credentials from the device-code session using the same APIs the dashboard uses, all plain fetch against the session's own service URLs: 1. GET {cts}/api/workspaces → server-authoritative region → CS_WORKSPACE_CRN 2. POST {zerokms}/create-client → CS_CLIENT_ID / CS_CLIENT_KEY (hex-transcoded) 3. POST {cts}/api/access-keys → CS_CLIENT_ACCESS_KEY (member role, always — the CLI deliberately cannot mint admin keys; role is omitted so the server owns the default) Design points: - The ZeroKMS client is created before the access key, so a partial failure leaves an inert client record, never an unaccounted-for live credential. - The key name resolves before any profile/network access, so the non-interactive missing-name failure is credential-free — that is the seam the new pty-less e2e exercises (minting live keys from a dev machine's real session in tests is deliberately impossible). - --write emits .env.production.local with mode 0600 and refuses to overwrite non-interactively; --json emits one minted object or the shared { status: 'error' } envelope. Closes #663 (the wasm/edge credential gap, CIP-2997): edge runtimes that cannot read ~/.cipherstash (containerised supabase functions serve, Workers) now have a supported path — stash env + --env-file/secret store. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * docs(skills): point stash-encryption's CI/prod config at `stash env` The Configuration section listed the four CS_* variables but left how to obtain them to the dashboard. Both the stash-cli and stash-supabase skills now document `stash env`; this closes the loop in the skill the other two reference for configuration. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * docs(skills): agent-driven `auth login` — the operational loop, live-verified Ran the flow for real (agent starts `auth login --json --region us-west-2` backgrounded, human approves the relayed URL): authorization_required → authorized → device_bound, exit 0, session verified via getToken. The event table in stash-cli was already accurate; what was missing was the part that bites an agent — the command blocks polling for up to ~15 minutes, so it must run as a background task with a generous timeout, and success is confirmed from the event stream (never by inspecting ~/.cipherstash). stash-encryption's Local Development section now points agents at the flow. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * fix(cli): harden `stash env` — all 10 findings from the branch review An 8-angle adversarial review of this branch confirmed 10 defects (plus one skill nit); this commit fixes all of them: 1. --write's overwrite decision (refusal AND interactive confirm) now runs BEFORE minting — a declined overwrite previously discarded a live, shown-exactly-once access key, contradicting the file's own contract. 2. Stdout is now actually pipe-clean: every clack call routes chrome to stderr via { output: process.stderr }, so `stash env > prod.env` and pipes into dotenv consumers capture only the block. (clack defaults everything to stdout — the old comment's claim was wrong.) 3. This also fixes prompts rendering into redirected stdout (isInteractive is stdin-only): prompts on stderr stay visible on the terminal. 4. `--write <path>` is now a feature, not a parse trap: parseArgs put the path in values.write leaving flags.write undefined, silently printing secrets to stdout. main.ts passes values.write ?? flags.write and the command accepts a target path. 5. `--name` followed by another flag gets its own name_requires_value diagnostic instead of a false missing_name. 6. `stash env <positional>` is rejected with did-you-mean --name guidance instead of silently vanishing into the subcommand slot. 7. --json + --write now compose: the file is written and the JSON confirmation ({ status: 'written' }) is deliberately secret-free. 8. 0600 is enforced on overwrite too (writeFileSync's mode only applies on creation) via an explicit chmod. 9. All three API responses are zod-validated; non-base64 client_key is refused before Node's lenient decoder can transcode garbage; a service shape change can no longer print undefined into a credentials file. 10. The member role is pinned in the request (wire value verified lowercase in cts-common) AND asserted on the response — a non-member key is refused with revocation guidance, so the documented 'cannot mint admin keys' invariant is enforced, not assumed. 11. All exits go through CliExit (never deep process.exit), so run() records env outcomes for telemetry; cancel paths use CliExit(0). The three divergent dead-code exit trailers are gone. 12. skills/stash-cli: the bare --env-file mention is attributed to `supabase functions serve`, per the manifest-resolution rule. Tests: 21 env unit tests (preflight-before-mint asserted via zero fetch calls; chrome-on-stderr; role pinning; validation refusals; json+write secret-free event; chmod-on-overwrite) and 4 e2e cases (all credential-free pre-mint failures, including asserting error chrome lands on stderr, not stdout). Full suite: 534 unit / 62 e2e green, code:check exit 0. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * fix(cli): address PR #682 review comments (timeout, name validation, doc nits) - apiFetch now carries a 30s AbortSignal.timeout; timeouts surface as request_timeout and connection failures as network_error (naming the host, and the undici cause rather than the opaque 'fetch failed'). A stalled CTS/ZeroKMS endpoint no longer hangs the CLI. (CodeRabbit) - The credential name rejects control characters (invalid_name) — a newline could break out of the dotenv comment line and inject a line into a written credentials file. Enforced pre-mint for both the flag and the interactive prompt. (Copilot) - skills/stash-cli: dotenv language on the output fence (MD040); skills/stash-supabase: keep the example command in one code span. Unit tests: 24 env tests (invalid_name pre-profile, request_timeout, network_error). Suite 537/62 green, code:check clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * fix(cli): address James's review notes on stash env (#682) - Note 2: treat an ABSENT access-key role as member (server default) rather than skipping the check — `(role ?? 'member')` — so the documented 'verified on the response' guarantee doesn't depend on the field always being present. - Note 3: only append the 'name already taken, use --name' hint on 400/409 (where duplicates land), not every non-403 — a 500 no longer carries a misleading suggestion. - Note 4 (defense-in-depth): reject any server-provided emitted value (CRN/region, client id, access key) that contains a control character, closing the dotenv line-injection surface the name guard already covered. clientKey is hex by construction. Tests: absent-role → success; 500 omits the --name hint (keeps the leftover-client note); a newline in the server region → refused, no injected line on stdout. 27 env unit tests, suite 540/62 green. Note 1 (CRN round-trip) needs no code change — the pre-merge live smoke already inits a wasm-inline client with the emitted workspaceCrn (see PR comment); replying there. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent 8b2551a commit f188c7a

10 files changed

Lines changed: 1357 additions & 64 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'stash': minor
3+
---
4+
5+
`stash env` now works: it mints deployment credentials from your device-code
6+
session and prints them as env vars — no dashboard copy-paste. The command
7+
creates a fresh ZeroKMS client and a member-role CipherStash access key (named
8+
via `--name`; the role is pinned in the request and verified on the response —
9+
the CLI deliberately cannot mint admin keys), then emits `CS_WORKSPACE_CRN`,
10+
`CS_CLIENT_ID`, `CS_CLIENT_KEY`, and `CS_CLIENT_ACCESS_KEY`.
11+
12+
Output goes to stdout by default — and stdout is pipe-clean (progress UI is on
13+
stderr), so `stash env --name x > prod.env` and pipes into secret stores are
14+
safe. `--write [path]` writes a file instead (default `.env.production.local`,
15+
enforced mode 0600 even when overwriting), confirming before overwriting and
16+
refusing non-interactively — always *before* anything is minted, so a refusal
17+
never discards the shown-exactly-once access key. `--json` emits NDJSON; with
18+
`--write` the confirmation event is deliberately secret-free. API responses
19+
are schema-validated so a service change can never print `undefined` into a
20+
credentials file. Creating access keys requires the admin role in the
21+
workspace.
22+
23+
This is also the supported credential path for WASM/edge local development
24+
(Supabase Edge Functions, Cloudflare Workers, Deno), where the runtime cannot
25+
read the `~/.cipherstash` device profile: mint a key and feed it via
26+
`supabase functions serve --env-file` or the platform's secret store.
27+
28+
The `STASH_EXPERIMENTAL_ENV_CMD` gate is removed.

packages/cli/src/bin/main.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Commands:
125125
encrypt cutover Rename swap encrypted → primary column (EQL v2 only)
126126
encrypt drop Generate a migration to drop the plaintext column
127127
128-
env (experimental) Print production env vars for deployment
128+
env Mint deployment credentials and print them as env vars
129129
130130
Options:
131131
--help, -h Show help
@@ -532,7 +532,19 @@ async function dispatch(
532532
await runSchemaCommand(subcommand, flags, values)
533533
break
534534
case 'env':
535-
await envCommand({ write: flags.write })
535+
await envCommand({
536+
// parseArgs puts `--write path/x` in values and bare `--write` in
537+
// flags — accept both so a path after --write targets that file
538+
// instead of silently printing secrets to stdout.
539+
write: values.write ?? flags.write,
540+
json: flags.json,
541+
name: values.name,
542+
// `--name` followed by another flag (or nothing) is booleanised by
543+
// parseArgs; surface it as its own error instead of missing_name.
544+
nameMissingValue: flags.name === true,
545+
// `stash env my-app` would otherwise vanish into `subcommand`.
546+
unexpectedArg: subcommand ?? commandArgs[0],
547+
})
536548
break
537549
case 'manifest':
538550
// Pure metadata (no native code) — safe to run anywhere, including when

packages/cli/src/cli/registry.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -530,15 +530,49 @@ export const registry: CommandGroup[] = [
530530
],
531531
},
532532
{
533-
title: 'Experimental',
533+
title: 'Deployment',
534534
commands: [
535535
{
536536
name: 'env',
537-
summary: '(experimental) Print production env vars for deployment',
537+
summary: 'Mint deployment credentials and print them as env vars',
538+
long: [
539+
'Mints a fresh ZeroKMS client and a CipherStash access key from your',
540+
'device-code session (`stash auth login`), then prints the four env',
541+
'vars a deployed app needs: CS_WORKSPACE_CRN, CS_CLIENT_ID,',
542+
'CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY.',
543+
'',
544+
'The access key is created with the member role (the CLI never mints',
545+
'admin keys) and is shown exactly once — pipe the output into your',
546+
'deployment secret store. Creating access keys requires your user to',
547+
'have the admin role in the workspace.',
548+
'',
549+
'Stdout carries only the dotenv block (or the --json events);',
550+
'progress UI goes to stderr, so `stash env --name x > prod.env`',
551+
'and pipes into secret stores are safe.',
552+
].join('\n'),
553+
examples: [
554+
'env --name my-app-prod',
555+
'env --name my-app-prod --write',
556+
'env --name staging --write .env.staging.local',
557+
'env --name edge-dev --json',
558+
],
538559
flags: [
560+
{
561+
name: '--name',
562+
value: '<name>',
563+
description:
564+
'Name for the minted access key and ZeroKMS client. Prompted for interactively; required in non-interactive runs.',
565+
},
539566
{
540567
name: '--write',
541-
description: 'Write the vars to a file instead of printing them.',
568+
value: '[path]',
569+
description:
570+
'Write the vars to a file (default .env.production.local, mode 0600) instead of printing them. An existing file prompts before overwriting — and is refused non-interactively — before anything is minted.',
571+
},
572+
{
573+
name: '--json',
574+
description:
575+
'Emit machine-readable NDJSON (a { status: "minted" } object, or { status: "written" } with --write — deliberately secret-free since the secrets are in the file; failures are { status: "error" }). Implies no prompts.',
542576
},
543577
],
544578
},

0 commit comments

Comments
 (0)