bod-cli 0.10.9 → 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.
- package/.cursor/skills/using-bod-cli/SKILL.md +60 -8
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/commands/env.ts +160 -33
- package/src/utils/args.ts +55 -0
- package/src/utils/resolve.ts +4 -1
- package/test/env-get.test.ts +351 -0
- package/test/env-list-mask.test.ts +54 -17
- package/test/env-pull-guard.test.ts +4 -2
|
@@ -134,12 +134,14 @@ 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
141
|
bod env list my-api # values MASKED (key, masked value, length)
|
|
142
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
|
|
143
145
|
bod env set my-api DATABASE_URL=postgres://...
|
|
144
146
|
bod env set my-api -f .env # bulk set from .env file
|
|
145
147
|
bod env unset my-api OLD_VAR
|
|
@@ -148,21 +150,27 @@ bod env pull my-api # write the resolved env to ./.env (0600)
|
|
|
148
150
|
|
|
149
151
|
**`bod env list <app>` masks values by default.** It is the only scope that resolves
|
|
150
152
|
plaintext secrets, and it used to print them straight to the terminal (and into any
|
|
151
|
-
scrollback, log or pipe). The mask shows a short tail plus a length, enough
|
|
152
|
-
"is this the same key I have locally?" without exposing the value:
|
|
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:
|
|
153
155
|
|
|
154
|
-
- **Under 20 codepoints → nothing is revealed.** A short value is usually
|
|
155
|
-
(`production`, `true`, a PIN, a short enum) and a tail plus the length gives
|
|
156
|
-
outright — `••••••on` / len 10 *is* `production`. Real secrets are long, so this
|
|
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
|
|
157
159
|
costs the useful case nothing.
|
|
158
|
-
- **20 or more → `floor(len/
|
|
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.
|
|
159
166
|
- An **empty** value renders as a dim `(empty)`, not as dots: `••••••` with `len 0`
|
|
160
167
|
would be indistinguishable from a masked secret.
|
|
161
168
|
- Everything is counted in **Unicode codepoints**, so an emoji is never sliced in half.
|
|
162
169
|
Characters that could corrupt the table are stripped from the tail: C0, DEL, C1
|
|
163
170
|
(U+0080–U+009F — a bare U+009B is a CSI that misaligns every column), U+2028/U+2029,
|
|
164
171
|
and the bidi controls (U+200E/U+200F, U+202A–U+202E, U+2066–U+2069) — U+202E alone
|
|
165
|
-
visually reverses the rest of the row.
|
|
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).
|
|
166
174
|
|
|
167
175
|
The `len` column is that same codepoint count, and is deliberately **exact** — masking
|
|
168
176
|
cannot defeat low entropy anyway (the 20-codepoint threshold is what does), and an exact
|
|
@@ -174,6 +182,50 @@ plaintext back on. For scripts use `--reveal`
|
|
|
174
182
|
`--reveal` is rejected with `--global/--group/--subs`, which only ever receive
|
|
175
183
|
server-side masked values.
|
|
176
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
|
+
|
|
177
229
|
**`bod env pull` never clobbers an existing file.** A `.env` is hand-maintained and
|
|
178
230
|
usually holds local-only credentials the server has never seen — replacing it can
|
|
179
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); list MASKS values (--reveal for plaintext; never TTY-conditional); 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
package/src/commands/env.ts
CHANGED
|
@@ -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
|
|
@@ -147,20 +148,41 @@ const UNSAFE_CELL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u
|
|
|
147
148
|
* nothing" is a different, actionable fact. */
|
|
148
149
|
export const EMPTY_MARKER = '(empty)'
|
|
149
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
|
+
|
|
150
165
|
/** Mask a resolved value for on-screen display.
|
|
151
166
|
*
|
|
152
|
-
* The
|
|
153
|
-
* the same one I have locally?" without printing the secret.
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* len
|
|
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.
|
|
160
182
|
* Everything is measured in CODEPOINTS, not UTF-16 code units, so an odd budget
|
|
161
183
|
* cannot slice an astral character (emoji, some CJK) in half and emit a lone
|
|
162
184
|
* surrogate. The `len` column reports the same unit.
|
|
163
|
-
* UNSAFE_CELL_CHARS are dropped from
|
|
185
|
+
* UNSAFE_CELL_CHARS are dropped from BOTH ends so a value cannot break, reverse or
|
|
164
186
|
* misalign the table it is printed in.
|
|
165
187
|
*
|
|
166
188
|
* NOTE on `len`: it is deliberately still exact. Masking cannot defeat low entropy
|
|
@@ -172,9 +194,11 @@ export const EMPTY_MARKER = '(empty)'
|
|
|
172
194
|
export function maskValue(value: string): string {
|
|
173
195
|
if (value === '') return EMPTY_MARKER
|
|
174
196
|
const chars = [...value]
|
|
175
|
-
const reveal = chars.length < 20 ? 0 : Math.min(4, Math.floor(chars.length /
|
|
176
|
-
const
|
|
177
|
-
|
|
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
|
|
178
202
|
}
|
|
179
203
|
|
|
180
204
|
/** Codepoint length — the unit the `len` column reports, matching maskValue's budget. */
|
|
@@ -182,18 +206,23 @@ export function displayLength(value: string): number {
|
|
|
182
206
|
return [...value].length
|
|
183
207
|
}
|
|
184
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
|
+
|
|
185
219
|
const listCmd = defineCommand({
|
|
186
220
|
meta: { name: 'list', description: 'List env vars (values MASKED by default; --reveal prints them in full). --global/--group/--subs: variables in that scope.' },
|
|
187
|
-
args:
|
|
188
|
-
app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
|
|
189
|
-
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
190
|
-
reveal: { type: 'boolean', description: 'Print full plaintext values (per-app scope) as KEY=VALUE' },
|
|
191
|
-
global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
|
|
192
|
-
group: { type: 'string', description: 'List masked entries scoped to a group; excludes <app>/--global' },
|
|
193
|
-
subs: { type: 'boolean', description: 'List masked platform-GLOBAL subs provider secrets (__subs__~~global; money creds kept out of app envs)' },
|
|
194
|
-
'subs-app': { type: 'string', description: 'List masked per-app subs provider secrets for this app name/id (__subs__~<appId>)' },
|
|
195
|
-
},
|
|
221
|
+
args: LIST_ARGS,
|
|
196
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)
|
|
197
226
|
const { url, apiKey } = getResolvedInstance(loadConfig())
|
|
198
227
|
const client = new BodClient(url, apiKey)
|
|
199
228
|
|
|
@@ -245,7 +274,7 @@ const listCmd = defineCommand({
|
|
|
245
274
|
const appId = await resolveAppId(client, resolveAppName(args.app))
|
|
246
275
|
const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
|
|
247
276
|
const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
|
|
248
|
-
const values = res
|
|
277
|
+
const values = requireValues(res)
|
|
249
278
|
if (Object.keys(values).length === 0) {
|
|
250
279
|
console.log(chalk.dim('No environment variables set.'))
|
|
251
280
|
return
|
|
@@ -273,6 +302,98 @@ const listCmd = defineCommand({
|
|
|
273
302
|
},
|
|
274
303
|
})
|
|
275
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')
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
|
|
276
397
|
const setCmd = defineCommand({
|
|
277
398
|
meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, --group, --subs/--subs-app, and/or --env.' },
|
|
278
399
|
args: {
|
|
@@ -404,18 +525,24 @@ function looksLikePath(s: string): boolean {
|
|
|
404
525
|
return s.includes('/') || s.startsWith('~') || s.startsWith('.')
|
|
405
526
|
}
|
|
406
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
|
+
|
|
407
538
|
const pullCmd = defineCommand({
|
|
408
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.' },
|
|
409
|
-
args:
|
|
410
|
-
app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
|
|
411
|
-
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 },
|
|
412
|
-
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
413
|
-
output: { type: 'string', alias: 'o', description: 'Output file (default .env)' },
|
|
414
|
-
stdout: { type: 'boolean', description: 'Print to stdout instead of writing any file' },
|
|
415
|
-
merge: { type: 'boolean', alias: 'm', description: 'Add only the keys missing from the file; never touch existing lines' },
|
|
416
|
-
force: { type: 'boolean', alias: 'F', description: 'Replace the whole file (a timestamped backup is written first)' },
|
|
417
|
-
},
|
|
540
|
+
args: PULL_ARGS,
|
|
418
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)
|
|
419
546
|
if (args.merge && args.force) {
|
|
420
547
|
console.error(chalk.red('Pass only one of --merge, --force — they are mutually exclusive.'))
|
|
421
548
|
process.exit(1)
|
|
@@ -427,7 +554,7 @@ const pullCmd = defineCommand({
|
|
|
427
554
|
const appId = await resolveAppId(client, resolveAppName(app))
|
|
428
555
|
const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
|
|
429
556
|
const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
|
|
430
|
-
const values = res
|
|
557
|
+
const values = requireValues(res)
|
|
431
558
|
// Every branch below (fresh write / refuse / merge / force / backup) acts on THIS
|
|
432
559
|
// resolved path. A destination that was silently dropped is how a real pull once
|
|
433
560
|
// dumped 22 secrets into an unrelated repo's cwd.
|
|
@@ -502,5 +629,5 @@ const pullCmd = defineCommand({
|
|
|
502
629
|
|
|
503
630
|
export default defineCommand({
|
|
504
631
|
meta: { name: 'env', description: 'Manage environment variables (Bodify global secrets store)' },
|
|
505
|
-
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 },
|
|
506
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
|
+
}
|
package/src/utils/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|
|
@@ -12,9 +12,12 @@ const APP_ID = 'e1dba964-b944-4dd0-b971-fe72ee493bf6'
|
|
|
12
12
|
const APP_NAME = 'blank'
|
|
13
13
|
|
|
14
14
|
const REMOTE: Record<string, string> = {
|
|
15
|
-
STRIPE_KEY: 'sk_live_51NxAbCdEfGhIjKlMnOpQrStU', // 33 cp ->
|
|
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
|
|
16
19
|
BOUND_19: 'abcdefghijklmnopqrs', // 19 cp -> 0 revealed (last length hidden)
|
|
17
|
-
BOUND_20: 'abcdefghijklmnopqrst', // 20 cp ->
|
|
20
|
+
BOUND_20: 'abcdefghijklmnopqrst', // 20 cp -> 2+2 revealed (first length shown)
|
|
18
21
|
LOW_ENTROPY: 'production', // 10 cp -> 0: the tail used to give it away
|
|
19
22
|
SHORT_PIN: '4821',
|
|
20
23
|
EMOJI_VAL: '\u{1F600}'.repeat(20), // astral: budget must not split a pair
|
|
@@ -96,14 +99,20 @@ test('masked by default (stdout is a pipe): keys visible, no plaintext secret an
|
|
|
96
99
|
for (const v of Object.values(REMOTE)) if (v) expect(stdout).not.toContain(v)
|
|
97
100
|
})
|
|
98
101
|
|
|
99
|
-
test('reveal boundary: 19 codepoints show nothing, 20 show
|
|
102
|
+
test('reveal boundary: 19 codepoints show nothing, 20 show two at EACH end', async () => {
|
|
100
103
|
const { stdout } = await runCli(['env', 'list', APP_NAME])
|
|
101
104
|
const DOTS = '\u2022'.repeat(6)
|
|
102
|
-
expect(valueCell(stdout, 'BOUND_20')).toBe(DOTS + '
|
|
105
|
+
expect(valueCell(stdout, 'BOUND_20')).toBe('ab' + DOTS + 'st') // 20 cp -> floor(20/10) = 2
|
|
103
106
|
expect(valueCell(stdout, 'BOUND_19')).toBe(DOTS)
|
|
104
|
-
// and nothing of the 19-char value leaks
|
|
105
|
-
for (let i = 1; i <= 19; i++)
|
|
106
|
-
|
|
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
|
|
107
116
|
// Length is exact and still shown — it is what spots a truncated paste.
|
|
108
117
|
expect(stdout).toMatch(/BOUND_19\s*\|.*\|\s*19\s*\|/)
|
|
109
118
|
expect(stdout).toMatch(/BOUND_20\s*\|.*\|\s*20\s*\|/)
|
|
@@ -130,7 +139,7 @@ test('an empty value renders as (empty), not as a mask', async () => {
|
|
|
130
139
|
|
|
131
140
|
test('astral values are sliced by codepoint — no lone surrogate, len counts codepoints', async () => {
|
|
132
141
|
const { stdout } = await runCli(['env', 'list', APP_NAME])
|
|
133
|
-
expect(valueCell(stdout, 'EMOJI_VAL')).toBe('\u2022'.repeat(6) + '\u{1F600}'.repeat(
|
|
142
|
+
expect(valueCell(stdout, 'EMOJI_VAL')).toBe('\u{1F600}'.repeat(2) + '\u2022'.repeat(6) + '\u{1F600}'.repeat(2))
|
|
134
143
|
expect(stdout).not.toContain('\uFFFD')
|
|
135
144
|
for (const ch of stdout) expect(ch.codePointAt(0)! >= 0xd800 && ch.codePointAt(0)! <= 0xdfff).toBe(false)
|
|
136
145
|
expect(stdout).toMatch(/EMOJI_VAL\s*\|.*\|\s*20\s*\|/)
|
|
@@ -151,7 +160,8 @@ test('control chars of every class are stripped and the row stays on one line',
|
|
|
151
160
|
const { stdout } = await runCli(['env', 'list', APP_NAME])
|
|
152
161
|
for (const [key, len, survivor, banned] of CONTROL_CASES) {
|
|
153
162
|
const cell = valueCell(stdout, key)
|
|
154
|
-
|
|
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)
|
|
155
165
|
for (const c of banned) expect(cell).not.toContain(c)
|
|
156
166
|
// ONE line: the key, the value and the len cell all sit on a single table row.
|
|
157
167
|
const row = stdout.split('\n').find(l => l.includes(key))!
|
|
@@ -195,17 +205,44 @@ test('--global passes the SERVER masked string through — maskValue is per-app
|
|
|
195
205
|
expect(stdout).not.toMatch(/\blen\b/)
|
|
196
206
|
})
|
|
197
207
|
|
|
198
|
-
test('maskValue budget: nothing below 20 codepoints, then
|
|
208
|
+
test('maskValue budget: nothing below 20 codepoints, then head = tail = min(4, len/10)', () => {
|
|
199
209
|
const DOTS = '\u2022'.repeat(6)
|
|
200
210
|
expect(maskValue('4821')).toBe(DOTS)
|
|
201
211
|
expect(maskValue('production')).toBe(DOTS)
|
|
202
|
-
expect(maskValue('a'.repeat(19))).toBe(DOTS)
|
|
203
|
-
expect(maskValue('abcdefghijklmnopqrst')).toBe(DOTS + '
|
|
204
|
-
expect(maskValue('
|
|
205
|
-
expect(maskValue('
|
|
206
|
-
expect(maskValue('
|
|
207
|
-
|
|
208
|
-
expect(maskValue('
|
|
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
|
+
}
|
|
209
246
|
})
|
|
210
247
|
|
|
211
248
|
test('displayLength counts codepoints, matching the slicing unit', () => {
|
|
@@ -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
|
-
|
|
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
|
})
|