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.
- package/.cursor/skills/using-bod-cli/SKILL.md +83 -2
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/commands/env.ts +224 -23
- 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 +251 -0
- package/test/env-pull-guard.test.ts +4 -2
|
@@ -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
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
|
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
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
|
+
})
|
|
@@ -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
|
-
|
|
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
|
})
|