bod-cli 0.10.8 → 0.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,17 +134,98 @@ bod host ./dist --slug my-site # reuse/update an existing slug
134
134
  bod host ./assets --app my-api # persistent app assets
135
135
  ```
136
136
 
137
- ### `bod env list|set|unset|pull <app>`
137
+ ### `bod env list|get|set|unset|pull <app>`
138
138
  Manage environment variables.
139
139
 
140
140
  ```bash
141
- bod env list my-api
141
+ bod env list my-api # values MASKED (key, masked value, length)
142
+ bod env list my-api --reveal # full plaintext, as KEY=VALUE lines
143
+ bod env get my-api DATABASE_URL # ONE value, plaintext, script-clean stdout
144
+ bod env get DATABASE_URL # app from bodify.yaml
142
145
  bod env set my-api DATABASE_URL=postgres://...
143
146
  bod env set my-api -f .env # bulk set from .env file
144
147
  bod env unset my-api OLD_VAR
145
148
  bod env pull my-api # write the resolved env to ./.env (0600)
146
149
  ```
147
150
 
151
+ **`bod env list <app>` masks values by default.** It is the only scope that resolves
152
+ plaintext secrets, and it used to print them straight to the terminal (and into any
153
+ scrollback, log or pipe). The mask shows a short **head and tail** plus a length, enough
154
+ to answer "is this the same key I have locally?" without exposing the value:
155
+
156
+ - **Under 20 codepoints → nothing is revealed, at either end.** A short value is usually
157
+ low-entropy (`production`, `true`, a PIN, a short enum) and a tail plus the length gives
158
+ it away outright — `••••••on` / len 10 *is* `production`. Real secrets are long, so this
159
+ costs the useful case nothing.
160
+ - **20 or more → `min(4, floor(len/10))` at EACH end** — 20 cp → `ab••••••st`, 40 cp or
161
+ more → the 4+4 ceiling (`AKIA••••••WXYZ`). Total revealed stays ~20% of the value and
162
+ never exceeds 8 characters; head and tail can never overlap.
163
+ - **The head is why this is not just "…last four".** Most consoles only ever show you the
164
+ *front* of a credential — AWS `AKIA…`, Stripe `sk_live_…`, a token prefix in a
165
+ dashboard — so a tail-only mask cannot be matched against any of them.
166
+ - An **empty** value renders as a dim `(empty)`, not as dots: `••••••` with `len 0`
167
+ would be indistinguishable from a masked secret.
168
+ - Everything is counted in **Unicode codepoints**, so an emoji is never sliced in half.
169
+ Characters that could corrupt the table are stripped from the tail: C0, DEL, C1
170
+ (U+0080–U+009F — a bare U+009B is a CSI that misaligns every column), U+2028/U+2029,
171
+ and the bidi controls (U+200E/U+200F, U+202A–U+202E, U+2066–U+2069) — U+202E alone
172
+ visually reverses the rest of the row. The strip applies to **both** ends, so an end
173
+ made entirely of such characters simply reveals nothing (never more).
174
+
175
+ The `len` column is that same codepoint count, and is deliberately **exact** — masking
176
+ cannot defeat low entropy anyway (the 20-codepoint threshold is what does), and an exact
177
+ length is what makes the listing useful for **comparing** two values (spotting a
178
+ truncated or whitespace-padded paste). It is not a byte count and not a grapheme count, so a decomposed-accent or flag-emoji string reports more than
179
+ it visibly renders. Masking is **not** TTY-conditional: piping does not switch
180
+ plaintext back on. For scripts use `--reveal`
181
+ (byte-identical to the old `KEY=VALUE` output) or `bod env pull … --stdout`.
182
+ `--reveal` is rejected with `--global/--group/--subs`, which only ever receive
183
+ server-side masked values.
184
+
185
+ **`bod env get <app> KEY`** is the precise single-value read — the alternative to
186
+ `bod env pull --stdout | grep`, which resolves and prints *every* secret just to read
187
+ one. Plaintext is correct here: naming one key **is** the intent to see it, so there is
188
+ no `--reveal`.
189
+
190
+ ```bash
191
+ KEY=$(bod env get my-api DATABASE_URL) # captures exactly the value
192
+ bod env get my-api DATABASE_URL -e prod # --env/-e works as on list/pull
193
+ ```
194
+
195
+ - **stdout is the value and nothing else** — no key name, no colour, no decoration, one
196
+ trailing newline. `$(...)` strips it, and `read -r` needs it. Every diagnostic
197
+ (including the `Using app … from bodify.yaml` notice) goes to **stderr**.
198
+ - **Exit codes are distinct**, because a script must tell "no such key" from "the agent
199
+ is down": **0** = the value was printed, **3** = the key is **not set** (a fact about
200
+ the app), **1** = the read **failed** (bad usage, `App not found: …`, HTTP 500,
201
+ connection refused, or a 200 whose body has no `values` — a server fault reported as
202
+ `Could not read env for this app`, never as "not set"). stdout stays empty on 1 and 3,
203
+ so a failed read can never land in the captured variable.
204
+
205
+ ```bash
206
+ V=$(bod env get my-api KEY); case $? in
207
+ 0) ;; # got it
208
+ 3) V=$DEFAULT ;; # genuinely unset — falling back is correct
209
+ *) exit 1 ;; # the read FAILED — do not fall back
210
+ esac
211
+ ```
212
+ Plain `V=$(bod env get …) || V=$DEFAULT` is the hazard this exists for: it would take
213
+ the default when the agent was merely unreachable.
214
+ - A var set to the **empty string is set**: an empty line, exit 0 (not "missing").
215
+ - One positional is read as the **KEY** (app from `bodify.yaml`); two are `<app> KEY`.
216
+ When a lone positional turns out to be unset, the message says so — `blank is not set
217
+ for this app (read as the KEY; …)` — because the word is often the *app* name the user
218
+ meant. **Three or more positionals are rejected**, never silently dropped.
219
+ - **An unknown or mistyped flag is rejected (exit 1), never ignored.** citty drops a flag
220
+ the command never declared, so `--envv prod` used to return the *default* environment's
221
+ secret at exit 0 — and because the unknown flag swallows its own operand, the
222
+ extra-positional guard never saw it. A dangling `--env` with no value is rejected for
223
+ the same reason (it parses as `''` and silently falls back to the default). `list` and
224
+ `pull` get the same check.
225
+ - `--global/--group/--subs` are **rejected**, like `list --reveal`: those scopes only ever
226
+ hold server-side masks, and a mask captured into a shell variable is a silent fake
227
+ secret. Use `bod env list` there.
228
+
148
229
  **`bod env pull` never clobbers an existing file.** A `.env` is hand-maintained and
149
230
  usually holds local-only credentials the server has never seen — replacing it can
150
231
  destroy the only copy. If the output file already exists, `pull` **refuses**, prints a
package/CLAUDE.md CHANGED
@@ -33,7 +33,7 @@ src/
33
33
  ├── rollback.ts # bod rollback [app]
34
34
  ├── apps.ts # bod apps list|status
35
35
  ├── logs.ts # bod logs <app> [-f]
36
- ├── env.ts # bod env list|set|unset|pull; scoped by <app>/--global/--group (+ --env); set supports -f .env; pull NEVER clobbers (--merge/--force/--stdout)
36
+ ├── env.ts # bod env list|get|set|unset|pull; scoped by <app>/--global/--group (+ --env); list MASKS values (--reveal for plaintext; never TTY-conditional); get prints ONE value plaintext on script-clean stdout (per-app scope only; exit 3 = unset, 1 = read failed); set supports -f .env; pull NEVER clobbers (--merge/--force/--stdout)
37
37
  ├── open.ts # bod open <app>
38
38
  ├── add.ts # bod add <pkg>
39
39
  └── remove.ts # bod remove <pkg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -8,6 +8,7 @@ import { BodClient } from '../client'
8
8
  import { printTable } from '../utils/output'
9
9
  import { resolveAppId, resolveAppName } from '../utils/resolve'
10
10
  import { backupFile } from '../utils/safe-write'
11
+ import { assertKnownFlags } from '../utils/args'
11
12
 
12
13
  // --- Subs PLATFORM PROVIDER scope (mirror bodify subs.secrets.ts) ---
13
14
  // Provider credentials (Stripe secret key, Apple shared secret, Play SA) are money
@@ -134,22 +135,105 @@ function diffEnv(existing: Record<string, string>, incoming: Record<string, stri
134
135
  return { added, changed, removed }
135
136
  }
136
137
 
138
+ /** Characters that must never reach a table cell, in one place.
139
+ * C0 + DEL + C1 (U+0080-U+009F: a bare U+009B is a CSI, which makes cli-table3
140
+ * miscount the cell width and misalign every column after it), the Unicode line and
141
+ * paragraph separators U+2028/U+2029, and the bidi controls (U+200E/U+200F,
142
+ * U+202A-U+202E, U+2066-U+2069) — U+202E alone visually REVERSES the rest of the row,
143
+ * which is how a value lies about which key it belongs to. */
144
+ const UNSAFE_CELL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
145
+
146
+ /** Shown instead of dots for a value that is the empty string: `••••••` with `len 0`
147
+ * is indistinguishable from a masked secret at a glance, and "this var is set to
148
+ * nothing" is a different, actionable fact. */
149
+ export const EMPTY_MARKER = '(empty)'
150
+
151
+ /** A 200 whose body carries no `values` object is a SERVER fault (a proxy error page, an
152
+ * agent that changed shape, a half-written response), NOT an app with no variables.
153
+ * `res.values ?? {}` silently turned that into "no vars" / "KEY is not set" — a false
154
+ * factual claim about the app that a script then acts on (falls back to a default,
155
+ * writes an empty dotenv file, reports the env as clean). Every reader of
156
+ * /apps/:id/env goes through here so the fault is named as a fault, once. */
157
+ function requireValues(res: ResolvedEnv | undefined): Record<string, string> {
158
+ if (!res || typeof res.values !== 'object' || res.values === null || Array.isArray(res.values)) {
159
+ console.error(chalk.red('Could not read env for this app: the server returned an unexpected response (no "values" object). This is a server fault - the variables were NOT read.'))
160
+ process.exit(1)
161
+ }
162
+ return res.values
163
+ }
164
+
165
+ /** Mask a resolved value for on-screen display.
166
+ *
167
+ * The revealed HEAD and TAIL are what make a mask USEFUL — they are how you answer
168
+ * "is the deployed key the same one I have locally?" without printing the secret.
169
+ * A head matters as much as a tail because most platforms only ever show you the
170
+ * FRONT of a credential (AWS `AKIA…`, `sk_live_…`, a token prefix in a dashboard):
171
+ * a tail-only mask cannot be matched against any of them.
172
+ * Reveal only stays safe when the value is long enough that a few characters cannot
173
+ * reconstruct it:
174
+ * len < 20 → 0 revealed, at either end. Anything shorter is likely low-entropy
175
+ * (`production`, `true`, a PIN, a short enum) and even a tail plus the
176
+ * length column de-anonymises it outright — `••••••on` / len 10 IS
177
+ * `production`. A head would give it away sooner still. Real secrets
178
+ * are long, so this costs the useful case nothing.
179
+ * len >= 20 → head = tail = min(4, floor(len/10)) codepoints — 20 → 2+2, 40+ → 4+4.
180
+ * Total revealed stays ~20% of the value and never exceeds 8 characters.
181
+ * Head and tail can never overlap: the budget is at most len/10 per side.
182
+ * Everything is measured in CODEPOINTS, not UTF-16 code units, so an odd budget
183
+ * cannot slice an astral character (emoji, some CJK) in half and emit a lone
184
+ * surrogate. The `len` column reports the same unit.
185
+ * UNSAFE_CELL_CHARS are dropped from BOTH ends so a value cannot break, reverse or
186
+ * misalign the table it is printed in.
187
+ *
188
+ * NOTE on `len`: it is deliberately still exact. Masking cannot defeat low entropy
189
+ * anyway — the length threshold above is the defence — and an exact length is what
190
+ * makes the listing useful for spotting a truncated or whitespace-padded paste. It
191
+ * counts codepoints of the resolved value: NOT a byte count and NOT a grapheme count
192
+ * (a combining-accent/NFD or flag-emoji string reports more than it renders).
193
+ * Compare it between two values; don't read it as "characters as a human counts". */
194
+ export function maskValue(value: string): string {
195
+ if (value === '') return EMPTY_MARKER
196
+ const chars = [...value]
197
+ const reveal = chars.length < 20 ? 0 : Math.min(4, Math.floor(chars.length / 10))
198
+ const safe = (part: string[]) => part.join('').replace(UNSAFE_CELL_CHARS, '')
199
+ const head = reveal ? safe(chars.slice(0, reveal)) : ''
200
+ const tail = reveal ? safe(chars.slice(-reveal)) : ''
201
+ return head + '••••••' + tail
202
+ }
203
+
204
+ /** Codepoint length — the unit the `len` column reports, matching maskValue's budget. */
205
+ export function displayLength(value: string): number {
206
+ return [...value].length
207
+ }
208
+
209
+ const LIST_ARGS = {
210
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
211
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
212
+ reveal: { type: 'boolean', description: 'Print full plaintext values (per-app scope) as KEY=VALUE' },
213
+ global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
214
+ group: { type: 'string', description: 'List masked entries scoped to a group; excludes <app>/--global' },
215
+ subs: { type: 'boolean', description: 'List masked platform-GLOBAL subs provider secrets (__subs__~~global; money creds kept out of app envs)' },
216
+ 'subs-app': { type: 'string', description: 'List masked per-app subs provider secrets for this app name/id (__subs__~<appId>)' },
217
+ } as const
218
+
137
219
  const listCmd = defineCommand({
138
- meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group/--subs: variables in that scope (masked).' },
139
- args: {
140
- app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
141
- env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
142
- global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
143
- group: { type: 'string', description: 'List masked entries scoped to a group; excludes <app>/--global' },
144
- subs: { type: 'boolean', description: 'List masked platform-GLOBAL subs provider secrets (__subs__~~global; money creds kept out of app envs)' },
145
- 'subs-app': { type: 'string', description: 'List masked per-app subs provider secrets for this app name/id (__subs__~<appId>)' },
146
- },
220
+ meta: { name: 'list', description: 'List env vars (values MASKED by default; --reveal prints them in full). --global/--group/--subs: variables in that scope.' },
221
+ args: LIST_ARGS,
147
222
  async run({ args }) {
223
+ // Same hazard as on `get`, one step milder: a mistyped `--envv prod` would list a
224
+ // DIFFERENT environment's keys than the one asked for, at exit 0.
225
+ assertKnownFlags('env list', LIST_ARGS, args)
148
226
  const { url, apiKey } = getResolvedInstance(loadConfig())
149
227
  const client = new BodClient(url, apiKey)
150
228
 
151
229
  const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
152
230
  assertScopeFlags(args.app, !!args.global, args.group, subs, args.env)
231
+ // The other scopes only ever receive server-side `masked` strings — there is no
232
+ // plaintext to reveal there. Say so instead of silently ignoring the flag.
233
+ if (args.reveal && (args.global || args.group || isSubsMode(subs))) {
234
+ console.error(chalk.red('--reveal only applies to the per-app scope; --global/--group/--subs return masked values from the server.'))
235
+ process.exit(1)
236
+ }
153
237
 
154
238
  if (isSubsMode(subs)) {
155
239
  const scopeApp = await buildSubsScopeApp(client, subs)
@@ -186,16 +270,127 @@ const listCmd = defineCommand({
186
270
  return
187
271
  }
188
272
 
189
- // Per-app: show fully resolved values for the chosen environment.
273
+ // Per-app: the only scope that can resolve PLAINTEXT secrets. Mask unless asked.
190
274
  const appId = await resolveAppId(client, resolveAppName(args.app))
191
275
  const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
192
276
  const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
193
- const values = res.values ?? {}
277
+ const values = requireValues(res)
194
278
  if (Object.keys(values).length === 0) {
195
279
  console.log(chalk.dim('No environment variables set.'))
196
280
  return
197
281
  }
198
- for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`)
282
+
283
+ // Machine output: DELIBERATELY not TTY-conditional. Piping into a file or a log
284
+ // is exactly the case where leaking plaintext hurts most, so a pipe must not
285
+ // silently switch the output back to secrets — and a script must see what the
286
+ // human saw. Scripting keeps two explicit, unchanged paths: `--reveal` (emits the
287
+ // pre-masking KEY=VALUE format byte-for-byte) and `bod env pull --stdout`.
288
+ if (args.reveal) {
289
+ for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`)
290
+ return
291
+ }
292
+
293
+ printTable(
294
+ Object.entries(values).map(([key, v]) => {
295
+ const cell = maskValue(v)
296
+ return { key, value: cell === EMPTY_MARKER ? chalk.dim(cell) : cell, len: displayLength(v) }
297
+ }),
298
+ ['key', 'value', 'len'],
299
+ )
300
+ // Hint on stderr so stdout stays a clean, parseable table.
301
+ console.error(chalk.dim(' values masked — `bod env list … --reveal` to print them, or `bod env pull … --stdout`'))
302
+ },
303
+ })
304
+
305
+ const GET_ARGS = {
306
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
307
+ key: { type: 'positional', description: 'Variable name', required: false },
308
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
309
+ global: { type: 'boolean', alias: 'g', description: 'Not supported — that scope only holds masked values (see below)' },
310
+ group: { type: 'string', description: 'Not supported — that scope only holds masked values' },
311
+ subs: { type: 'boolean', description: 'Not supported — that scope only holds masked values' },
312
+ 'subs-app': { type: 'string', description: 'Not supported — that scope only holds masked values' },
313
+ } as const
314
+
315
+ const getCmd = defineCommand({
316
+ meta: { name: 'get', description: "Print ONE variable's resolved plaintext value to stdout — script-friendly (KEY=$(bod env get <app> KEY)). Exit codes: 0 = printed, 3 = key is not set (a fact about the app), 1 = the read FAILED (bad usage, app not found, server/network fault)." },
317
+ args: GET_ARGS,
318
+ async run({ args }) {
319
+ // FIRST, before anything reads a flag: an UNDECLARED flag is silently dropped by
320
+ // citty, so `--envv prod` returned the DEFAULT environment's secret at exit 0 —
321
+ // straight into `K=$(bod env get …)`, with no error anywhere. The unknown flag also
322
+ // swallows its own operand, so the extra-positional guard below never sees it.
323
+ assertKnownFlags('env get', GET_ARGS, args)
324
+ const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
325
+
326
+ // Scope decision: REJECT --global/--group/--subs rather than return the mask.
327
+ // `get` exists to be captured (`K=$(bod env get …)`); returning `••••••abcd` there would
328
+ // silently put a fake secret into a variable — a mask is unmistakable to a human eye
329
+ // and invisible to a script. Same rule as `list --reveal`, which refuses these scopes
330
+ // for exactly the same reason. Because this rejects EVERY non-app scope up front,
331
+ // assertScopeFlags/resolvePositionals have nothing left to decide here: only <app>
332
+ // can be the primary axis, and --env is orthogonal to it.
333
+ if (args.global || args.group || isSubsMode(subs)) {
334
+ console.error(chalk.red('env get only supports the per-app scope; --global/--group/--subs return masked values from the server — use `bod env list` there.'))
335
+ process.exit(1)
336
+ }
337
+
338
+ // Positionals: `<app> KEY`, or a LONE `KEY` (app from bodify.yaml). Unlike `pull`,
339
+ // a lone positional needs no path/name heuristic — citty binds it to `app`, but a
340
+ // `get` with no KEY can do nothing at all, so the only useful reading of one
341
+ // positional is the KEY. Two positionals are always <app> KEY.
342
+ // Anything BEYOND two is rejected, never ignored: `bod env get app FOO EXTRA` used to
343
+ // print FOO and exit 0, so a typo'd flag or an unquoted value silently produced a
344
+ // confident answer to a question the user did not ask.
345
+ const positionals = (args._ ?? []).filter(a => typeof a === 'string') as string[]
346
+ if (positionals.length > 2) {
347
+ console.error(chalk.red(`env get takes at most <app> KEY — got ${positionals.length} positionals (unexpected: ${positionals.slice(2).join(', ')}). Quote a value containing spaces, or read one key per invocation.`))
348
+ process.exit(1)
349
+ }
350
+ const loneKey = !args.key
351
+ const app = args.key ? args.app : undefined
352
+ const key = args.key ?? args.app
353
+ if (!key) {
354
+ console.error(chalk.red('Variable name required: bod env get <app> KEY (or `bod env get KEY` with a bodify.yaml)'))
355
+ process.exit(1)
356
+ }
357
+
358
+ const { url, apiKey } = getResolvedInstance(loadConfig())
359
+ const client = new BodClient(url, apiKey)
360
+ // resolveAppId exits 1 with "App not found: <name>" — a distinct message from the
361
+ // missing-key one below, so a script's stderr says WHICH of the two failed.
362
+ const appId = await resolveAppId(client, resolveAppName(app))
363
+ const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
364
+ const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
365
+ const values = requireValues(res)
366
+
367
+ // Object.hasOwn — not truthiness, and not `in`. A var set to the empty string IS
368
+ // set (so `values[key] !== undefined` is wrong), but `key in values` also walks the
369
+ // PROTOTYPE of the JSON.parse'd object: `bod env get app toString` printed the source
370
+ // of Function.prototype.toString, over SEVERAL lines, at exit 0 — so a script
371
+ // captured a fake multi-line "secret" instead of taking its unset branch, and
372
+ // `read -r` got a fragment of it. hasOwn keeps the empty-string semantics without
373
+ // the inherited keys.
374
+ if (!Object.hasOwn(values, key)) {
375
+ // Say HOW the argument was read. With one positional, `bod env get blank` in an app
376
+ // called `blank` prints "blank is not set" — which reads as "the app has no such
377
+ // var" when the user meant the app. Naming the interpretation makes the mistake
378
+ // visible instead of plausible.
379
+ const how = loneKey ? ' (read as the KEY; pass `bod env get <app> KEY` for an app)' : ''
380
+ console.error(chalk.red(`${key} is not set for this app${args.env ? ` in env "${args.env}"` : ''}${how}.`))
381
+ // Exit 3, NOT 1: "the key is not set" is a fact about the app, while 1 means the
382
+ // read did not happen (app not found, 500, connection refused, bad usage). They
383
+ // used to be indistinguishable, so `V=$(bod env get api KEY) || V=$DEFAULT` fell
384
+ // back to the default when the agent was merely DOWN — silently deploying the
385
+ // wrong value. A script that wants the fallback tests for 3.
386
+ process.exit(3)
387
+ }
388
+
389
+ // Trailing newline, deliberately: `$(...)` strips ALL trailing newlines, so it costs
390
+ // the capture case nothing, while a bare `bod env get` in a terminal and a
391
+ // `bod env get … | read -r V` both need the line terminated. No key name, no colour,
392
+ // no decoration — the value and nothing else.
393
+ process.stdout.write(values[key] + '\n')
199
394
  },
200
395
  })
201
396
 
@@ -330,18 +525,24 @@ function looksLikePath(s: string): boolean {
330
525
  return s.includes('/') || s.startsWith('~') || s.startsWith('.')
331
526
  }
332
527
 
528
+ const PULL_ARGS = {
529
+ app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
530
+ dest: { type: 'positional', description: 'Output file (default .env). A lone positional is only read as the destination when it is explicitly a path (./x, ~/x, a/b).', required: false },
531
+ env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
532
+ output: { type: 'string', alias: 'o', description: 'Output file (default .env)' },
533
+ stdout: { type: 'boolean', description: 'Print to stdout instead of writing any file' },
534
+ merge: { type: 'boolean', alias: 'm', description: 'Add only the keys missing from the file; never touch existing lines' },
535
+ force: { type: 'boolean', alias: 'F', description: 'Replace the whole file (a timestamped backup is written first)' },
536
+ } as const
537
+
333
538
  const pullCmd = defineCommand({
334
539
  meta: { name: 'pull', description: 'Write an app\'s resolved env to a dotenv file (default .env, mode 0600). Refuses to clobber an existing file; use --merge or --force.' },
335
- args: {
336
- app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
337
- dest: { type: 'positional', description: 'Output file (default .env). A lone positional is only read as the destination when it is explicitly a path (./x, ~/x, a/b).', required: false },
338
- env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
339
- output: { type: 'string', alias: 'o', description: 'Output file (default .env)' },
340
- stdout: { type: 'boolean', description: 'Print to stdout instead of writing any file' },
341
- merge: { type: 'boolean', alias: 'm', description: 'Add only the keys missing from the file; never touch existing lines' },
342
- force: { type: 'boolean', alias: 'F', description: 'Replace the whole file (a timestamped backup is written first)' },
343
- },
540
+ args: PULL_ARGS,
344
541
  async run({ args }) {
542
+ // A mistyped `--envv prod` here WRITES a different environment's secrets to disk, and
543
+ // a mistyped `--outt <path>` writes them to the default destination instead — both
544
+ // at exit 0, both harder to notice than a wrong value on stdout.
545
+ assertKnownFlags('env pull', PULL_ARGS, args)
345
546
  if (args.merge && args.force) {
346
547
  console.error(chalk.red('Pass only one of --merge, --force — they are mutually exclusive.'))
347
548
  process.exit(1)
@@ -353,7 +554,7 @@ const pullCmd = defineCommand({
353
554
  const appId = await resolveAppId(client, resolveAppName(app))
354
555
  const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
355
556
  const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
356
- const values = res.values ?? {}
557
+ const values = requireValues(res)
357
558
  // Every branch below (fresh write / refuse / merge / force / backup) acts on THIS
358
559
  // resolved path. A destination that was silently dropped is how a real pull once
359
560
  // dumped 22 secrets into an unrelated repo's cwd.
@@ -428,5 +629,5 @@ const pullCmd = defineCommand({
428
629
 
429
630
  export default defineCommand({
430
631
  meta: { name: 'env', description: 'Manage environment variables (Bodify global secrets store)' },
431
- subCommands: { list: listCmd, set: setCmd, unset: unsetCmd, rm: rmCmd, pull: pullCmd },
632
+ subCommands: { list: listCmd, get: getCmd, set: setCmd, unset: unsetCmd, rm: rmCmd, pull: pullCmd },
432
633
  })
@@ -0,0 +1,55 @@
1
+ import chalk from 'chalk'
2
+
3
+ /** Flags parsed from the WHOLE argv by cli.ts before citty dispatches, so they may
4
+ * legally appear after the subcommand (`bod env get app KEY --instance staging`).
5
+ * They are not declared on any subcommand, so they must be allowed explicitly. */
6
+ const GLOBAL_FLAGS = ['instance', 'local', 'l']
7
+
8
+ const camel = (name: string) => name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
9
+
10
+ type ArgDef = { type?: string; alias?: string | string[]; default?: unknown }
11
+
12
+ /** citty/mri SILENTLY ACCEPTS a flag the command never declared: `--envv prod` parses as
13
+ * `envv: 'prod'` and the command reads its own (absent) `--env`, so it answers about the
14
+ * DEFAULT environment at exit 0. On `env get` that lands another environment's secret in
15
+ * a shell variable with no error at all — strictly worse than the extra-positional case,
16
+ * which at least only ever returned a value the user did name. The positional guard
17
+ * cannot see it, because the unknown flag swallows its own operand: `args._` stays at two.
18
+ *
19
+ * A dangling string flag (`--env` with no operand) is the same failure wearing a
20
+ * different hat: mri yields `''`, every `args.env ? …` is false, and the default
21
+ * environment is used as if no flag had been passed. Rejected for the same reason.
22
+ *
23
+ * Checked against the command's DECLARED args (names, their camelCase forms, and
24
+ * aliases) so it cannot drift out of sync with them. Exits 1 — stdout untouched. */
25
+ export function assertKnownFlags(cmd: string, declared: Record<string, ArgDef>, args: Record<string, unknown>): void {
26
+ const allowed = new Set<string>(['_', ...GLOBAL_FLAGS])
27
+ const stringFlags: string[] = []
28
+ for (const [name, def] of Object.entries(declared)) {
29
+ // Positionals are keys on `args` too (citty binds `<app>` to `args.app`), so they
30
+ // are allowed here even though they are not flags — they just never appear in the
31
+ // "Known:" list below, which is about what the user could have MISTYPED.
32
+ allowed.add(name)
33
+ allowed.add(camel(name))
34
+ if (def.type === 'positional') continue
35
+ for (const a of [def.alias ?? []].flat()) allowed.add(a)
36
+ if (def.type === 'string' && def.default !== '') stringFlags.push(name)
37
+ }
38
+
39
+ const unknown = Object.keys(args).filter(k => !allowed.has(k))
40
+ if (unknown.length) {
41
+ console.error(chalk.red(
42
+ `Unknown option${unknown.length > 1 ? 's' : ''} for \`bod ${cmd}\`: ${unknown.map(u => '--' + u).join(', ')}. ` +
43
+ `Known: ${[...new Set(Object.keys(declared).filter(n => declared[n]!.type !== 'positional'))].map(n => '--' + n).join(', ')}. ` +
44
+ `A mistyped flag is ignored by the parser, so this would otherwise have answered a different question at exit 0.`))
45
+ process.exit(1)
46
+ }
47
+
48
+ const dangling = stringFlags.filter(n => args[n] === '' || args[camel(n)] === '')
49
+ if (dangling.length) {
50
+ console.error(chalk.red(
51
+ `${dangling.map(n => '--' + n).join(', ')} needs a value (\`--${dangling[0]} <value>\`). ` +
52
+ `An empty one is not "unset" — it would silently fall back to the default.`))
53
+ process.exit(1)
54
+ }
55
+ }
@@ -130,6 +130,9 @@ export function resolveAppName(arg: string | undefined): string {
130
130
  console.error(chalk.red('App name required. Pass <app> or run from a directory with bodify.yaml'))
131
131
  process.exit(1)
132
132
  }
