bod-cli 0.10.7 → 0.10.9
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 +30 -1
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/commands/env.ts +113 -6
- package/src/commands/host.ts +20 -1
- package/src/utils/media-preview.ts +158 -0
- package/test/env-list-mask.test.ts +214 -0
- package/test/env-pull-guard.test.ts +119 -0
- package/test/host-no-preview.test.ts +75 -0
|
@@ -138,13 +138,42 @@ bod host ./assets --app my-api # persistent app assets
|
|
|
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
|
|
142
143
|
bod env set my-api DATABASE_URL=postgres://...
|
|
143
144
|
bod env set my-api -f .env # bulk set from .env file
|
|
144
145
|
bod env unset my-api OLD_VAR
|
|
145
146
|
bod env pull my-api # write the resolved env to ./.env (0600)
|
|
146
147
|
```
|
|
147
148
|
|
|
149
|
+
**`bod env list <app>` masks values by default.** It is the only scope that resolves
|
|
150
|
+
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 to answer
|
|
152
|
+
"is this the same key I have locally?" without exposing the value:
|
|
153
|
+
|
|
154
|
+
- **Under 20 codepoints → nothing is revealed.** A short value is usually low-entropy
|
|
155
|
+
(`production`, `true`, a PIN, a short enum) and a tail plus the length gives it away
|
|
156
|
+
outright — `••••••on` / len 10 *is* `production`. Real secrets are long, so this
|
|
157
|
+
costs the useful case nothing.
|
|
158
|
+
- **20 or more → `floor(len/5)` characters, capped at 4** — the familiar "…last four".
|
|
159
|
+
- An **empty** value renders as a dim `(empty)`, not as dots: `••••••` with `len 0`
|
|
160
|
+
would be indistinguishable from a masked secret.
|
|
161
|
+
- Everything is counted in **Unicode codepoints**, so an emoji is never sliced in half.
|
|
162
|
+
Characters that could corrupt the table are stripped from the tail: C0, DEL, C1
|
|
163
|
+
(U+0080–U+009F — a bare U+009B is a CSI that misaligns every column), U+2028/U+2029,
|
|
164
|
+
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.
|
|
166
|
+
|
|
167
|
+
The `len` column is that same codepoint count, and is deliberately **exact** — masking
|
|
168
|
+
cannot defeat low entropy anyway (the 20-codepoint threshold is what does), and an exact
|
|
169
|
+
length is what makes the listing useful for **comparing** two values (spotting a
|
|
170
|
+
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
|
|
171
|
+
it visibly renders. Masking is **not** TTY-conditional: piping does not switch
|
|
172
|
+
plaintext back on. For scripts use `--reveal`
|
|
173
|
+
(byte-identical to the old `KEY=VALUE` output) or `bod env pull … --stdout`.
|
|
174
|
+
`--reveal` is rejected with `--global/--group/--subs`, which only ever receive
|
|
175
|
+
server-side masked values.
|
|
176
|
+
|
|
148
177
|
**`bod env pull` never clobbers an existing file.** A `.env` is hand-maintained and
|
|
149
178
|
usually holds local-only credentials the server has never seen — replacing it can
|
|
150
179
|
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|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)
|
|
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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
import chalk from 'chalk'
|
|
3
|
-
import { writeFileSync, appendFileSync, readFileSync, existsSync } from 'fs'
|
|
3
|
+
import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, chmodSync } from 'fs'
|
|
4
|
+
import { dirname, resolve as resolvePath } from 'path'
|
|
5
|
+
import { homedir } from 'os'
|
|
4
6
|
import { loadConfig, getResolvedInstance } from '../config'
|
|
5
7
|
import { BodClient } from '../client'
|
|
6
8
|
import { printTable } from '../utils/output'
|
|
@@ -132,11 +134,60 @@ function diffEnv(existing: Record<string, string>, incoming: Record<string, stri
|
|
|
132
134
|
return { added, changed, removed }
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/** Characters that must never reach a table cell, in one place.
|
|
138
|
+
* C0 + DEL + C1 (U+0080-U+009F: a bare U+009B is a CSI, which makes cli-table3
|
|
139
|
+
* miscount the cell width and misalign every column after it), the Unicode line and
|
|
140
|
+
* paragraph separators U+2028/U+2029, and the bidi controls (U+200E/U+200F,
|
|
141
|
+
* U+202A-U+202E, U+2066-U+2069) — U+202E alone visually REVERSES the rest of the row,
|
|
142
|
+
* which is how a value lies about which key it belongs to. */
|
|
143
|
+
const UNSAFE_CELL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
|
|
144
|
+
|
|
145
|
+
/** Shown instead of dots for a value that is the empty string: `••••••` with `len 0`
|
|
146
|
+
* is indistinguishable from a masked secret at a glance, and "this var is set to
|
|
147
|
+
* nothing" is a different, actionable fact. */
|
|
148
|
+
export const EMPTY_MARKER = '(empty)'
|
|
149
|
+
|
|
150
|
+
/** Mask a resolved value for on-screen display.
|
|
151
|
+
*
|
|
152
|
+
* The tail is what makes a mask USEFUL — it is how you answer "is the deployed key
|
|
153
|
+
* the same one I have locally?" without printing the secret. But a tail only stays
|
|
154
|
+
* safe when the value is long enough that a few characters cannot reconstruct it:
|
|
155
|
+
* len < 20 → 0 revealed. Anything shorter is likely low-entropy (`production`,
|
|
156
|
+
* `true`, a PIN, a short enum) and a tail plus the length column
|
|
157
|
+
* de-anonymises it outright — `••••••on` / len 10 IS `production`.
|
|
158
|
+
* Real secrets are long, so this costs the useful case nothing.
|
|
159
|
+
* len >= 20 → floor(len/5), capped at 4 — the familiar "…last four" affordance.
|
|
160
|
+
* Everything is measured in CODEPOINTS, not UTF-16 code units, so an odd budget
|
|
161
|
+
* cannot slice an astral character (emoji, some CJK) in half and emit a lone
|
|
162
|
+
* surrogate. The `len` column reports the same unit.
|
|
163
|
+
* UNSAFE_CELL_CHARS are dropped from the tail so a value cannot break, reverse or
|
|
164
|
+
* misalign the table it is printed in.
|
|
165
|
+
*
|
|
166
|
+
* NOTE on `len`: it is deliberately still exact. Masking cannot defeat low entropy
|
|
167
|
+
* anyway — the length threshold above is the defence — and an exact length is what
|
|
168
|
+
* makes the listing useful for spotting a truncated or whitespace-padded paste. It
|
|
169
|
+
* counts codepoints of the resolved value: NOT a byte count and NOT a grapheme count
|
|
170
|
+
* (a combining-accent/NFD or flag-emoji string reports more than it renders).
|
|
171
|
+
* Compare it between two values; don't read it as "characters as a human counts". */
|
|
172
|
+
export function maskValue(value: string): string {
|
|
173
|
+
if (value === '') return EMPTY_MARKER
|
|
174
|
+
const chars = [...value]
|
|
175
|
+
const reveal = chars.length < 20 ? 0 : Math.min(4, Math.floor(chars.length / 5))
|
|
176
|
+
const tail = reveal ? chars.slice(-reveal).join('').replace(UNSAFE_CELL_CHARS, '') : ''
|
|
177
|
+
return '••••••' + tail
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Codepoint length — the unit the `len` column reports, matching maskValue's budget. */
|
|
181
|
+
export function displayLength(value: string): number {
|
|
182
|
+
return [...value].length
|
|
183
|
+
}
|
|
184
|
+
|
|
135
185
|
const listCmd = defineCommand({
|
|
136
|
-
meta: { name: 'list', description: 'List env vars
|
|
186
|
+
meta: { name: 'list', description: 'List env vars (values MASKED by default; --reveal prints them in full). --global/--group/--subs: variables in that scope.' },
|
|
137
187
|
args: {
|
|
138
188
|
app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
|
|
139
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' },
|
|
140
191
|
global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
|
|
141
192
|
group: { type: 'string', description: 'List masked entries scoped to a group; excludes <app>/--global' },
|
|
142
193
|
subs: { type: 'boolean', description: 'List masked platform-GLOBAL subs provider secrets (__subs__~~global; money creds kept out of app envs)' },
|
|
@@ -148,6 +199,12 @@ const listCmd = defineCommand({
|
|
|
148
199
|
|
|
149
200
|
const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
|
|
150
201
|
assertScopeFlags(args.app, !!args.global, args.group, subs, args.env)
|
|
202
|
+
// The other scopes only ever receive server-side `masked` strings — there is no
|
|
203
|
+
// plaintext to reveal there. Say so instead of silently ignoring the flag.
|
|
204
|
+
if (args.reveal && (args.global || args.group || isSubsMode(subs))) {
|
|
205
|
+
console.error(chalk.red('--reveal only applies to the per-app scope; --global/--group/--subs return masked values from the server.'))
|
|
206
|
+
process.exit(1)
|
|
207
|
+
}
|
|
151
208
|
|
|
152
209
|
if (isSubsMode(subs)) {
|
|
153
210
|
const scopeApp = await buildSubsScopeApp(client, subs)
|
|
@@ -184,7 +241,7 @@ const listCmd = defineCommand({
|
|
|
184
241
|
return
|
|
185
242
|
}
|
|
186
243
|
|
|
187
|
-
// Per-app:
|
|
244
|
+
// Per-app: the only scope that can resolve PLAINTEXT secrets. Mask unless asked.
|
|
188
245
|
const appId = await resolveAppId(client, resolveAppName(args.app))
|
|
189
246
|
const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
|
|
190
247
|
const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
|
|
@@ -193,7 +250,26 @@ const listCmd = defineCommand({
|
|
|
193
250
|
console.log(chalk.dim('No environment variables set.'))
|
|
194
251
|
return
|
|
195
252
|
}
|
|
196
|
-
|
|
253
|
+
|
|
254
|
+
// Machine output: DELIBERATELY not TTY-conditional. Piping into a file or a log
|
|
255
|
+
// is exactly the case where leaking plaintext hurts most, so a pipe must not
|
|
256
|
+
// silently switch the output back to secrets — and a script must see what the
|
|
257
|
+
// human saw. Scripting keeps two explicit, unchanged paths: `--reveal` (emits the
|
|
258
|
+
// pre-masking KEY=VALUE format byte-for-byte) and `bod env pull --stdout`.
|
|
259
|
+
if (args.reveal) {
|
|
260
|
+
for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
printTable(
|
|
265
|
+
Object.entries(values).map(([key, v]) => {
|
|
266
|
+
const cell = maskValue(v)
|
|
267
|
+
return { key, value: cell === EMPTY_MARKER ? chalk.dim(cell) : cell, len: displayLength(v) }
|
|
268
|
+
}),
|
|
269
|
+
['key', 'value', 'len'],
|
|
270
|
+
)
|
|
271
|
+
// Hint on stderr so stdout stays a clean, parseable table.
|
|
272
|
+
console.error(chalk.dim(' values masked — `bod env list … --reveal` to print them, or `bod env pull … --stdout`'))
|
|
197
273
|
},
|
|
198
274
|
})
|
|
199
275
|
|
|
@@ -307,10 +383,32 @@ const rmCmd = defineCommand({
|
|
|
307
383
|
},
|
|
308
384
|
})
|
|
309
385
|
|
|
386
|
+
/** `env pull` takes <app> and an optional destination path, either of which may be
|
|
387
|
+
* omitted (app falls back to bodify.yaml). With ONE positional the two are
|
|
388
|
+
* ambiguous, so a token that is EXPLICITLY a path ('/', '~', or a leading '.') is
|
|
389
|
+
* read as the destination. A bare `name.ext` is NOT enough: an app may legitimately
|
|
390
|
+
* be called `my.app`, and guessing wrong writes another app's secrets into cwd. */
|
|
391
|
+
export function splitPullPositionals(a?: string, b?: string): { app?: string; dest?: string } {
|
|
392
|
+
if (b !== undefined) return { app: a, dest: b }
|
|
393
|
+
if (a !== undefined && looksLikePath(a)) return { app: undefined, dest: a }
|
|
394
|
+
return { app: a, dest: undefined }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A '~' reaching us unexpanded (quoted, or an argv built by a script) must not become
|
|
398
|
+
* a literal './~/...' directory holding real secrets. */
|
|
399
|
+
function expandTilde(p: string): string {
|
|
400
|
+
return p === '~' || p.startsWith('~/') ? homedir() + p.slice(1) : p
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function looksLikePath(s: string): boolean {
|
|
404
|
+
return s.includes('/') || s.startsWith('~') || s.startsWith('.')
|
|
405
|
+
}
|
|
406
|
+
|
|
310
407
|
const pullCmd = defineCommand({
|
|
311
408
|
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.' },
|
|
312
409
|
args: {
|
|
313
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 },
|
|
314
412
|
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
315
413
|
output: { type: 'string', alias: 'o', description: 'Output file (default .env)' },
|
|
316
414
|
stdout: { type: 'boolean', description: 'Print to stdout instead of writing any file' },
|
|
@@ -325,11 +423,15 @@ const pullCmd = defineCommand({
|
|
|
325
423
|
|
|
326
424
|
const { url, apiKey } = getResolvedInstance(loadConfig())
|
|
327
425
|
const client = new BodClient(url, apiKey)
|
|
328
|
-
const
|
|
426
|
+
const { app, dest } = splitPullPositionals(args.app, args.dest)
|
|
427
|
+
const appId = await resolveAppId(client, resolveAppName(app))
|
|
329
428
|
const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
|
|
330
429
|
const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
|
|
331
430
|
const values = res.values ?? {}
|
|
332
|
-
|
|
431
|
+
// Every branch below (fresh write / refuse / merge / force / backup) acts on THIS
|
|
432
|
+
// resolved path. A destination that was silently dropped is how a real pull once
|
|
433
|
+
// dumped 22 secrets into an unrelated repo's cwd.
|
|
434
|
+
const out = resolvePath(process.cwd(), expandTilde(args.output ?? dest ?? '.env'))
|
|
333
435
|
|
|
334
436
|
const body = renderDotEnv(values)
|
|
335
437
|
|
|
@@ -340,6 +442,7 @@ const pullCmd = defineCommand({
|
|
|
340
442
|
}
|
|
341
443
|
|
|
342
444
|
if (!existsSync(out)) {
|
|
445
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
343
446
|
writeFileSync(out, body, { mode: 0o600 })
|
|
344
447
|
console.log(chalk.green(`✓ Wrote ${Object.keys(values).length} var(s) to ${out}`))
|
|
345
448
|
return
|
|
@@ -363,6 +466,7 @@ const pullCmd = defineCommand({
|
|
|
363
466
|
const addition = renderDotEnv(Object.fromEntries(d.added.map(k => [k, values[k]])))
|
|
364
467
|
const sep = existingText.length && !existingText.endsWith('\n') ? '\n' : ''
|
|
365
468
|
appendFileSync(out, `${sep}${addition}`)
|
|
469
|
+
chmodSync(out, 0o600)
|
|
366
470
|
console.log(chalk.green(`✓ Added ${d.added.length} var(s) to ${out}: ${d.added.join(', ')}`))
|
|
367
471
|
if (d.changed.length) console.log(chalk.yellow(` Kept your local value for ${d.changed.length} differing key(s): ${d.changed.join(', ')}`))
|
|
368
472
|
if (backup) console.log(chalk.dim(` Backup: ${backup}`))
|
|
@@ -372,6 +476,9 @@ const pullCmd = defineCommand({
|
|
|
372
476
|
if (args.force) {
|
|
373
477
|
const backup = backupFile(out)
|
|
374
478
|
writeFileSync(out, body, { mode: 0o600 })
|
|
479
|
+
// `mode` only applies when the file is CREATED — an existing 0644 dotenv would
|
|
480
|
+
// otherwise stay world-readable after being filled with the server's secrets.
|
|
481
|
+
chmodSync(out, 0o600)
|
|
375
482
|
console.log(chalk.green(`✓ Replaced ${out} with ${Object.keys(values).length} var(s)`))
|
|
376
483
|
if (d.removed.length) console.log(chalk.yellow(` Dropped ${d.removed.length} local-only key(s): ${d.removed.join(', ')}`))
|
|
377
484
|
if (backup) console.log(chalk.dim(` Backup: ${backup}`))
|
package/src/commands/host.ts
CHANGED
|
@@ -3,6 +3,7 @@ import chalk from 'chalk'
|
|
|
3
3
|
import { statSync, readdirSync, readFileSync, existsSync } from 'fs'
|
|
4
4
|
import { join, relative, basename, extname } from 'path'
|
|
5
5
|
import { loadConfig, getResolvedInstance } from '../config'
|
|
6
|
+
import { buildMediaPreview } from '../utils/media-preview'
|
|
6
7
|
|
|
7
8
|
const MIME: Record<string, string> = {
|
|
8
9
|
'.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
|
|
@@ -42,6 +43,9 @@ export default defineCommand({
|
|
|
42
43
|
path: { type: 'positional', description: 'File or directory to publish', required: true },
|
|
43
44
|
slug: { type: 'string', description: 'Reuse/update an existing site slug' },
|
|
44
45
|
app: { type: 'string', description: 'Link to a Bodify app id → persistent (else ephemeral, 48h)' },
|
|
46
|
+
// citty/mri folds `--no-preview` into `preview: false`; a literal 'no-preview' arg
|
|
47
|
+
// never receives it, so the documented opt-out silently did nothing.
|
|
48
|
+
preview: { type: 'boolean', default: true, description: 'Auto-generate an OG landing page for a single video/audio file (--no-preview to skip)' },
|
|
45
49
|
},
|
|
46
50
|
async run({ args }) {
|
|
47
51
|
const root = args.path
|
|
@@ -50,6 +54,16 @@ export default defineCommand({
|
|
|
50
54
|
const files = collect(root)
|
|
51
55
|
if (!files.length) { console.error(chalk.red('No files to publish')); process.exit(1) }
|
|
52
56
|
|
|
57
|
+
// A bare video/mp4 URL cannot unfurl in WhatsApp/iMessage/Slack — those need an
|
|
58
|
+
// HTML document with og:* tags. For a single media file, synthesize one.
|
|
59
|
+
const preview = (args.preview !== false && files.length === 1 && statSync(root).isFile())
|
|
60
|
+
? buildMediaPreview(files[0].abs, mimeFor(files[0].rel))
|
|
61
|
+
: null
|
|
62
|
+
if (preview) {
|
|
63
|
+
if (preview.warning) console.log(chalk.yellow(`! ${preview.warning}`))
|
|
64
|
+
files.push(...preview.extras)
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
const { url, apiKey, name: instanceName } = getResolvedInstance(loadConfig())
|
|
54
68
|
const auth: Record<string, string> = apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
|
|
55
69
|
const totalBytes = files.reduce((n, f) => n + statSync(f.abs).size, 0)
|
|
@@ -68,6 +82,9 @@ export default defineCommand({
|
|
|
68
82
|
upload: { uploads: Array<{ path: string; url: string | null; key: string }>; finalizeUrl: string }
|
|
69
83
|
}
|
|
70
84
|
|
|
85
|
+
// 2a. og:* URLs must be absolute — re-render index.html now that the slug is known.
|
|
86
|
+
if (preview) preview.render(`${site.siteUrl.replace(/\/+$/, '')}`)
|
|
87
|
+
|
|
71
88
|
// 2. Upload each file — presigned PUT (R2) or direct-to-agent PUT (local provider).
|
|
72
89
|
const byPath = new Map(files.map(f => [f.rel, f.abs]))
|
|
73
90
|
for (const u of site.upload.uploads) {
|
|
@@ -87,7 +104,9 @@ export default defineCommand({
|
|
|
87
104
|
const finRes = await fetch(`${url}${site.upload.finalizeUrl}`, { method: 'POST', headers: { ...auth } })
|
|
88
105
|
if (!finRes.ok) { console.error(chalk.red(`Finalize failed: ${finRes.status} ${await finRes.text()}`)); process.exit(1) }
|
|
89
106
|
|
|
90
|
-
|
|
107
|
+
const shareUrl = preview ? `${site.siteUrl.replace(/\/+$/, '')}/` : site.siteUrl
|
|
108
|
+
console.log(chalk.green(`✓ Live: ${shareUrl}`))
|
|
109
|
+
if (preview) console.log(chalk.dim(` share this URL — it previews in WhatsApp/iMessage/Slack`))
|
|
91
110
|
console.log(chalk.dim(` slug: ${site.slug}${args.app ? ` (persistent, app ${args.app})` : site.expiresAt ? ` (expires ${new Date(site.expiresAt).toISOString()})` : ''}`))
|
|
92
111
|
},
|
|
93
112
|
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { statSync, mkdtempSync, existsSync, writeFileSync } from 'fs'
|
|
3
|
+
import { tmpdir } from 'os'
|
|
4
|
+
import { join, basename } from 'path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Chat apps (WhatsApp/iMessage/Slack) unfurl HTML with Open Graph tags — never a
|
|
8
|
+
* bare `video/mp4` response. A single-media `bod host ./clip.mp4` therefore has
|
|
9
|
+
* nothing to unfurl. This synthesizes the missing landing page: an `index.html`
|
|
10
|
+
* carrying og:* + twitter:* and a `poster.jpg` frame pulled with ffmpeg, so the
|
|
11
|
+
* bare `…/s/<slug>/` URL previews and plays.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** WhatsApp drops og:image over ~600 KB entirely — stay well under. */
|
|
15
|
+
const POSTER_MAX_BYTES = 500 * 1024
|
|
16
|
+
|
|
17
|
+
export type PreviewExtra = { rel: string; abs: string }
|
|
18
|
+
|
|
19
|
+
function esc(s: string): string {
|
|
20
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function run(cmd: string, args: string[]): { ok: boolean; stdout: string } {
|
|
24
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8' })
|
|
25
|
+
return { ok: r.status === 0, stdout: r.stdout ?? '' }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function probe(abs: string): { width?: number; height?: number; duration?: number } {
|
|
29
|
+
const r = run('ffprobe', ['-v', 'error', '-select_streams', 'v:0',
|
|
30
|
+
'-show_entries', 'stream=width,height:format=duration', '-of', 'csv=p=0', abs])
|
|
31
|
+
if (!r.ok) return {}
|
|
32
|
+
const nums = r.stdout.trim().split(/[\s,]+/).map(Number).filter(n => Number.isFinite(n))
|
|
33
|
+
const [width, height, duration] = nums
|
|
34
|
+
return { width, height, duration }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Grab a representative frame as JPEG, shrinking quality until it fits the cap. */
|
|
38
|
+
function extractPoster(abs: string, outDir: string, duration?: number): string | null {
|
|
39
|
+
const out = join(outDir, 'poster.jpg')
|
|
40
|
+
// A frame ~10% in beats frame 0, which is often black/a fade-in.
|
|
41
|
+
const seek = duration && duration > 2 ? Math.min(duration * 0.1, 5) : 0
|
|
42
|
+
for (const q of ['3', '6', '9']) {
|
|
43
|
+
const r = run('ffmpeg', ['-y', '-ss', String(seek), '-i', abs, '-frames:v', '1',
|
|
44
|
+
'-vf', "scale='min(1280,iw)':-2", '-q:v', q, out])
|
|
45
|
+
if (!r.ok || !existsSync(out)) return null
|
|
46
|
+
if (statSync(out).size <= POSTER_MAX_BYTES) return out
|
|
47
|
+
}
|
|
48
|
+
return existsSync(out) && statSync(out).size <= POSTER_MAX_BYTES ? out : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function page(o: {
|
|
52
|
+
title: string; fileName: string; kind: 'video' | 'audio'
|
|
53
|
+
poster: boolean; width?: number; height?: number; mime: string
|
|
54
|
+
posterWidth?: number; posterHeight?: number
|
|
55
|
+
/** Absolute site base, e.g. https://bodify.bod.ee/s/<slug> (no trailing slash). */
|
|
56
|
+
base: string
|
|
57
|
+
}): string {
|
|
58
|
+
const t = esc(o.title)
|
|
59
|
+
// og:*/twitter:* MUST be absolute — crawlers do not resolve relative URLs.
|
|
60
|
+
const f = `${o.base}/${encodeURIComponent(o.fileName)}`
|
|
61
|
+
const posterUrl = `${o.base}/poster.jpg`
|
|
62
|
+
const media = o.kind === 'video'
|
|
63
|
+
? `<video src="${f}" ${o.poster ? `poster="${posterUrl}"` : ''} controls playsinline autoplay muted loop></video>`
|
|
64
|
+
: `<audio src="${f}" controls></audio>`
|
|
65
|
+
return `<!doctype html>
|
|
66
|
+
<html lang="en">
|
|
67
|
+
<head>
|
|
68
|
+
<meta charset="utf-8">
|
|
69
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
70
|
+
<title>${t}</title>
|
|
71
|
+
<meta property="og:type" content="${o.kind}.other">
|
|
72
|
+
<meta property="og:title" content="${t}">
|
|
73
|
+
<meta property="og:description" content="${esc(o.fileName)}">
|
|
74
|
+
${o.kind === 'video' ? `<meta property="og:video" content="${f}">
|
|
75
|
+
<meta property="og:video:secure_url" content="${f}">
|
|
76
|
+
<meta property="og:video:type" content="${o.mime}">
|
|
77
|
+
${o.width ? `<meta property="og:video:width" content="${o.width}">` : ''}
|
|
78
|
+
${o.height ? `<meta property="og:video:height" content="${o.height}">` : ''}` : `<meta property="og:audio" content="${f}">
|
|
79
|
+
<meta property="og:audio:type" content="${o.mime}">`}
|
|
80
|
+
${o.poster ? `<meta property="og:image" content="${posterUrl}">
|
|
81
|
+
<meta property="og:image:type" content="image/jpeg">
|
|
82
|
+
${o.posterWidth ? `<meta property="og:image:width" content="${o.posterWidth}">` : ''}
|
|
83
|
+
${o.posterHeight ? `<meta property="og:image:height" content="${o.posterHeight}">` : ''}` : ''}
|
|
84
|
+
<meta name="twitter:card" content="${o.poster ? 'summary_large_image' : 'summary'}">
|
|
85
|
+
<meta name="twitter:title" content="${t}">
|
|
86
|
+
${o.poster ? `<meta name="twitter:image" content="${posterUrl}">` : ''}
|
|
87
|
+
<style>
|
|
88
|
+
:root{color-scheme:dark}
|
|
89
|
+
html,body{margin:0;height:100%;background:#0b0b0d;color:#e8e8ea;
|
|
90
|
+
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
91
|
+
body{display:grid;place-items:center;padding:16px;box-sizing:border-box}
|
|
92
|
+
video,audio{max-width:100%;max-height:92vh;border-radius:12px;display:block}
|
|
93
|
+
</style>
|
|
94
|
+
</head>
|
|
95
|
+
<body>${media}</body>
|
|
96
|
+
</html>
|
|
97
|
+
`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type MediaPreview = {
|
|
101
|
+
/** Files to add to the publish manifest (poster only — index.html is rendered later). */
|
|
102
|
+
extras: PreviewExtra[]
|
|
103
|
+
/** Render + write index.html once the absolute site base URL is known. Returns its abs path. */
|
|
104
|
+
render: (base: string) => string
|
|
105
|
+
warning?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Prepare the OG landing page + poster for a single-media publish.
|
|
110
|
+
* Returns null when not applicable (the file isn't video/audio).
|
|
111
|
+
* og:* URLs must be absolute, and the slug is only known after /v1/publish —
|
|
112
|
+
* so the poster is produced now and the HTML is rendered by `render(base)`.
|
|
113
|
+
*/
|
|
114
|
+
export function buildMediaPreview(abs: string, mime: string): MediaPreview | null {
|
|
115
|
+
const kind = mime.startsWith('video/') ? 'video' : mime.startsWith('audio/') ? 'audio' : null
|
|
116
|
+
if (!kind) return null
|
|
117
|
+
|
|
118
|
+
const dir = mkdtempSync(join(tmpdir(), 'bod-host-og-'))
|
|
119
|
+
const fileName = basename(abs)
|
|
120
|
+
const extras: PreviewExtra[] = []
|
|
121
|
+
let warning: string | undefined
|
|
122
|
+
let posterAbs: string | null = null
|
|
123
|
+
let meta: ReturnType<typeof probe> = {}
|
|
124
|
+
|
|
125
|
+
if (kind === 'video') {
|
|
126
|
+
if (!run('ffmpeg', ['-version']).ok) {
|
|
127
|
+
warning = 'ffmpeg not found — no poster frame, so chat apps will show a title-only preview.'
|
|
128
|
+
} else {
|
|
129
|
+
meta = probe(abs)
|
|
130
|
+
posterAbs = extractPoster(abs, dir, meta.duration)
|
|
131
|
+
if (!posterAbs) warning = 'Could not extract a poster frame — preview will be title-only.'
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const htmlPath = join(dir, 'index.html')
|
|
136
|
+
if (posterAbs) extras.push({ rel: 'poster.jpg', abs: posterAbs })
|
|
137
|
+
|
|
138
|
+
const posterDims = posterAbs ? probe(posterAbs) : {}
|
|
139
|
+
const render = (base: string): string => {
|
|
140
|
+
const html = page({
|
|
141
|
+
title: fileName.replace(/\.[^.]+$/, ''),
|
|
142
|
+
fileName, kind, mime,
|
|
143
|
+
poster: !!posterAbs,
|
|
144
|
+
width: meta.width, height: meta.height,
|
|
145
|
+
posterWidth: posterDims.width, posterHeight: posterDims.height,
|
|
146
|
+
base: base.replace(/\/+$/, ''),
|
|
147
|
+
})
|
|
148
|
+
writeFileSync(htmlPath, html)
|
|
149
|
+
return htmlPath
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Write a placeholder now so the manifest can declare a size; finalize verifies
|
|
153
|
+
// existence, not size, and render() rewrites it with the real absolute URLs.
|
|
154
|
+
render('https://placeholder.invalid/s/placeholder')
|
|
155
|
+
extras.unshift({ rel: 'index.html', abs: htmlPath })
|
|
156
|
+
|
|
157
|
+
return { extras, render, warning }
|
|
158
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
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 -> 4 revealed
|
|
16
|
+
BOUND_19: 'abcdefghijklmnopqrs', // 19 cp -> 0 revealed (last length hidden)
|
|
17
|
+
BOUND_20: 'abcdefghijklmnopqrst', // 20 cp -> 4 revealed (first length shown)
|
|
18
|
+
LOW_ENTROPY: 'production', // 10 cp -> 0: the tail used to give it away
|
|
19
|
+
SHORT_PIN: '4821',
|
|
20
|
+
EMOJI_VAL: '\u{1F600}'.repeat(20), // astral: budget must not split a pair
|
|
21
|
+
EMPTY_VAL: '',
|
|
22
|
+
// One fixture per control-char class, each 20 codepoints so a tail IS budgeted and
|
|
23
|
+
// the strip is actually exercised. Tail = the last 4 codepoints in every case.
|
|
24
|
+
C0_VAL: 'abcdefghijklmnop\r\n\tx', // C0 + DEL family
|
|
25
|
+
DEL_VAL: 'abcdefghijklmnop\u007f\u007f\u007fx',
|
|
26
|
+
C1_VAL: 'abcdefghijklmnop\u009b\u0085\u0080x', // U+009B = CSI (breaks table width), U+0085 = NEL
|
|
27
|
+
SEP_VAL: 'abcdefghijklmnop\u2028\u2029\u2028x', // line / paragraph separators
|
|
28
|
+
BIDI_VAL: 'abcdefghijklmnop\u202e\u200f\u2066x', // RTL override + mark + isolate
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// What the server returns for --global: it masks server-side, so `masked` is the
|
|
32
|
+
// authoritative display string and the CLI must pass it through untouched.
|
|
33
|
+
const GLOBAL_VARS = [
|
|
34
|
+
{ key: 'SHARED_DB_URL', entries: [{ scope: {}, masked: 'post***rres', updatedAt: 0 }] },
|
|
35
|
+
{ key: 'TEAM_TOKEN', entries: [{ scope: { group: 'infra' }, masked: 'ghp_***', updatedAt: 0 }] },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
let server: ReturnType<typeof Bun.serve>
|
|
39
|
+
let home: string
|
|
40
|
+
let cwd: string
|
|
41
|
+
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
server = Bun.serve({
|
|
44
|
+
port: 0,
|
|
45
|
+
fetch(req) {
|
|
46
|
+
const url = new URL(req.url)
|
|
47
|
+
if (url.pathname === '/api/apps') return Response.json([{ id: APP_ID, name: APP_NAME }])
|
|
48
|
+
if (url.pathname === `/api/apps/${APP_ID}/env`) return Response.json({ values: REMOTE })
|
|
49
|
+
if (url.pathname === '/api/secrets/vars') return Response.json(GLOBAL_VARS)
|
|
50
|
+
return new Response('not found', { status: 404 })
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
home = mkdtempSync(join(tmpdir(), 'bod-list-home-'))
|
|
54
|
+
cwd = mkdtempSync(join(tmpdir(), 'bod-list-cwd-'))
|
|
55
|
+
mkdirSync(join(home, '.bod'), { recursive: true })
|
|
56
|
+
writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
|
|
57
|
+
defaultInstance: 'test',
|
|
58
|
+
instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
|
|
59
|
+
}))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
server?.stop(true)
|
|
64
|
+
rmSync(home, { recursive: true, force: true })
|
|
65
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/** The `value` cell of one row of the masked table, for assertions that must not be
|
|
69
|
+
* confused by the `len` column or another row. */
|
|
70
|
+
function valueCell(stdout: string, key: string): string {
|
|
71
|
+
const row = stdout.split('\n').find(l => new RegExp(`\\|\\s*${key}\\s*\\|`).test(l))
|
|
72
|
+
if (!row) throw new Error(`no row for ${key} in:\n${stdout}`)
|
|
73
|
+
return row.split('|')[2]!.trim()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function runCli(args: string[]) {
|
|
77
|
+
const proc = Bun.spawn(['bun', CLI, ...args], {
|
|
78
|
+
cwd,
|
|
79
|
+
env: { ...process.env, HOME: home, BOD_INSTANCE: '', FORCE_COLOR: '0' },
|
|
80
|
+
stdout: 'pipe', stderr: 'pipe',
|
|
81
|
+
})
|
|
82
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
83
|
+
new Response(proc.stdout).text(),
|
|
84
|
+
new Response(proc.stderr).text(),
|
|
85
|
+
proc.exited,
|
|
86
|
+
])
|
|
87
|
+
return { stdout, stderr, code }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// NOTE: Bun.spawn gives the CLI a PIPE, not a TTY — so every test here is already the
|
|
91
|
+
// piped case. Masking being non-TTY-conditional is what makes these assertions hold.
|
|
92
|
+
test('masked by default (stdout is a pipe): keys visible, no plaintext secret anywhere', async () => {
|
|
93
|
+
const { code, stdout } = await runCli(['env', 'list', APP_NAME])
|
|
94
|
+
expect(code).toBe(0)
|
|
95
|
+
for (const k of Object.keys(REMOTE)) expect(stdout).toContain(k)
|
|
96
|
+
for (const v of Object.values(REMOTE)) if (v) expect(stdout).not.toContain(v)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('reveal boundary: 19 codepoints show nothing, 20 show the last four', async () => {
|
|
100
|
+
const { stdout } = await runCli(['env', 'list', APP_NAME])
|
|
101
|
+
const DOTS = '\u2022'.repeat(6)
|
|
102
|
+
expect(valueCell(stdout, 'BOUND_20')).toBe(DOTS + 'qrst')
|
|
103
|
+
expect(valueCell(stdout, 'BOUND_19')).toBe(DOTS)
|
|
104
|
+
// and nothing of the 19-char value leaks by any suffix length
|
|
105
|
+
for (let i = 1; i <= 19; i++) expect(valueCell(stdout, 'BOUND_19')).not.toContain('abcdefghijklmnopqrs'.slice(-i))
|
|
106
|
+
expect(valueCell(stdout, 'STRIPE_KEY')).toBe(DOTS + 'rStU') // 33 cp -> 4
|
|
107
|
+
// Length is exact and still shown — it is what spots a truncated paste.
|
|
108
|
+
expect(stdout).toMatch(/BOUND_19\s*\|.*\|\s*19\s*\|/)
|
|
109
|
+
expect(stdout).toMatch(/BOUND_20\s*\|.*\|\s*20\s*\|/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('a low-entropy value at the old boundary is no longer de-anonymised', async () => {
|
|
113
|
+
const { stdout } = await runCli(['env', 'list', APP_NAME])
|
|
114
|
+
const DOTS = '\u2022'.repeat(6)
|
|
115
|
+
// The regression this threshold exists for: `production` is 10 cp and used to render
|
|
116
|
+
// as ••••••on / len 10, which is the whole value.
|
|
117
|
+
expect(valueCell(stdout, 'LOW_ENTROPY')).toBe(DOTS)
|
|
118
|
+
expect(maskValue('production')).toBe(DOTS)
|
|
119
|
+
expect(maskValue('false')).toBe(DOTS)
|
|
120
|
+
expect(maskValue('true')).toBe(DOTS)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('an empty value renders as (empty), not as a mask', async () => {
|
|
124
|
+
const { stdout } = await runCli(['env', 'list', APP_NAME])
|
|
125
|
+
expect(valueCell(stdout, 'EMPTY_VAL')).toBe('(empty)')
|
|
126
|
+
expect(valueCell(stdout, 'EMPTY_VAL')).not.toContain('\u2022')
|
|
127
|
+
expect(stdout).toMatch(/EMPTY_VAL\s*\|.*\|\s*0\s*\|/)
|
|
128
|
+
expect(maskValue('')).toBe('(empty)')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('astral values are sliced by codepoint — no lone surrogate, len counts codepoints', async () => {
|
|
132
|
+
const { stdout } = await runCli(['env', 'list', APP_NAME])
|
|
133
|
+
expect(valueCell(stdout, 'EMOJI_VAL')).toBe('\u2022'.repeat(6) + '\u{1F600}'.repeat(4))
|
|
134
|
+
expect(stdout).not.toContain('\uFFFD')
|
|
135
|
+
for (const ch of stdout) expect(ch.codePointAt(0)! >= 0xd800 && ch.codePointAt(0)! <= 0xdfff).toBe(false)
|
|
136
|
+
expect(stdout).toMatch(/EMOJI_VAL\s*\|.*\|\s*20\s*\|/)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Every class the strip must cover. A leaked one does one of two visible harms: it
|
|
140
|
+
// breaks the row across lines (C0/NEL/LS/PS) or it reflows/miscounts the cell
|
|
141
|
+
// (bidi override, C1 CSI) — so assert BOTH: one line, and not a single such char.
|
|
142
|
+
const CONTROL_CASES: Array<[string, number, string, string[]]> = [
|
|
143
|
+
['C0_VAL', 20, 'x', ['\r', '\n', '\t']],
|
|
144
|
+
['DEL_VAL', 20, 'x', ['\u007f']],
|
|
145
|
+
['C1_VAL', 20, 'x', ['\u009b', '\u0085', '\u0080']],
|
|
146
|
+
['SEP_VAL', 20, 'x', ['\u2028', '\u2029']],
|
|
147
|
+
['BIDI_VAL', 20, 'x', ['\u202e', '\u200f', '\u2066']],
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
test('control chars of every class are stripped and the row stays on one line', async () => {
|
|
151
|
+
const { stdout } = await runCli(['env', 'list', APP_NAME])
|
|
152
|
+
for (const [key, len, survivor, banned] of CONTROL_CASES) {
|
|
153
|
+
const cell = valueCell(stdout, key)
|
|
154
|
+
expect(cell).toBe('\u2022'.repeat(6) + survivor) // only the safe tail char survives
|
|
155
|
+
for (const c of banned) expect(cell).not.toContain(c)
|
|
156
|
+
// ONE line: the key, the value and the len cell all sit on a single table row.
|
|
157
|
+
const row = stdout.split('\n').find(l => l.includes(key))!
|
|
158
|
+
for (const c of banned) expect(row).not.toContain(c)
|
|
159
|
+
expect(row).toMatch(new RegExp(`${key}\\s*\\|[^\\n]*\\|\\s*${len}\\s*\\|`))
|
|
160
|
+
}
|
|
161
|
+
// Column alignment is intact: with no stray CSI miscounting a cell, every border
|
|
162
|
+
// rule is the same width. (This is what a leaked U+009B used to break.)
|
|
163
|
+
const rules = stdout.split('\n').filter(l => l.startsWith('+')).map(l => l.length)
|
|
164
|
+
expect(rules.length).toBeGreaterThan(1)
|
|
165
|
+
expect(new Set(rules).size).toBe(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('--reveal prints full plaintext in the original KEY=VALUE format', async () => {
|
|
169
|
+
const { code, stdout } = await runCli(['env', 'list', APP_NAME, '--reveal'])
|
|
170
|
+
expect(code).toBe(0)
|
|
171
|
+
// Byte-identical to the pre-masking format: one KEY=VALUE line per var, raw values.
|
|
172
|
+
expect(stdout).toBe(Object.entries(REMOTE).map(([k, v]) => `${k}=${v}`).join('\n') + '\n')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('the --reveal hint goes to stderr, keeping stdout parseable', async () => {
|
|
176
|
+
const { stdout, stderr } = await runCli(['env', 'list', APP_NAME])
|
|
177
|
+
expect(stderr).toContain('--reveal')
|
|
178
|
+
expect(stdout).not.toContain('--reveal')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('--reveal is rejected on scopes that only ever hold masked server values', async () => {
|
|
182
|
+
const { code, stderr } = await runCli(['env', 'list', '--global', '--reveal'])
|
|
183
|
+
expect(code).toBe(1)
|
|
184
|
+
expect(stderr).toContain('--reveal only applies to the per-app scope')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('--global passes the SERVER masked string through — maskValue is per-app only', async () => {
|
|
188
|
+
const { code, stdout } = await runCli(['env', 'list', '--global'])
|
|
189
|
+
expect(code).toBe(0)
|
|
190
|
+
for (const v of GLOBAL_VARS) {
|
|
191
|
+
expect(stdout).toContain(v.key)
|
|
192
|
+
expect(stdout).toContain(v.entries[0].masked) // verbatim, not re-masked
|
|
193
|
+
}
|
|
194
|
+
expect(stdout).not.toContain('\u2022')
|
|
195
|
+
expect(stdout).not.toMatch(/\blen\b/)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('maskValue budget: nothing below 20 codepoints, then floor(len/5) capped at 4', () => {
|
|
199
|
+
const DOTS = '\u2022'.repeat(6)
|
|
200
|
+
expect(maskValue('4821')).toBe(DOTS)
|
|
201
|
+
expect(maskValue('production')).toBe(DOTS)
|
|
202
|
+
expect(maskValue('a'.repeat(19))).toBe(DOTS) // 19 -> still nothing
|
|
203
|
+
expect(maskValue('abcdefghijklmnopqrst')).toBe(DOTS + 'qrst') // 20 -> the first tail
|
|
204
|
+
expect(maskValue('a'.repeat(100))).toBe(DOTS + 'aaaa')
|
|
205
|
+
expect(maskValue('\u{1F600}'.repeat(20))).toBe(DOTS + '\u{1F600}'.repeat(4))
|
|
206
|
+
expect(maskValue('\u{1F600}'.repeat(19))).toBe(DOTS) // 19 codepoints (38 UTF-16 units)
|
|
207
|
+
// A tail that is ENTIRELY unsafe strips to nothing — reveals less, never more.
|
|
208
|
+
expect(maskValue('abcdefghijklmnop\u202e\u2028\u009b\r')).toBe(DOTS)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('displayLength counts codepoints, matching the slicing unit', () => {
|
|
212
|
+
expect(displayLength('\u{1F600}'.repeat(20))).toBe(20) // not 40
|
|
213
|
+
expect(displayLength('abcdefghij\n')).toBe(11)
|
|
214
|
+
})
|
|
@@ -169,3 +169,122 @@ test('-o <other file> is guarded too, not just .env', async () => {
|
|
|
169
169
|
expect(stderr).toContain('prod.env')
|
|
170
170
|
expect(readFileSync(join(cwd, 'prod.env'), 'utf8')).toBe(LOCAL)
|
|
171
171
|
})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
// ── The destination argument must actually be honoured. Regression: `bod env pull
|
|
175
|
+
// <app> <path> --force` ignored <path>, printed "Wrote 22 var(s) to .env" and dumped
|
|
176
|
+
// 22 real secrets into the CURRENT directory of an unrelated repo.
|
|
177
|
+
const BODY = 'BOD_AI_URL=https://ai.bod.ee\nBLANK_WALLET_MODE=platform\nSHARED_KEY=server-value\n'
|
|
178
|
+
|
|
179
|
+
test('explicit path positional → writes THERE, 0600, creates nothing in cwd', async () => {
|
|
180
|
+
const dest = join(cwd, 'sub', 'dir', 'feedox.vars')
|
|
181
|
+
const { code, stdout } = await runCli(['env', 'pull', APP_NAME, dest])
|
|
182
|
+
expect(code).toBe(0)
|
|
183
|
+
expect(readFileSync(dest, 'utf8')).toBe(BODY)
|
|
184
|
+
expect(statSync(dest).mode & 0o777).toBe(0o600)
|
|
185
|
+
// Parent dirs created, and the only thing in cwd is the tree we asked for.
|
|
186
|
+
expect(readdirSync(cwd)).toEqual(['sub'])
|
|
187
|
+
expect(existsSync(envPath())).toBe(false)
|
|
188
|
+
// ...and the line names the real path it wrote.
|
|
189
|
+
expect(stdout).toContain(`Wrote 3 var(s) to ${dest}`)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('--force applies to the RESOLVED destination, never to cwd .env', async () => {
|
|
193
|
+
const dest = join(cwd, 'other.vars')
|
|
194
|
+
writeFileSync(dest, LOCAL)
|
|
195
|
+
const { code, stdout } = await runCli(['env', 'pull', APP_NAME, dest, '--force'])
|
|
196
|
+
expect(code).toBe(0)
|
|
197
|
+
expect(readFileSync(dest, 'utf8')).toBe(BODY)
|
|
198
|
+
expect(existsSync(envPath())).toBe(false)
|
|
199
|
+
expect(stdout).toContain(dest)
|
|
200
|
+
// The clobbered file is recoverable, next to the destination — not in cwd by luck.
|
|
201
|
+
const bak = readdirSync(cwd).filter(f => f.startsWith('other.vars.backup-'))
|
|
202
|
+
expect(bak).toHaveLength(1)
|
|
203
|
+
expect(readFileSync(join(cwd, bak[0]), 'utf8')).toBe(LOCAL)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('--merge applies to the RESOLVED destination', async () => {
|
|
207
|
+
const dest = join(cwd, 'other.vars')
|
|
208
|
+
writeFileSync(dest, LOCAL)
|
|
209
|
+
const { code } = await runCli(['env', 'pull', APP_NAME, dest, '--merge'])
|
|
210
|
+
expect(code).toBe(0)
|
|
211
|
+
expect(readFileSync(dest, 'utf8').startsWith(LOCAL)).toBe(true)
|
|
212
|
+
expect(existsSync(envPath())).toBe(false)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('the refusal guard applies to an explicit path destination', async () => {
|
|
216
|
+
const dest = join(cwd, 'other.vars')
|
|
217
|
+
writeFileSync(dest, LOCAL)
|
|
218
|
+
const { code, stderr } = await runCli(['env', 'pull', APP_NAME, dest])
|
|
219
|
+
expect(code).toBe(1)
|
|
220
|
+
expect(readFileSync(dest, 'utf8')).toBe(LOCAL)
|
|
221
|
+
expect(stderr).toContain(dest)
|
|
222
|
+
expect(existsSync(envPath())).toBe(false)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('a LONE path positional is a destination, with the app from bodify.yaml', async () => {
|
|
226
|
+
writeFileSync(join(cwd, 'bodify.yaml'), `name: ${APP_NAME}\n`)
|
|
227
|
+
const dest = join(cwd, 'nested', 'out.vars')
|
|
228
|
+
const { code, stdout } = await runCli(['env', 'pull', dest])
|
|
229
|
+
expect(code).toBe(0)
|
|
230
|
+
expect(stdout).toContain('from bodify.yaml')
|
|
231
|
+
expect(readFileSync(dest, 'utf8')).toBe(BODY)
|
|
232
|
+
expect(existsSync(envPath())).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('a LONE app positional is still an app, not a path', async () => {
|
|
236
|
+
const { code } = await runCli(['env', 'pull', APP_NAME])
|
|
237
|
+
expect(code).toBe(0)
|
|
238
|
+
expect(readFileSync(envPath(), 'utf8')).toBe(BODY)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('no positional at all → app from bodify.yaml, default .env in cwd', async () => {
|
|
242
|
+
writeFileSync(join(cwd, 'bodify.yaml'), `name: ${APP_NAME}\n`)
|
|
243
|
+
const { code } = await runCli(['env', 'pull'])
|
|
244
|
+
expect(code).toBe(0)
|
|
245
|
+
expect(readFileSync(envPath(), 'utf8')).toBe(BODY)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('-o wins over a destination positional', async () => {
|
|
249
|
+
const { code } = await runCli(['env', 'pull', APP_NAME, join(cwd, 'ignored.vars'), '-o', 'chosen.vars'])
|
|
250
|
+
expect(code).toBe(0)
|
|
251
|
+
expect(readFileSync(join(cwd, 'chosen.vars'), 'utf8')).toBe(BODY)
|
|
252
|
+
expect(existsSync(join(cwd, 'ignored.vars'))).toBe(false)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// ── Fixes found while reviewing the destination change itself.
|
|
256
|
+
|
|
257
|
+
test('a lone `name.ext` is an APP, never a file in cwd — an app may be called my.app', async () => {
|
|
258
|
+
writeFileSync(join(cwd, 'bodify.yaml'), `name: ${APP_NAME}\n`)
|
|
259
|
+
const { code, stderr } = await runCli(['env', 'pull', 'my.app'])
|
|
260
|
+
// Loud refusal, not a silent write of the bodify.yaml app's secrets into ./my.app.
|
|
261
|
+
expect(code).toBe(1)
|
|
262
|
+
expect(stderr).toContain('App not found: my.app')
|
|
263
|
+
expect(readdirSync(cwd)).toEqual(['bodify.yaml'])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('an unexpanded ~ destination goes HOME, never to a literal ~ dir in cwd', async () => {
|
|
267
|
+
const { code, stdout } = await runCli(['env', 'pull', APP_NAME, '~/pull-probe/out.vars'])
|
|
268
|
+
expect(code).toBe(0)
|
|
269
|
+
expect(existsSync(join(cwd, '~'))).toBe(false)
|
|
270
|
+
const dest = join(home, 'pull-probe', 'out.vars')
|
|
271
|
+
expect(readFileSync(dest, 'utf8')).toBe(BODY)
|
|
272
|
+
expect(stdout).toContain(dest)
|
|
273
|
+
rmSync(join(home, 'pull-probe'), { recursive: true, force: true })
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('--force onto an existing 0644 file leaves it 0600, not world-readable', async () => {
|
|
277
|
+
const dest = join(cwd, 'wide.vars')
|
|
278
|
+
writeFileSync(dest, 'OLD=1\n', { mode: 0o644 })
|
|
279
|
+
const { code } = await runCli(['env', 'pull', APP_NAME, dest, '--force'])
|
|
280
|
+
expect(code).toBe(0)
|
|
281
|
+
expect(statSync(dest).mode & 0o777).toBe(0o600)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('--merge into an existing 0644 file narrows it to 0600', async () => {
|
|
285
|
+
const dest = join(cwd, 'wide2.vars')
|
|
286
|
+
writeFileSync(dest, 'OLD=1\n', { mode: 0o644 })
|
|
287
|
+
const { code } = await runCli(['env', 'pull', APP_NAME, dest, '--merge'])
|
|
288
|
+
expect(code).toBe(0)
|
|
289
|
+
expect(statSync(dest).mode & 0o777).toBe(0o600)
|
|
290
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// `bod host --no-preview` must actually opt out. citty/mri folds `--no-preview` into
|
|
2
|
+
// `preview: false`, so the literal 'no-preview' arg never saw it and the flag was inert:
|
|
3
|
+
// a single mp4 still got an index.html + poster.jpg. Verified at the CLI surface against
|
|
4
|
+
// a mock publish API — the assertion is the file COUNT in the real manifest.
|
|
5
|
+
import { test, expect, beforeAll, afterAll } from 'bun:test'
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
7
|
+
import { tmpdir } from 'os'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
|
|
10
|
+
const CLI = join(import.meta.dir, '..', 'src', 'cli.ts')
|
|
11
|
+
let server: ReturnType<typeof Bun.serve>
|
|
12
|
+
let home: string
|
|
13
|
+
let media: string
|
|
14
|
+
let manifests: string[][] = []
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
server = Bun.serve({
|
|
18
|
+
port: 0,
|
|
19
|
+
async fetch(req) {
|
|
20
|
+
const url = new URL(req.url)
|
|
21
|
+
if (url.pathname === '/api/v1/publish') {
|
|
22
|
+
const body = await req.json() as { files: Array<{ path: string }> }
|
|
23
|
+
manifests.push(body.files.map(f => f.path))
|
|
24
|
+
return Response.json({
|
|
25
|
+
slug: 'abc', siteUrl: `http://localhost:${server.port}/s/abc`,
|
|
26
|
+
upload: { uploads: [], finalizeUrl: '/api/v1/publish/abc/finalize' },
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
if (url.pathname.endsWith('/finalize')) return Response.json({ ok: true })
|
|
30
|
+
return new Response('not found', { status: 404 })
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
home = mkdtempSync(join(tmpdir(), 'bod-host-home-'))
|
|
34
|
+
mkdirSync(join(home, '.bod'), { recursive: true })
|
|
35
|
+
writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
|
|
36
|
+
defaultInstance: 'test',
|
|
37
|
+
instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
|
|
38
|
+
}))
|
|
39
|
+
media = mkdtempSync(join(tmpdir(), 'bod-host-media-'))
|
|
40
|
+
// Not a decodable video; ffmpeg fails, the page is still synthesized (title-only).
|
|
41
|
+
writeFileSync(join(media, 'clip.mp4'), 'not-a-real-mp4')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
server?.stop(true)
|
|
46
|
+
rmSync(home, { recursive: true, force: true })
|
|
47
|
+
rmSync(media, { recursive: true, force: true })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
async function runCli(args: string[]) {
|
|
51
|
+
const proc = Bun.spawn(['bun', CLI, ...args], {
|
|
52
|
+
cwd: media,
|
|
53
|
+
env: { ...process.env, HOME: home, BOD_INSTANCE: '', FORCE_COLOR: '0' },
|
|
54
|
+
stdout: 'pipe', stderr: 'pipe',
|
|
55
|
+
})
|
|
56
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
57
|
+
new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited,
|
|
58
|
+
])
|
|
59
|
+
return { stdout, stderr, code }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('a single video publishes with the synthesized OG page', async () => {
|
|
63
|
+
manifests = []
|
|
64
|
+
const { code } = await runCli(['host', join(media, 'clip.mp4')])
|
|
65
|
+
expect(code).toBe(0)
|
|
66
|
+
expect(manifests[0]).toContain('index.html')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('--no-preview publishes the media file ALONE', async () => {
|
|
70
|
+
manifests = []
|
|
71
|
+
const { code, stdout } = await runCli(['host', join(media, 'clip.mp4'), '--no-preview'])
|
|
72
|
+
expect(code).toBe(0)
|
|
73
|
+
expect(manifests[0]).toEqual(['clip.mp4'])
|
|
74
|
+
expect(stdout).not.toContain('previews in WhatsApp')
|
|
75
|
+
})
|