133
- console.log(chalk.dim(`Using app "${name}" from bodify.yaml`))
133
+ // stderr, not stdout: this is a diagnostic, never data. `bod env get` (and any other
134
+ // machine-readable stdout — `env list --reveal`, `env pull --stdout`) must stay clean
135
+ // enough that `X=$(bod env get app KEY)` captures the value and nothing else.
136
+ console.error(chalk.dim(`Using app "${name}" from bodify.yaml`))
134
137
  return name
135
138
  }
@@ -0,0 +1,351 @@
1
+ // `bod env get <app> KEY` is the precise, single-value read: plaintext, script-clean
2
+ // stdout, non-zero + stderr when the key is unset. Verified at the REAL CLI surface
3
+ // (full citty parse, real command substitution) against a mock agent.
4
+ import { test, expect, beforeAll, afterAll } from 'bun:test'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+
9
+ const CLI = join(import.meta.dir, '..', 'src', 'cli.ts')
10
+ const APP_ID = 'e1dba964-b944-4dd0-b971-fe72ee493bf6'
11
+ const APP_NAME = 'blank'
12
+ // A second app whose /env returns a 200 with NO `values` — the shape a broken agent,
13
+ // a proxy error page or a half-written response produces.
14
+ const BROKEN_ID = '00000000-0000-4000-8000-000000000bad'
15
+ const BROKEN_NAME = 'broken'
16
+
17
+ const REMOTE: Record<string, string> = {
18
+ DATABASE_URL: 'postgres://u:p@db.internal:5432/app',
19
+ EMPTY_VAL: '', // set-to-nothing is SET, not missing
20
+ SPACED: 'a value with spaces', // must survive capture byte-for-byte
21
+ MULTILINE: '-----BEGIN KEY-----\nabc\n-----END KEY-----',
22
+ }
23
+ const PROD: Record<string, string> = { DATABASE_URL: 'postgres://prod/app', PROD_ONLY: 'p1' }
24
+
25
+ let server: ReturnType<typeof Bun.serve>
26
+ let home: string
27
+ let deadHome: string // config pointing at a port nothing listens on
28
+ let cwd: string
29
+
30
+ beforeAll(() => {
31
+ server = Bun.serve({
32
+ port: 0,
33
+ fetch(req) {
34
+ const url = new URL(req.url)
35
+ if (url.pathname === '/api/apps') return Response.json([{ id: APP_ID, name: APP_NAME }, { id: BROKEN_ID, name: BROKEN_NAME }])
36
+ if (url.pathname === `/api/apps/${BROKEN_ID}/env`) return Response.json({ error: 'nope' })
37
+ if (url.pathname === `/api/apps/${APP_ID}/env`) {
38
+ return Response.json({ values: url.searchParams.get('environment') === 'prod' ? PROD : REMOTE })
39
+ }
40
+ if (url.pathname === '/api/secrets/vars') return Response.json([])
41
+ return new Response('not found', { status: 404 })
42
+ },
43
+ })
44
+ home = mkdtempSync(join(tmpdir(), 'bod-get-home-'))
45
+ deadHome = mkdtempSync(join(tmpdir(), 'bod-get-dead-'))
46
+ mkdirSync(join(deadHome, '.bod'), { recursive: true })
47
+ // Port 1 is reserved and never bound: connection refused, i.e. the agent is DOWN.
48
+ writeFileSync(join(deadHome, '.bod', 'config.json'), JSON.stringify({
49
+ defaultInstance: 'test',
50
+ instances: { test: { url: 'http://127.0.0.1:1', apiKey: 'test-key' } },
51
+ }))
52
+ cwd = mkdtempSync(join(tmpdir(), 'bod-get-cwd-'))
53
+ mkdirSync(join(home, '.bod'), { recursive: true })
54
+ writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
55
+ defaultInstance: 'test',
56
+ instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
57
+ }))
58
+ })
59
+
60
+ afterAll(() => {
61
+ server?.stop(true)
62
+ rmSync(home, { recursive: true, force: true })
63
+ rmSync(deadHome, { recursive: true, force: true })
64
+ rmSync(cwd, { recursive: true, force: true })
65
+ })
66
+
67
+ const ENV = () => ({ ...process.env, HOME: home, BOD_INSTANCE: '', FORCE_COLOR: '0' })
68
+
69
+ async function runCli(args: string[]) {
70
+ const proc = Bun.spawn(['bun', CLI, ...args], { cwd, env: ENV(), stdout: 'pipe', stderr: 'pipe' })
71
+ const [stdout, stderr, code] = await Promise.all([
72
+ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited,
73
+ ])
74
+ return { stdout, stderr, code }
75
+ }
76
+
77
+ /** The actual consumer surface: a real shell doing `V=$(bod env get …); printf %s "$V"`. */
78
+ async function captureInShell(script: string) {
79
+ const proc = Bun.spawn(['bash', '-c', script], {
80
+ cwd, env: { ...ENV(), CLI }, stdout: 'pipe', stderr: 'pipe',
81
+ })
82
+ const [stdout, stderr, code] = await Promise.all([
83
+ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited,
84
+ ])
85
+ return { stdout, stderr, code }
86
+ }
87
+
88
+ test('prints exactly the value plus one newline — no key, no decoration', async () => {
89
+ const { code, stdout, stderr } = await runCli(['env', 'get', APP_NAME, 'DATABASE_URL'])
90
+ expect(code).toBe(0)
91
+ expect(stdout).toBe(REMOTE.DATABASE_URL + '\n')
92
+ expect(stderr).toBe('')
93
+ })
94
+
95
+ test('command substitution yields the value byte-for-byte (newline stripped by $())', async () => {
96
+ const { code, stdout } = await captureInShell(`V=$(bun "$CLI" env get ${APP_NAME} DATABASE_URL); printf %s "$V"`)
97
+ expect(code).toBe(0)
98
+ expect(stdout).toBe(REMOTE.DATABASE_URL)
99
+ })
100
+
101
+ test('a value with spaces survives capture unchanged', async () => {
102
+ const { stdout } = await captureInShell(`V=$(bun "$CLI" env get ${APP_NAME} SPACED); printf %s "$V"`)
103
+ expect(stdout).toBe(REMOTE.SPACED)
104
+ })
105
+
106
+ test('the trailing newline lets `read` consume the first line', async () => {
107
+ const { stdout } = await captureInShell(`bun "$CLI" env get ${APP_NAME} DATABASE_URL | { read -r V; printf %s "$V"; }`)
108
+ expect(stdout).toBe(REMOTE.DATABASE_URL)
109
+ })
110
+
111
+ test('a multi-line value is emitted verbatim with exactly one added newline', async () => {
112
+ const { stdout } = await runCli(['env', 'get', APP_NAME, 'MULTILINE'])
113
+ expect(stdout).toBe(REMOTE.MULTILINE + '\n')
114
+ })
115
+
116
+ test('a var set to the empty string is SET: empty stdout line, exit 0', async () => {
117
+ const { code, stdout, stderr } = await runCli(['env', 'get', APP_NAME, 'EMPTY_VAL'])
118
+ expect(code).toBe(0)
119
+ expect(stdout).toBe('\n')
120
+ expect(stderr).toBe('')
121
+ })
122
+
123
+ test('a missing key exits 3 with the message on stderr, stdout empty', async () => {
124
+ const { code, stdout, stderr } = await runCli(['env', 'get', APP_NAME, 'NOPE'])
125
+ expect(code).toBe(3) // 3 = "not set", NOT 1 = "the read failed"
126
+ expect(stdout).toBe('')
127
+ expect(stderr).toContain('NOPE is not set')
128
+ })
129
+
130
+ // The whole point of the split: `V=$(bod env get …) || V=$DEFAULT` must distinguish
131
+ // "the app genuinely has no such key" from "the agent is DOWN". While both exited 1,
132
+ // a dead agent silently produced the default — a wrong value shipped with no error.
133
+ test('exit 3 vs 1: a real bash `||` fallback fires on unset and NOT on a dead agent', async () => {
134
+ const script = `
135
+ V=$(bun "$CLI" env get ${APP_NAME} "$1" 2>/dev/null); C=$?
136
+ if [ $C -eq 3 ]; then echo "FALLBACK"; else echo "code=$C"; fi`
137
+
138
+ // key is unset, agent healthy -> the script may safely fall back
139
+ const unset = await captureInShell(`set -- NOPE; ${script}`)
140
+ expect(unset.stdout.trim()).toBe('FALLBACK')
141
+
142
+ // agent DOWN (nothing listening) -> exit 1, so the fallback must NOT fire
143
+ const down = await captureInShell(`export HOME=${deadHome}; set -- DATABASE_URL; ${script}`)
144
+ expect(down.stdout.trim()).toBe('code=1')
145
+ expect(down.stdout).not.toContain('FALLBACK')
146
+
147
+ // and a malformed 200 is likewise 1, not 3
148
+ const broken = await captureInShell(`set -- DATABASE_URL; V=$(bun "$CLI" env get ${BROKEN_NAME} "$1" 2>/dev/null); C=$?; if [ $C -eq 3 ]; then echo FALLBACK; else echo "code=$C"; fi`)
149
+ expect(broken.stdout.trim()).toBe('code=1')
150
+ })
151
+
152
+ // A 200 with no `values` used to be read as an EMPTY env: `res.values ?? {}` turned a
153
+ // server fault into the false factual claim "DASH is not set for this app", which a
154
+ // script then acts on. It must fail as a read error instead.
155
+ test('a malformed 200 is a read FAILURE, never "the key is not set"', async () => {
156
+ const { code, stdout, stderr } = await runCli(['env', 'get', BROKEN_NAME, 'DASH'])
157
+ expect(code).toBe(1)
158
+ expect(stdout).toBe('')
159
+ expect(stderr).toContain('Could not read env for this app')
160
+ expect(stderr).not.toContain('is not set')
161
+ })
162
+
163
+ test('the same malformed 200 fails list and pull rather than reporting an empty env', async () => {
164
+ const list = await runCli(['env', 'list', BROKEN_NAME])
165
+ expect(list.code).toBe(1)
166
+ expect(list.stderr).toContain('Could not read env for this app')
167
+ expect(list.stdout).not.toContain('No environment variables set')
168
+
169
+ const out = join(cwd, 'from-broken.vars')
170
+ const pull = await runCli(['env', 'pull', BROKEN_NAME, '-o', out])
171
+ expect(pull.code).toBe(1)
172
+ expect(pull.stderr).toContain('Could not read env for this app')
173
+ expect(existsSync(out)).toBe(false) // and it wrote no empty file
174
+ })
175
+
176
+ // One positional is read as the KEY — which is invisible when the word is ALSO the app
177
+ // name: `bod env get blank` in app `blank` used to say "blank is not set for this app",
178
+ // reading as a fact about the app rather than a misparse of the argument.
179
+ test('the unset message names how a lone positional was interpreted', async () => {
180
+ writeFileSync(join(cwd, 'bodify.yaml'), `name: ${APP_NAME}\n`)
181
+ try {
182
+ const { code, stderr } = await runCli(['env', 'get', APP_NAME])
183
+ expect(code).toBe(3)
184
+ expect(stderr).toContain(`${APP_NAME} is not set`)
185
+ expect(stderr).toContain('read as the KEY')
186
+ // Two positionals carry no ambiguity, so they get no such clause.
187
+ const two = await runCli(['env', 'get', APP_NAME, 'NOPE'])
188
+ expect(two.stderr).not.toContain('read as the KEY')
189
+ } finally {
190
+ rmSync(join(cwd, 'bodify.yaml'), { force: true })
191
+ }
192
+ })
193
+
194
+ // Extra positionals used to be dropped: `bod env get blank FOO EXTRA` printed FOO and
195
+ // exited 0 — a confident answer to a question the user did not ask.
196
+ test('extra positionals are rejected, not silently ignored', async () => {
197
+ const { code, stdout, stderr } = await runCli(['env', 'get', APP_NAME, 'DATABASE_URL', 'EXTRA'])
198
+ expect(code).toBe(1)
199
+ expect(stdout).toBe('') // the value did NOT leak out
200
+ expect(stdout).not.toContain(REMOTE.DATABASE_URL)
201
+ expect(stderr).toContain('at most <app> KEY')
202
+ expect(stderr).toContain('EXTRA')
203
+
204
+ // Four positionals are rejected the same way, and the message names every extra one.
205
+ const four = await runCli(['env', 'get', APP_NAME, 'DATABASE_URL', 'A', 'B'])
206
+ expect(four.code).toBe(1)
207
+ expect(four.stdout).toBe('')
208
+ expect(four.stderr).toContain('at most <app> KEY')
209
+ expect(four.stderr).toContain('A, B')
210
+ })
211
+
212
+ // `key in values` walked the PROTOTYPE of the JSON.parse'd body: `bod env get blank
213
+ // toString` printed the source of Function.prototype.toString — MULTI-LINE, at exit 0.
214
+ // A script's `V=$(bod env get …) || V=$DEFAULT` captured that garbage instead of taking
215
+ // its unset branch, and a `read -r V` got only the first fragment of it. Object.hasOwn
216
+ // keeps the empty-string-is-set semantics without the inherited keys.
217
+ test('prototype keys are NOT values: every one exits 3 with empty stdout', async () => {
218
+ for (const key of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf']) {
219
+ const { code, stdout, stderr } = await runCli(['env', 'get', APP_NAME, key])
220
+ expect(`${key}:${code}`).toBe(`${key}:3`)
221
+ expect(stdout).toBe('')
222
+ expect(stderr).toContain(`${key} is not set`)
223
+ }
224
+ // And the real consumer surface: the `||` fallback fires, with nothing captured.
225
+ const shell = await captureInShell(
226
+ `V=$(bun "$CLI" env get ${APP_NAME} toString 2>/dev/null) || echo "FELLBACK"; printf '[%s]' "$V"`)
227
+ expect(shell.stdout).toContain('FELLBACK')
228
+ expect(shell.stdout).toContain('[]')
229
+ expect(shell.stdout).not.toContain('native code')
230
+ })
231
+
232
+ // citty/mri SILENTLY DROPS a flag the command never declared, and the extra-positional
233
+ // guard cannot see it because the unknown flag swallows its own operand (`args._` stays
234
+ // at two). So `--envv prod` returned the DEFAULT environment's secret at exit 0 — a
235
+ // different environment's value landing in a shell variable with no error anywhere.
236
+ test('an unknown flag is rejected, not ignored', async () => {
237
+ const { code, stdout, stderr } = await runCli(['env', 'get', APP_NAME, 'DATABASE_URL', '--typo'])
238
+ expect(code).toBe(1)
239
+ expect(stdout).toBe('')
240
+ expect(stderr).toContain('Unknown option')
241
+ expect(stderr).toContain('--typo')
242
+ })
243
+
244
+ test('a mistyped --envv is rejected, NOT answered from the default environment', async () => {
245
+ const { code, stdout, stderr } = await runCli(['env', 'get', APP_NAME, 'DATABASE_URL', '--envv', 'prod'])
246
+ expect(code).toBe(1)
247
+ expect(stdout).toBe('')
248
+ expect(stdout).not.toContain(REMOTE.DATABASE_URL) // the WRONG env's value never printed
249
+ expect(stderr).toContain('--envv')
250
+ })
251
+
252
+ // `--env` with no operand parses as '', every `args.env ? …` is false, and the default
253
+ // environment is silently used as if no flag had been passed.
254
+ test('a dangling --env with no value is rejected', async () => {
255
+ const { code, stdout, stderr } = await runCli(['env', 'get', APP_NAME, 'DATABASE_URL', '--env'])
256
+ expect(code).toBe(1)
257
+ expect(stdout).toBe('')
258
+ expect(stderr).toContain('--env needs a value')
259
+ })
260
+
261
+ // The declared flags, their aliases and the GLOBAL flags cli.ts parses out of the whole
262
+ // argv must all still pass — the guard must reject typos, not legitimate usage.
263
+ test('declared flags, aliases and global flags are still accepted', async () => {
264
+ for (const extra of [['--env', 'prod'], ['-e', 'prod'], ['--instance', 'test']]) {
265
+ const { code, stdout } = await runCli(['env', 'get', APP_NAME, 'DATABASE_URL', ...extra])
266
+ expect(`${extra[0]}:${code}`).toBe(`${extra[0]}:0`)
267
+ expect(stdout.trim().startsWith('postgres://')).toBe(true)
268
+ }
269
+ })
270
+
271
+ // Pre-existing on list/pull, same citty behaviour, same wrong-environment hazard — and
272
+ // on pull the wrong environment is WRITTEN to disk.
273
+ test('list and pull reject an unknown flag too', async () => {
274
+ const list = await runCli(['env', 'list', APP_NAME, '--envv', 'prod'])
275
+ expect(list.code).toBe(1)
276
+ expect(list.stderr).toContain('--envv')
277
+
278
+ const out = join(cwd, 'typo.vars')
279
+ const pull = await runCli(['env', 'pull', APP_NAME, '--envv', 'prod', '-o', out])
280
+ expect(pull.code).toBe(1)
281
+ expect(pull.stderr).toContain('--envv')
282
+ expect(existsSync(out)).toBe(false) // and no file was written from the wrong env
283
+ })
284
+
285
+ test('a missing key cannot poison the captured variable', async () => {
286
+ const { code, stdout } = await captureInShell(
287
+ `V=$(bun "$CLI" env get ${APP_NAME} NOPE 2>/dev/null) || echo "FAILED:[$V]"; printf %s "$V"`)
288
+ expect(code).toBe(0)
289
+ expect(stdout).toContain('FAILED:[]')
290
+ })
291
+
292
+ test('a missing APP is a distinct error from a missing key', async () => {
293
+ const { code, stderr } = await runCli(['env', 'get', 'no-such-app', 'DATABASE_URL'])
294
+ expect(code).toBe(1)
295
+ expect(stderr).toContain('App not found: no-such-app')
296
+ expect(stderr).not.toContain('is not set')
297
+ })
298
+
299
+ test('--env/-e selects the environment', async () => {
300
+ const a = await runCli(['env', 'get', APP_NAME, 'DATABASE_URL', '--env', 'prod'])
301
+ expect(a.code).toBe(0)
302
+ expect(a.stdout).toBe(PROD.DATABASE_URL + '\n')
303
+ const b = await runCli(['env', 'get', APP_NAME, 'PROD_ONLY', '-e', 'prod'])
304
+ expect(b.stdout).toBe('p1\n')
305
+ // A key that exists only in the default env is missing under --env, and says so.
306
+ const c = await runCli(['env', 'get', APP_NAME, 'SPACED', '-e', 'prod'])
307
+ expect(c.code).toBe(3)
308
+ expect(c.stderr).toContain('env "prod"')
309
+ })
310
+
311
+ test('no positional at all is a usage error, not an empty success', async () => {
312
+ const { code, stdout, stderr } = await runCli(['env', 'get'])
313
+ expect(code).toBe(1)
314
+ expect(stdout).toBe('')
315
+ expect(stderr).toContain('Variable name required')
316
+ })
317
+
318
+ // A lone positional is the KEY (app from bodify.yaml) — `get` with no key is useless,
319
+ // so that is its only useful reading. With no bodify.yaml it must NOT be read as an app.
320
+ test('a lone positional with no bodify.yaml fails on the missing app, not silently', async () => {
321
+ const { code, stdout, stderr } = await runCli(['env', 'get', 'DATABASE_URL'])
322
+ expect(code).toBe(1)
323
+ expect(stdout).toBe('')
324
+ expect(stderr).toContain('App name required')
325
+ })
326
+
327
+ // Scope decision: the masked scopes are REJECTED, never returned as a mask — a mask
328
+ // captured into a shell variable is a silent fake secret.
329
+ test('--global/--group/--subs are rejected with a clear stderr message', async () => {
330
+ for (const flags of [['--global'], ['--group', 'infra'], ['--subs'], ['--subs-app', APP_NAME]]) {
331
+ const { code, stdout, stderr } = await runCli(['env', 'get', ...flags, 'DATABASE_URL'])
332
+ expect(code).toBe(1)
333
+ expect(stdout).toBe('')
334
+ expect(stderr).toContain('env get only supports the per-app scope')
335
+ expect(stderr).not.toContain('•')
336
+ }
337
+ })
338
+
339
+ test('the app falls back to bodify.yaml and its notice stays OFF stdout', async () => {
340
+ writeFileSync(join(cwd, 'bodify.yaml'), `name: ${APP_NAME}\n`)
341
+ try {
342
+ const { code, stdout, stderr } = await runCli(['env', 'get', 'DATABASE_URL'])
343
+ expect(code).toBe(0)
344
+ expect(stdout).toBe(REMOTE.DATABASE_URL + '\n') // value ONLY
345
+ expect(stderr).toContain('from bodify.yaml')
346
+ const shell = await captureInShell(`V=$(bun "$CLI" env get DATABASE_URL 2>/dev/null); printf %s "$V"`)
347
+ expect(shell.stdout).toBe(REMOTE.DATABASE_URL)
348
+ } finally {
349
+ rmSync(join(cwd, 'bodify.yaml'), { force: true })
350
+ }
351
+ })
@@ -0,0 +1,251 @@
1
+ // `bod env list <app>` is the ONLY scope that can resolve plaintext secrets. It must
2
+ // mask them by default — including when piped — and reveal only on explicit request.
3
+ // Verified at the REAL CLI surface (full citty parse) against a mock agent.
4
+ import { test, expect, beforeAll, afterAll } from 'bun:test'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+ import { maskValue, displayLength } from '../src/commands/env'
9
+
10
+ const CLI = join(import.meta.dir, '..', 'src', 'cli.ts')
11
+ const APP_ID = 'e1dba964-b944-4dd0-b971-fe72ee493bf6'
12
+ const APP_NAME = 'blank'
13
+
14
+ const REMOTE: Record<string, string> = {
15
+ STRIPE_KEY: 'sk_live_51NxAbCdEfGhIjKlMnOpQrStU', // 33 cp -> 3+3 revealed
16
+ AWS_KEY: 'AKIA' + 'X'.repeat(36), // 40 cp -> 4+4: the HEAD is the half a
17
+ // console shows you (AKIA…), so it is the
18
+ // half a tail-only mask could never match
19
+ BOUND_19: 'abcdefghijklmnopqrs', // 19 cp -> 0 revealed (last length hidden)
20
+ BOUND_20: 'abcdefghijklmnopqrst', // 20 cp -> 2+2 revealed (first length shown)
21
+ LOW_ENTROPY: 'production', // 10 cp -> 0: the tail used to give it away
22
+ SHORT_PIN: '4821',
23
+ EMOJI_VAL: '\u{1F600}'.repeat(20), // astral: budget must not split a pair
24
+ EMPTY_VAL: '',
25
+ // One fixture per control-char class, each 20 codepoints so a tail IS budgeted and
26
+ // the strip is actually exercised. Tail = the last 4 codepoints in every case.
27
+ C0_VAL: 'abcdefghijklmnop\r\n\tx', // C0 + DEL family
28
+ DEL_VAL: 'abcdefghijklmnop\u007f\u007f\u007fx',
29
+ C1_VAL: 'abcdefghijklmnop\u009b\u0085\u0080x', // U+009B = CSI (breaks table width), U+0085 = NEL
30
+ SEP_VAL: 'abcdefghijklmnop\u2028\u2029\u2028x', // line / paragraph separators
31
+ BIDI_VAL: 'abcdefghijklmnop\u202e\u200f\u2066x', // RTL override + mark + isolate
32
+ }
33
+
34
+ // What the server returns for --global: it masks server-side, so `masked` is the
35
+ // authoritative display string and the CLI must pass it through untouched.
36
+ const GLOBAL_VARS = [
37
+ { key: 'SHARED_DB_URL', entries: [{ scope: {}, masked: 'post***rres', updatedAt: 0 }] },
38
+ { key: 'TEAM_TOKEN', entries: [{ scope: { group: 'infra' }, masked: 'ghp_***', updatedAt: 0 }] },
39
+ ]
40
+
41
+ let server: ReturnType<typeof Bun.serve>
42
+ let home: string
43
+ let cwd: string
44
+
45
+ beforeAll(() => {
46
+ server = Bun.serve({
47
+ port: 0,
48
+ fetch(req) {
49
+ const url = new URL(req.url)
50
+ if (url.pathname === '/api/apps') return Response.json([{ id: APP_ID, name: APP_NAME }])
51
+ if (url.pathname === `/api/apps/${APP_ID}/env`) return Response.json({ values: REMOTE })
52
+ if (url.pathname === '/api/secrets/vars') return Response.json(GLOBAL_VARS)
53
+ return new Response('not found', { status: 404 })
54
+ },
55
+ })
56
+ home = mkdtempSync(join(tmpdir(), 'bod-list-home-'))
57
+ cwd = mkdtempSync(join(tmpdir(), 'bod-list-cwd-'))
58
+ mkdirSync(join(home, '.bod'), { recursive: true })
59
+ writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
60
+ defaultInstance: 'test',
61
+ instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
62
+ }))
63
+ })
64
+
65
+ afterAll(() => {
66
+ server?.stop(true)
67
+ rmSync(home, { recursive: true, force: true })
68
+ rmSync(cwd, { recursive: true, force: true })
69
+ })
70
+
71
+ /** The `value` cell of one row of the masked table, for assertions that must not be
72
+ * confused by the `len` column or another row. */
73
+ function valueCell(stdout: string, key: string): string {
74
+ const row = stdout.split('\n').find(l => new RegExp(`\\|\\s*${key}\\s*\\|`).test(l))
75
+ if (!row) throw new Error(`no row for ${key} in:\n${stdout}`)
76
+ return row.split('|')[2]!.trim()
77
+ }
78
+
79
+ async function runCli(args: string[]) {
80
+ const proc = Bun.spawn(['bun', CLI, ...args], {
81
+ cwd,
82
+ env: { ...process.env, HOME: home, BOD_INSTANCE: '', FORCE_COLOR: '0' },
83
+ stdout: 'pipe', stderr: 'pipe',
84
+ })
85
+ const [stdout, stderr, code] = await Promise.all([
86
+ new Response(proc.stdout).text(),
87
+ new Response(proc.stderr).text(),
88
+ proc.exited,
89
+ ])
90
+ return { stdout, stderr, code }
91
+ }
92
+
93
+ // NOTE: Bun.spawn gives the CLI a PIPE, not a TTY — so every test here is already the
94
+ // piped case. Masking being non-TTY-conditional is what makes these assertions hold.
95
+ test('masked by default (stdout is a pipe): keys visible, no plaintext secret anywhere', async () => {
96
+ const { code, stdout } = await runCli(['env', 'list', APP_NAME])
97
+ expect(code).toBe(0)
98
+ for (const k of Object.keys(REMOTE)) expect(stdout).toContain(k)
99
+ for (const v of Object.values(REMOTE)) if (v) expect(stdout).not.toContain(v)
100
+ })
101
+
102
+ test('reveal boundary: 19 codepoints show nothing, 20 show two at EACH end', async () => {
103
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
104
+ const DOTS = '\u2022'.repeat(6)
105
+ expect(valueCell(stdout, 'BOUND_20')).toBe('ab' + DOTS + 'st') // 20 cp -> floor(20/10) = 2
106
+ expect(valueCell(stdout, 'BOUND_19')).toBe(DOTS)
107
+ // and nothing of the 19-char value leaks from EITHER end, at any length
108
+ for (let i = 1; i <= 19; i++) {
109
+ expect(valueCell(stdout, 'BOUND_19')).not.toContain('abcdefghijklmnopqrs'.slice(-i))
110
+ expect(valueCell(stdout, 'BOUND_19')).not.toContain('abcdefghijklmnopqrs'.slice(0, i))
111
+ }
112
+ expect(valueCell(stdout, 'STRIPE_KEY')).toBe('sk_' + DOTS + 'StU') // 33 cp -> 3+3
113
+ // The head is the point of this shape: a dashboard showing only `AKIA…` can be
114
+ // matched against the listing, which a tail-only mask made impossible.
115
+ expect(valueCell(stdout, 'AWS_KEY')).toBe('AKIA' + DOTS + 'XXXX') // 40 cp -> capped 4+4
116
+ // Length is exact and still shown — it is what spots a truncated paste.
117
+ expect(stdout).toMatch(/BOUND_19\s*\|.*\|\s*19\s*\|/)
118
+ expect(stdout).toMatch(/BOUND_20\s*\|.*\|\s*20\s*\|/)
119
+ })
120
+
121
+ test('a low-entropy value at the old boundary is no longer de-anonymised', async () => {
122
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
123
+ const DOTS = '\u2022'.repeat(6)
124
+ // The regression this threshold exists for: `production` is 10 cp and used to render
125
+ // as ••••••on / len 10, which is the whole value.
126
+ expect(valueCell(stdout, 'LOW_ENTROPY')).toBe(DOTS)
127
+ expect(maskValue('production')).toBe(DOTS)
128
+ expect(maskValue('false')).toBe(DOTS)
129
+ expect(maskValue('true')).toBe(DOTS)
130
+ })
131
+
132
+ test('an empty value renders as (empty), not as a mask', async () => {
133
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
134
+ expect(valueCell(stdout, 'EMPTY_VAL')).toBe('(empty)')
135
+ expect(valueCell(stdout, 'EMPTY_VAL')).not.toContain('\u2022')
136
+ expect(stdout).toMatch(/EMPTY_VAL\s*\|.*\|\s*0\s*\|/)
137
+ expect(maskValue('')).toBe('(empty)')
138
+ })
139
+
140
+ test('astral values are sliced by codepoint — no lone surrogate, len counts codepoints', async () => {
141
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
142
+ expect(valueCell(stdout, 'EMOJI_VAL')).toBe('\u{1F600}'.repeat(2) + '\u2022'.repeat(6) + '\u{1F600}'.repeat(2))
143
+ expect(stdout).not.toContain('\uFFFD')
144
+ for (const ch of stdout) expect(ch.codePointAt(0)! >= 0xd800 && ch.codePointAt(0)! <= 0xdfff).toBe(false)
145
+ expect(stdout).toMatch(/EMOJI_VAL\s*\|.*\|\s*20\s*\|/)
146
+ })
147
+
148
+ // Every class the strip must cover. A leaked one does one of two visible harms: it
149
+ // breaks the row across lines (C0/NEL/LS/PS) or it reflows/miscounts the cell
150
+ // (bidi override, C1 CSI) — so assert BOTH: one line, and not a single such char.
151
+ const CONTROL_CASES: Array<[string, number, string, string[]]> = [
152
+ ['C0_VAL', 20, 'x', ['\r', '\n', '\t']],
153
+ ['DEL_VAL', 20, 'x', ['\u007f']],
154
+ ['C1_VAL', 20, 'x', ['\u009b', '\u0085', '\u0080']],
155
+ ['SEP_VAL', 20, 'x', ['\u2028', '\u2029']],
156
+ ['BIDI_VAL', 20, 'x', ['\u202e', '\u200f', '\u2066']],
157
+ ]
158
+
159
+ test('control chars of every class are stripped and the row stays on one line', async () => {
160
+ const { stdout } = await runCli(['env', 'list', APP_NAME])
161
+ for (const [key, len, survivor, banned] of CONTROL_CASES) {
162
+ const cell = valueCell(stdout, key)
163
+ // 'ab' is the (safe) head; only the safe char of the tail survives the strip.
164
+ expect(cell).toBe('ab' + '\u2022'.repeat(6) + survivor)
165
+ for (const c of banned) expect(cell).not.toContain(c)
166
+ // ONE line: the key, the value and the len cell all sit on a single table row.
167
+ const row = stdout.split('\n').find(l => l.includes(key))!
168
+ for (const c of banned) expect(row).not.toContain(c)
169
+ expect(row).toMatch(new RegExp(`${key}\\s*\\|[^\\n]*\\|\\s*${len}\\s*\\|`))
170
+ }
171
+ // Column alignment is intact: with no stray CSI miscounting a cell, every border
172
+ // rule is the same width. (This is what a leaked U+009B used to break.)
173
+ const rules = stdout.split('\n').filter(l => l.startsWith('+')).map(l => l.length)
174
+ expect(rules.length).toBeGreaterThan(1)
175
+ expect(new Set(rules).size).toBe(1)
176
+ })
177
+
178
+ test('--reveal prints full plaintext in the original KEY=VALUE format', async () => {
179
+ const { code, stdout } = await runCli(['env', 'list', APP_NAME, '--reveal'])
180
+ expect(code).toBe(0)
181
+ // Byte-identical to the pre-masking format: one KEY=VALUE line per var, raw values.
182
+ expect(stdout).toBe(Object.entries(REMOTE).map(([k, v]) => `${k}=${v}`).join('\n') + '\n')
183
+ })
184
+
185
+ test('the --reveal hint goes to stderr, keeping stdout parseable', async () => {
186
+ const { stdout, stderr } = await runCli(['env', 'list', APP_NAME])
187
+ expect(stderr).toContain('--reveal')
188
+ expect(stdout).not.toContain('--reveal')
189
+ })
190
+
191
+ test('--reveal is rejected on scopes that only ever hold masked server values', async () => {
192
+ const { code, stderr } = await runCli(['env', 'list', '--global', '--reveal'])
193
+ expect(code).toBe(1)
194
+ expect(stderr).toContain('--reveal only applies to the per-app scope')
195
+ })
196
+
197
+ test('--global passes the SERVER masked string through — maskValue is per-app only', async () => {
198
+ const { code, stdout } = await runCli(['env', 'list', '--global'])
199
+ expect(code).toBe(0)
200
+ for (const v of GLOBAL_VARS) {
201
+ expect(stdout).toContain(v.key)
202
+ expect(stdout).toContain(v.entries[0].masked) // verbatim, not re-masked
203
+ }
204
+ expect(stdout).not.toContain('\u2022')
205
+ expect(stdout).not.toMatch(/\blen\b/)
206
+ })
207
+
208
+ test('maskValue budget: nothing below 20 codepoints, then head = tail = min(4, len/10)', () => {
209
+ const DOTS = '\u2022'.repeat(6)
210
+ expect(maskValue('4821')).toBe(DOTS)
211
+ expect(maskValue('production')).toBe(DOTS)
212
+ expect(maskValue('a'.repeat(19))).toBe(DOTS) // 19 -> still nothing
213
+ expect(maskValue('abcdefghijklmnopqrst')).toBe('ab' + DOTS + 'st') // 20 -> the first 2+2
214
+ expect(maskValue('abcdefghijklmnopqrstuvwxyz1234')).toBe('abc' + DOTS + '234') // 30 -> 3+3
215
+ expect(maskValue('b'.repeat(40))).toBe('bbbb' + DOTS + 'bbbb') // 40 -> the 4+4 ceiling
216
+ expect(maskValue('a'.repeat(100))).toBe('aaaa' + DOTS + 'aaaa') // still 4+4, never more
217
+ expect(maskValue('\u{1F600}'.repeat(20))).toBe('\u{1F600}'.repeat(2) + DOTS + '\u{1F600}'.repeat(2))
218
+ expect(maskValue('\u{1F600}'.repeat(19))).toBe(DOTS) // 19 codepoints (38 UTF-16 units)
219
+ // An end that is ENTIRELY unsafe strips to nothing — reveals less, never more.
220
+ expect(maskValue('\u202e\u2028\u009b\rabcdefghijklmnop')).toBe(DOTS + 'op')
221
+ expect(maskValue('abcdefghijklmnop\u202e\u2028\u009b\r')).toBe('ab' + DOTS)
222
+ })
223
+
224
+ // The floor exists so head and tail can never meet: reveal is capped at len/10, so
225
+ // 2*reveal <= len/5 < len for every value long enough to reveal anything at all. If
226
+ // they ever DID overlap, the same characters would be shown twice and a 20-char value
227
+ // would be printed whole.
228
+ test('head and tail never overlap, at any length', () => {
229
+ const DOTS = '\u2022'.repeat(6)
230
+ for (let n = 20; n <= 60; n++) {
231
+ // Distinct-per-position value: an overlap would repeat a character across the dots.
232
+ const value = Array.from({ length: n }, (_, i) => String.fromCharCode(33 + (i % 90))).join('')
233
+ const masked = maskValue(value)
234
+ const [head, tail] = masked.split(DOTS)
235
+ expect(masked).toContain(DOTS)
236
+ expect(head!.length + tail!.length).toBeLessThanOrEqual(8)
237
+ expect(head!.length + tail!.length).toBeLessThanOrEqual(Math.floor(n / 5))
238
+ // Every revealed character comes from its own end of the value, and no index is
239
+ // claimed by both — head is a strict prefix, tail a strict suffix, and they are
240
+ // disjoint because their combined length is under n.
241
+ expect(value.startsWith(head!)).toBe(true)
242
+ expect(value.endsWith(tail!)).toBe(true)
243
+ expect(head!.length + tail!.length).toBeLessThan(n)
244
+ expect(new Set([...head! + tail!]).size).toBe(head!.length + tail!.length)
245
+ }
246
+ })
247
+
248
+ test('displayLength counts codepoints, matching the slicing unit', () => {
249
+ expect(displayLength('\u{1F600}'.repeat(20))).toBe(20) // not 40
250
+ expect(displayLength('abcdefghij\n')).toBe(11)
251
+ })
@@ -225,9 +225,11 @@ test('the refusal guard applies to an explicit path destination', async () => {
225
225
  test('a LONE path positional is a destination, with the app from bodify.yaml', async () => {
226
226
  writeFileSync(join(cwd, 'bodify.yaml'), `name: ${APP_NAME}\n`)
227
227
  const dest = join(cwd, 'nested', 'out.vars')
228
- const { code, stdout } = await runCli(['env', 'pull', dest])
228
+ const { code, stdout, stderr } = await runCli(['env', 'pull', dest])
229
229
  expect(code).toBe(0)
230
- expect(stdout).toContain('from bodify.yaml')
230
+ // The fallback notice is a DIAGNOSTIC: stderr, so it can never pollute a captured stdout.
231
+ expect(stderr).toContain('from bodify.yaml')
232
+ expect(stdout).not.toContain('from bodify.yaml')
231
233
  expect(readFileSync(dest, 'utf8')).toBe(BODY)
232
234
  expect(existsSync(envPath())).toBe(false)
233
235
  })