bod-cli 0.10.6 → 0.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,7 +134,7 @@ 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 <app>`
137
+ ### `bod env list|set|unset|pull <app>`
138
138
  Manage environment variables.
139
139
 
140
140
  ```bash
@@ -142,8 +142,27 @@ bod env list my-api
142
142
  bod env set my-api DATABASE_URL=postgres://...
143
143
  bod env set my-api -f .env # bulk set from .env file
144
144
  bod env unset my-api OLD_VAR
145
+ bod env pull my-api # write the resolved env to ./.env (0600)
145
146
  ```
146
147
 
148
+ **`bod env pull` never clobbers an existing file.** A `.env` is hand-maintained and
149
+ usually holds local-only credentials the server has never seen — replacing it can
150
+ destroy the only copy. If the output file already exists, `pull` **refuses**, prints a
151
+ key-level diff (added / changed / removed — key names only, never values) and exits 1:
152
+
153
+ ```bash
154
+ bod env pull my-api --merge # add ONLY the missing keys; existing lines and local
155
+ # values are kept verbatim (usually what you want)
156
+ bod env pull my-api --force # replace the whole file
157
+ bod env pull my-api --stdout # print, write nothing
158
+ ```
159
+
160
+ `--merge` and `--force` both write a timestamped `<file>.backup-YYYYMMDD-HHMMSS`
161
+ (mode 0600) before touching anything. Add `.env.backup-*` to `.gitignore`.
162
+
163
+ The same rule applies to `bod db get|query -o <file>`: it refuses to overwrite an
164
+ existing file unless you pass `--force` (which backs it up first).
165
+
147
166
  ### `bod db get|set|update|push|delete|query <path>`
148
167
  Read and write the per-app BodDB. Requires `database: true` in `bodify.yaml`.
149
168
 
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; scoped by <app>/--global/--group (+ --env); set supports -f .env
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)
37
37
  ├── open.ts # bod open <app>
38
38
  ├── add.ts # bod add <pkg>
39
39
  └── remove.ts # bod remove <pkg>
@@ -47,4 +47,8 @@ src/
47
47
  - First-run redirects to `bod login`
48
48
  - Global `--instance` flag / `BOD_INSTANCE` env var for multi-instance
49
49
  - `resolveAppId` in `utils/resolve.ts` — shared name→ID lookup
50
+ - **Never silently overwrite a file in the user's working tree.** Anything that writes
51
+ into cwd (`env pull`, `db -o`, `init`) must refuse when the target exists unless the
52
+ user opted in explicitly — then back it up first. Helpers: `utils/safe-write.ts`
53
+ (`guardOverwrite`, `backupFile`).
50
54
  - Templates use proper CaaB Resource convention from bodify scaffolding docs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -1,6 +1,7 @@
1
1
  import { defineCommand } from 'citty'
2
2
  import chalk from 'chalk'
3
3
  import { existsSync, readFileSync, writeFileSync } from 'fs'
4
+ import { backupFile, guardOverwrite } from '../utils/safe-write'
4
5
  import { join } from 'path'
5
6
  import { loadConfig, getResolvedInstance } from '../config'
6
7
  import { BodClient } from '../client'
@@ -166,11 +167,15 @@ async function readBody(positional: string | undefined, file: string | undefined
166
167
  }
167
168
  }
168
169
 
169
- function printResult(data: unknown, output: string | undefined) {
170
+ function printResult(data: unknown, output: string | undefined, force = false) {
170
171
  const text = JSON.stringify(data, null, 2)
171
172
  if (output) {
173
+ // Same rule as `bod env pull`: never silently destroy a file in the user's tree.
174
+ guardOverwrite(output, force, 'Pass --force to replace it (a timestamped backup is written first), or choose another -o path.')
175
+ const backup = force ? backupFile(output) : undefined
172
176
  writeFileSync(output, text)
173
177
  console.log(chalk.green(`✓ Wrote ${output}`))
178
+ if (backup) console.log(chalk.dim(` Backup: ${backup}`))
174
179
  } else {
175
180
  console.log(text)
176
181
  }
@@ -232,9 +237,10 @@ const getCmd = defineCommand({
232
237
  limit: { type: 'string', alias: 'n', description: 'Shallow only: max keys to return' },
233
238
  offset: { type: 'string', description: 'Shallow only: skip N keys' },
234
239
  output: { type: 'string', alias: 'o', description: 'Write to file instead of stdout' },
240
+ force: { type: 'boolean', alias: 'F', description: 'Overwrite the -o file if it exists (backs it up first)' },
235
241
  },
236
242
  async run({ args }) {
237
- strictArgs(['path', 'app', 'a', 'deep', 'd', 'limit', 'n', 'offset', 'output', 'o'])
243
+ strictArgs(['path', 'app', 'a', 'deep', 'd', 'limit', 'n', 'offset', 'output', 'o', 'force', 'F'])
238
244
  const target = await resolveDbTarget(args.app, readLocalFlag())
239
245
  printTargetBanner(target)
240
246
  const path = normalizePath(args.path)
@@ -246,7 +252,7 @@ const getCmd = defineCommand({
246
252
  }
247
253
  const sub = `/db${path ? '/' + path : '/'}${qs.length ? '?' + qs.join('&') : ''}`
248
254
  const res = await target.request<DbResponse & { shallow?: boolean }>('GET', sub)
249
- printResult(res.data ?? null, args.output)
255
+ printResult(res.data ?? null, args.output, !!args.force)
250
256
  if (res.shallow && !args.output) {
251
257
  console.error(chalk.dim(`(shallow — pass -d for deep read; target: ${target.label})`))
252
258
  }
@@ -369,9 +375,10 @@ const queryCmd = defineCommand({
369
375
  offset: { type: 'string', description: 'Skip N results' },
370
376
  deep: { type: 'boolean', alias: 'd', description: 'Return full matched documents. Default: shallow (keys only).', default: false },
371
377
  output: { type: 'string', alias: 'o', description: 'Write to file instead of stdout' },
378
+ force: { type: 'boolean', alias: 'F', description: 'Overwrite the -o file if it exists (backs it up first)' },
372
379
  },
373
380
  async run({ args }) {
374
- strictArgs(['path', 'app', 'a', 'filter', 'f', 'where', 'w', 'order', 'limit', 'n', 'offset', 'deep', 'd', 'output', 'o'])
381
+ strictArgs(['path', 'app', 'a', 'filter', 'f', 'where', 'w', 'order', 'limit', 'n', 'offset', 'deep', 'd', 'output', 'o', 'force', 'F'])
375
382
  // Merge --filter (shorthand) and --where (canonical). Both are repeatable.
376
383
  const filters: Array<{ field: string; op: string; value: unknown }> = []
377
384
  const filterList = args.filter ? (Array.isArray(args.filter) ? args.filter : [args.filter]) : []
@@ -396,7 +403,7 @@ const queryCmd = defineCommand({
396
403
  if (!args.deep && Array.isArray(rows)) {
397
404
  rows = rows.map(r => ({ _path: r._path, _key: r._key }))
398
405
  }
399
- printResult(rows ?? null, args.output)
406
+ printResult(rows ?? null, args.output, !!args.force)
400
407
  if (!args.deep && !args.output && Array.isArray(rows)) {
401
408
  console.error(chalk.dim(`(${rows.length} match${rows.length === 1 ? '' : 'es'}; shallow — pass -d for full docs)`))
402
409
  }
@@ -13,7 +13,7 @@ async function detectBranch(explicit?: string): Promise<string> {
13
13
  return branch || 'main'
14
14
  }
15
15
 
16
- async function uploadDeploy(client: BodClient, appId: string, branch: string) {
16
+ export async function uploadDeploy(client: BodClient, appId: string, branch: string) {
17
17
  const allExcludes = resolveExcludes(readExcludesFromYaml())
18
18
  const excludeFlags = allExcludes.map(e => `--exclude=${e}`)
19
19
 
@@ -55,54 +55,58 @@ async function uploadDeploy(client: BodClient, appId: string, branch: string) {
55
55
  }
56
56
 
57
57
  // Rewrite root + workspace package.jsons (file:../X → file:./X for included siblings)
58
- rewritePkg(join(process.cwd(), 'package.json'))
58
+ // try/finally: a throw anywhere below would otherwise leave the user's
59
+ // package.json files rewritten on disk. Never exit un-restored.
59
60
  try {
60
- const rootPkg = JSON.parse(fs.readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
61
- for (const pattern of rootPkg.workspaces ?? []) {
62
- const base = join(process.cwd(), pattern.replace(/\/?\*$/, ''))
63
- if (!fs.existsSync(base)) continue
64
- for (const sub of fs.readdirSync(base)) {
65
- rewritePkg(join(base, sub, 'package.json'))
61
+ rewritePkg(join(process.cwd(), 'package.json'))
62
+ try {
63
+ const rootPkg = JSON.parse(fs.readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
64
+ for (const pattern of rootPkg.workspaces ?? []) {
65
+ const base = join(process.cwd(), pattern.replace(/\/?\*$/, ''))
66
+ if (!fs.existsSync(base)) continue
67
+ for (const sub of fs.readdirSync(base)) {
68
+ rewritePkg(join(base, sub, 'package.json'))
69
+ }
66
70
  }
67
- }
68
- } catch {}
71
+ } catch {}
69
72
 
70
- // Rewrite sibling package.jsons: file:.. deps that aren't included become "*" (npm fallback)
71
- const includedSiblings = new Set([...siblingMap.values()])
72
- for (const sp of siblingPaths) {
73
- const abs = resolve(sp)
74
- const sibPkgPath = join(abs, 'package.json')
75
- if (!fs.existsSync(sibPkgPath)) continue
76
- const original = fs.readFileSync(sibPkgPath, 'utf8')
77
- const sibPkg = JSON.parse(original)
78
- let changed = false
79
- for (const field of ['dependencies', 'devDependencies']) {
80
- if (!sibPkg[field]) continue
81
- for (const [name, ver] of Object.entries(sibPkg[field]) as [string, string][]) {
82
- if (!ver.startsWith('file:') || !ver.includes('..')) continue
83
- const depName = basename(resolve(abs, ver.slice(5)))
84
- if (!includedSiblings.has(depName)) {
85
- sibPkg[field][name] = '*'
86
- changed = true
73
+ // Rewrite sibling package.jsons: file:.. deps that aren't included become "*" (npm fallback)
74
+ const includedSiblings = new Set([...siblingMap.values()])
75
+ for (const sp of siblingPaths) {
76
+ const abs = resolve(sp)
77
+ const sibPkgPath = join(abs, 'package.json')
78
+ if (!fs.existsSync(sibPkgPath)) continue
79
+ const original = fs.readFileSync(sibPkgPath, 'utf8')
80
+ const sibPkg = JSON.parse(original)
81
+ let changed = false
82
+ for (const field of ['dependencies', 'devDependencies']) {
83
+ if (!sibPkg[field]) continue
84
+ for (const [name, ver] of Object.entries(sibPkg[field]) as [string, string][]) {
85
+ if (!ver.startsWith('file:') || !ver.includes('..')) continue
86
+ const depName = basename(resolve(abs, ver.slice(5)))
87
+ if (!includedSiblings.has(depName)) {
88
+ sibPkg[field][name] = '*'
89
+ changed = true
90
+ }
87
91
  }
88
92
  }
93
+ if (changed) {
94
+ rewritten.push({ path: sibPkgPath, original })
95
+ fs.writeFileSync(sibPkgPath, JSON.stringify(sibPkg, null, 2) + '\n')
96
+ }
89
97
  }
90
- if (changed) {
91
- rewritten.push({ path: sibPkgPath, original })
92
- fs.writeFileSync(sibPkgPath, JSON.stringify(sibPkg, null, 2) + '\n')
93
- }
94
- }
95
98
 
96
- const tar = Bun.spawnSync(['tar', 'czh', ...excludeFlags, '-f', tmpFile, '.', ...siblingArgs], {
97
- stderr: 'pipe',
98
- env,
99
- })
100
-
101
- // Restore original package.json files
102
- for (const { path, original } of rewritten) fs.writeFileSync(path, original)
103
- if (tar.exitCode !== 0) {
104
- const errText = tar.stderr.toString()
105
- throw new Error(`tar failed: ${errText}`)
99
+ const tar = Bun.spawnSync(['tar', 'czh', ...excludeFlags, '-f', tmpFile, '.', ...siblingArgs], {
100
+ stderr: 'pipe',
101
+ env,
102
+ })
103
+ if (tar.exitCode !== 0) {
104
+ const errText = tar.stderr.toString()
105
+ throw new Error(`tar failed: ${errText}`)
106
+ }
107
+ } finally {
108
+ // Restore original package.json files
109
+ for (const { path, original } of rewritten) fs.writeFileSync(path, original)
106
110
  }
107
111
 
108
112
  const file = Bun.file(tmpFile)
@@ -1,10 +1,13 @@
1
1
  import { defineCommand } from 'citty'
2
2
  import chalk from 'chalk'
3
- import { writeFileSync } 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'
7
9
  import { resolveAppId, resolveAppName } from '../utils/resolve'
10
+ import { backupFile } from '../utils/safe-write'
8
11
 
9
12
  // --- Subs PLATFORM PROVIDER scope (mirror bodify subs.secrets.ts) ---
10
13
  // Provider credentials (Stripe secret key, Apple shared secret, Play SA) are money
@@ -113,6 +116,24 @@ function quoteDotEnvValue(value: string): string {
113
116
  return value
114
117
  }
115
118
 
119
+ /** Serialize resolved values to dotenv text (trailing newline; '' for no vars). */
120
+ function renderDotEnv(values: Record<string, string>): string {
121
+ const body = Object.entries(values).map(([k, v]) => `${k}=${quoteDotEnvValue(v)}`).join('\n')
122
+ return body ? body + '\n' : ''
123
+ }
124
+
125
+ /** Key-level diff of a pull against what is already in the file. */
126
+ function diffEnv(existing: Record<string, string>, incoming: Record<string, string>) {
127
+ const added: string[] = []
128
+ const changed: string[] = []
129
+ for (const [k, v] of Object.entries(incoming)) {
130
+ if (!(k in existing)) added.push(k)
131
+ else if (existing[k] !== v) changed.push(k)
132
+ }
133
+ const removed = Object.keys(existing).filter(k => !(k in incoming))
134
+ return { added, changed, removed }
135
+ }
136
+
116
137
  const listCmd = defineCommand({
117
138
  meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group/--subs: variables in that scope (masked).' },
118
139
  args: {
@@ -288,26 +309,120 @@ const rmCmd = defineCommand({
288
309
  },
289
310
  })
290
311
 
312
+ /** `env pull` takes <app> and an optional destination path, either of which may be
313
+ * omitted (app falls back to bodify.yaml). With ONE positional the two are
314
+ * ambiguous, so a token that is EXPLICITLY a path ('/', '~', or a leading '.') is
315
+ * read as the destination. A bare `name.ext` is NOT enough: an app may legitimately
316
+ * be called `my.app`, and guessing wrong writes another app's secrets into cwd. */
317
+ export function splitPullPositionals(a?: string, b?: string): { app?: string; dest?: string } {
318
+ if (b !== undefined) return { app: a, dest: b }
319
+ if (a !== undefined && looksLikePath(a)) return { app: undefined, dest: a }
320
+ return { app: a, dest: undefined }
321
+ }
322
+
323
+ /** A '~' reaching us unexpanded (quoted, or an argv built by a script) must not become
324
+ * a literal './~/...' directory holding real secrets. */
325
+ function expandTilde(p: string): string {
326
+ return p === '~' || p.startsWith('~/') ? homedir() + p.slice(1) : p
327
+ }
328
+
329
+ function looksLikePath(s: string): boolean {
330
+ return s.includes('/') || s.startsWith('~') || s.startsWith('.')
331
+ }
332
+
291
333
  const pullCmd = defineCommand({
292
- meta: { name: 'pull', description: 'Write an app\'s resolved env to a dotenv file (default .env, mode 0600).' },
334
+ 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.' },
293
335
  args: {
294
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 },
295
338
  env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
296
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)' },
297
343
  },
298
344
  async run({ args }) {
345
+ if (args.merge && args.force) {
346
+ console.error(chalk.red('Pass only one of --merge, --force — they are mutually exclusive.'))
347
+ process.exit(1)
348
+ }
349
+
299
350
  const { url, apiKey } = getResolvedInstance(loadConfig())
300
351
  const client = new BodClient(url, apiKey)
301
- const appId = await resolveAppId(client, resolveAppName(args.app))
352
+ const { app, dest } = splitPullPositionals(args.app, args.dest)
353
+ const appId = await resolveAppId(client, resolveAppName(app))
302
354
  const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
303
355
  const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
304
356
  const values = res.values ?? {}
305
- const out = args.output ?? '.env'
306
- const body = Object.entries(values)
307
- .map(([k, v]) => `${k}=${quoteDotEnvValue(v)}`)
308
- .join('\n')
309
- writeFileSync(out, body ? body + '\n' : '', { mode: 0o600 })
310
- console.log(chalk.green(`✓ Wrote ${Object.keys(values).length} var(s) to ${out}`))
357
+ // Every branch below (fresh write / refuse / merge / force / backup) acts on THIS
358
+ // resolved path. A destination that was silently dropped is how a real pull once
359
+ // dumped 22 secrets into an unrelated repo's cwd.
360
+ const out = resolvePath(process.cwd(), expandTilde(args.output ?? dest ?? '.env'))
361
+
362
+ const body = renderDotEnv(values)
363
+
364
+ // The always-safe escape hatch: look, don't touch.
365
+ if (args.stdout) {
366
+ process.stdout.write(body)
367
+ return
368
+ }
369
+
370
+ if (!existsSync(out)) {
371
+ mkdirSync(dirname(out), { recursive: true })
372
+ writeFileSync(out, body, { mode: 0o600 })
373
+ console.log(chalk.green(`✓ Wrote ${Object.keys(values).length} var(s) to ${out}`))
374
+ return
375
+ }
376
+
377
+ // ── The file already exists. It is hand-maintained, often holds local-only
378
+ // credentials that exist nowhere else, and is NOT recoverable from the
379
+ // server. Never replace it on a bare `bod env pull`.
380
+ const existingText = readFileSync(out, 'utf8')
381
+ const existing = parseDotEnv(existingText)
382
+ const d = diffEnv(existing, values)
383
+
384
+ if (!d.added.length && !d.changed.length) {
385
+ console.log(chalk.dim(`${out} already has every pulled var (${Object.keys(values).length}) — nothing to do.`))
386
+ if (d.removed.length) console.log(chalk.dim(` ${d.removed.length} local-only key(s) not on the server: ${d.removed.join(', ')}`))
387
+ return
388
+ }
389
+
390
+ if (args.merge) {
391
+ const backup = backupFile(out)
392
+ const addition = renderDotEnv(Object.fromEntries(d.added.map(k => [k, values[k]])))
393
+ const sep = existingText.length && !existingText.endsWith('\n') ? '\n' : ''
394
+ appendFileSync(out, `${sep}${addition}`)
395
+ chmodSync(out, 0o600)
396
+ console.log(chalk.green(`✓ Added ${d.added.length} var(s) to ${out}: ${d.added.join(', ')}`))
397
+ if (d.changed.length) console.log(chalk.yellow(` Kept your local value for ${d.changed.length} differing key(s): ${d.changed.join(', ')}`))
398
+ if (backup) console.log(chalk.dim(` Backup: ${backup}`))
399
+ return
400
+ }
401
+
402
+ if (args.force) {
403
+ const backup = backupFile(out)
404
+ writeFileSync(out, body, { mode: 0o600 })
405
+ // `mode` only applies when the file is CREATED — an existing 0644 dotenv would
406
+ // otherwise stay world-readable after being filled with the server's secrets.
407
+ chmodSync(out, 0o600)
408
+ console.log(chalk.green(`✓ Replaced ${out} with ${Object.keys(values).length} var(s)`))
409
+ if (d.removed.length) console.log(chalk.yellow(` Dropped ${d.removed.length} local-only key(s): ${d.removed.join(', ')}`))
410
+ if (backup) console.log(chalk.dim(` Backup: ${backup}`))
411
+ return
412
+ }
413
+
414
+ // Default: refuse, and show WHAT would change (key names only — never print
415
+ // the secret values of a file we are not allowed to touch).
416
+ console.error(chalk.red(`Refusing to overwrite existing ${out} — it may hold local values that exist nowhere else.`))
417
+ console.error('')
418
+ if (d.added.length) console.error(chalk.green(` + ${d.added.length} would be added: ${d.added.join(', ')}`))
419
+ if (d.changed.length) console.error(chalk.yellow(` ~ ${d.changed.length} would be changed: ${d.changed.join(', ')}`))
420
+ if (d.removed.length) console.error(chalk.red(` - ${d.removed.length} would be removed: ${d.removed.join(', ')}`))
421
+ console.error('')
422
+ console.error(chalk.dim(' bod env pull … --merge add only the missing keys, keep your file (usually what you want)'))
423
+ console.error(chalk.dim(' bod env pull … --force replace the file (a timestamped backup is written first)'))
424
+ console.error(chalk.dim(' bod env pull … --stdout print to stdout, write nothing'))
425
+ process.exit(1)
311
426
  },
312
427
  })
313
428
 
@@ -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
- console.log(chalk.green(`✓ Live: ${site.siteUrl}`))
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
  })
@@ -109,6 +109,7 @@ function generateGitignore(): string {
109
109
  dist/
110
110
  .env
111
111
  .env.local
112
+ .env.backup-*
112
113
  *.log
113
114
  `
114
115
  }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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,45 @@
1
+ import { existsSync, copyFileSync, chmodSync } from 'fs'
2
+ import chalk from 'chalk'
3
+
4
+ /**
5
+ * Timestamp suffix for backup files. Local time, filename-safe, sorts lexically.
6
+ * e.g. `20260827-141503`
7
+ */
8
+ function stamp(now = new Date()): string {
9
+ const p = (n: number) => String(n).padStart(2, '0')
10
+ return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
11
+ }
12
+
13
+ /**
14
+ * Copy `path` aside before it is overwritten. Returns the backup path, or
15
+ * undefined when there was nothing to back up.
16
+ *
17
+ * Backups inherit 0600 — a dotenv backup holds the same secrets as the original,
18
+ * so it must not be world-readable just because it has a different name.
19
+ */
20
+ export function backupFile(path: string, now = new Date()): string | undefined {
21
+ if (!existsSync(path)) return undefined
22
+ let dest = `${path}.backup-${stamp(now)}`
23
+ // Two pulls inside the same second must not silently eat the first backup.
24
+ let n = 1
25
+ while (existsSync(dest)) dest = `${path}.backup-${stamp(now)}.${n++}`
26
+ copyFileSync(path, dest)
27
+ chmodSync(dest, 0o600)
28
+ return dest
29
+ }
30
+
31
+ /**
32
+ * Refuse to clobber an existing file unless the caller explicitly opted in.
33
+ *
34
+ * The default for ANY command that writes into the user's working tree is
35
+ * non-destructive: a file already on disk is assumed to be hand-maintained and
36
+ * possibly unrecoverable (this guard exists because `bod env pull` silently
37
+ * destroyed a developer's hand-written `.env`). `hint` must tell the user what
38
+ * to type next.
39
+ */
40
+ export function guardOverwrite(path: string, force: boolean, hint: string): void {
41
+ if (!existsSync(path) || force) return
42
+ console.error(chalk.red(`Refusing to overwrite existing file: ${path}`))
43
+ console.error(chalk.dim(hint))
44
+ process.exit(1)
45
+ }
@@ -0,0 +1,77 @@
1
+ // Sibling consistency with `bod env pull`: any command that writes into the user's
2
+ // working tree must refuse to clobber an existing file. Guarding one command while
3
+ // its sibling still overwrites is not a fix. Real CLI surface, mock agent.
4
+ import { test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } 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
+
12
+ let server: ReturnType<typeof Bun.serve>
13
+ let home: string
14
+ let cwd: string
15
+
16
+ beforeAll(() => {
17
+ server = Bun.serve({
18
+ port: 0,
19
+ fetch(req) {
20
+ const url = new URL(req.url)
21
+ if (url.pathname === '/api/apps') return Response.json([{ id: APP_ID, name: 'bodify' }])
22
+ if (url.pathname.startsWith(`/api/apps/${APP_ID}/db`)) return Response.json({ ok: true, data: { a: 1 } })
23
+ return new Response('not found', { status: 404 })
24
+ },
25
+ })
26
+ home = mkdtempSync(join(tmpdir(), 'bod-db-home-'))
27
+ mkdirSync(join(home, '.bod'), { recursive: true })
28
+ writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
29
+ defaultInstance: 'test',
30
+ instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
31
+ }))
32
+ })
33
+
34
+ afterAll(() => { server?.stop(true); rmSync(home, { recursive: true, force: true }) })
35
+ beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'bod-db-cwd-')) })
36
+
37
+ async function runCli(args: string[]) {
38
+ const proc = Bun.spawn(['bun', CLI, ...args], {
39
+ cwd, env: { ...process.env, HOME: home, BOD_INSTANCE: '', FORCE_COLOR: '0' },
40
+ stdout: 'pipe', stderr: 'pipe',
41
+ })
42
+ const [stdout, stderr, code] = await Promise.all([
43
+ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited,
44
+ ])
45
+ return { stdout, stderr, code }
46
+ }
47
+
48
+ const OUT = 'dump.json'
49
+ const MINE = '{"hand":"written"}'
50
+
51
+ test('db get -o <existing file> → refuses, file untouched', async () => {
52
+ writeFileSync(join(cwd, OUT), MINE)
53
+ const { code, stderr } = await runCli(['db', 'get', '/', '-o', OUT])
54
+ expect(code).toBe(1)
55
+ expect(stderr).toContain('Refusing to overwrite')
56
+ expect(stderr).toContain(OUT)
57
+ expect(stderr).toContain('--force')
58
+ expect(readFileSync(join(cwd, OUT), 'utf8')).toBe(MINE)
59
+ expect(readdirSync(cwd)).toEqual([OUT])
60
+ })
61
+
62
+ test('db get -o --force → writes, and the original survives in a backup', async () => {
63
+ writeFileSync(join(cwd, OUT), MINE)
64
+ const { code } = await runCli(['db', 'get', '/', '-o', OUT, '--force'])
65
+ expect(code).toBe(0)
66
+ expect(readFileSync(join(cwd, OUT), 'utf8')).toContain('"a"')
67
+ const backups = readdirSync(cwd).filter(f => f.startsWith(`${OUT}.backup-`))
68
+ expect(backups).toHaveLength(1)
69
+ expect(readFileSync(join(cwd, backups[0]), 'utf8')).toBe(MINE)
70
+ })
71
+
72
+ test('db get -o <new file> → writes normally', async () => {
73
+ const { code } = await runCli(['db', 'get', '/', '-o', OUT])
74
+ expect(code).toBe(0)
75
+ expect(readFileSync(join(cwd, OUT), 'utf8')).toContain('"a"')
76
+ expect(readdirSync(cwd)).toEqual([OUT])
77
+ })
@@ -0,0 +1,41 @@
1
+ // `bod deploy --upload` temporarily rewrites the user's package.json files (file:../X →
2
+ // file:./X) and restores them afterwards. If anything throws in between, the restore MUST
3
+ // still run — otherwise a failed deploy leaves the developer's package.json mangled.
4
+ import { test, expect, afterEach } from 'bun:test'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+ import { uploadDeploy } from '../src/commands/deploy'
9
+
10
+ const origCwd = process.cwd()
11
+ let root: string
12
+ afterEach(() => { process.chdir(origCwd); if (root) rmSync(root, { recursive: true, force: true }) })
13
+
14
+ /** app/ depends on ../sib; sib/package.json is INVALID JSON → JSON.parse throws in the
15
+ * sibling-rewrite loop, AFTER app/package.json has already been rewritten on disk. */
16
+ function fixture(sibPkgBody: string) {
17
+ root = realpathSync(mkdtempSync(join(tmpdir(), 'bod-deploy-')))
18
+ mkdirSync(join(root, 'app')); mkdirSync(join(root, 'sib'))
19
+ const appPkg = JSON.stringify({ name: 'app', dependencies: { sib: 'file:../sib' } }, null, 2)
20
+ writeFileSync(join(root, 'app', 'package.json'), appPkg)
21
+ writeFileSync(join(root, 'sib', 'package.json'), sibPkgBody)
22
+ process.chdir(join(root, 'app'))
23
+ return appPkg
24
+ }
25
+
26
+ const client = { request: async () => { throw new Error('should not reach upload') } } as any
27
+
28
+ test('a throw during the rewrite leaves package.json RESTORED, not mangled', async () => {
29
+ const appPkg = fixture('{ this is not json')
30
+ await expect(uploadDeploy(client, 'id', 'main')).rejects.toThrow()
31
+ // The rewrite definitely happened (file:../sib → file:./sib) — and was undone.
32
+ expect(readFileSync(join(root, 'app', 'package.json'), 'utf8')).toBe(appPkg)
33
+ expect(readFileSync(join(root, 'app', 'package.json'), 'utf8')).toContain('file:../sib')
34
+ })
35
+
36
+ test('the happy path also restores package.json after packing', async () => {
37
+ const appPkg = fixture(JSON.stringify({ name: 'sib' }, null, 2))
38
+ // Packing succeeds; the upload call is what fails, well after the finally block.
39
+ await expect(uploadDeploy(client, 'id', 'main')).rejects.toThrow()
40
+ expect(readFileSync(join(root, 'app', 'package.json'), 'utf8')).toBe(appPkg)
41
+ })
@@ -0,0 +1,290 @@
1
+ // `bod env pull` must NEVER silently destroy a hand-maintained .env — a dotenv holds
2
+ // local credentials that exist nowhere else on the server. Verified at the REAL CLI
3
+ // surface (full citty parse, real fs writes in a scratch cwd) against a mock agent.
4
+ import { test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'
5
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, statSync } 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
+
13
+ // What the server would hand back for `bod env pull blank`.
14
+ const REMOTE: Record<string, string> = {
15
+ BOD_AI_URL: 'https://ai.bod.ee',
16
+ BLANK_WALLET_MODE: 'platform',
17
+ SHARED_KEY: 'server-value',
18
+ }
19
+
20
+ let server: ReturnType<typeof Bun.serve>
21
+ let home: string
22
+ let cwd: string
23
+
24
+ beforeAll(() => {
25
+ server = Bun.serve({
26
+ port: 0,
27
+ fetch(req) {
28
+ const url = new URL(req.url)
29
+ if (url.pathname === '/api/apps') return Response.json([{ id: APP_ID, name: APP_NAME }])
30
+ if (url.pathname === `/api/apps/${APP_ID}/env`) return Response.json({ values: REMOTE })
31
+ return new Response('not found', { status: 404 })
32
+ },
33
+ })
34
+ home = mkdtempSync(join(tmpdir(), 'bod-pull-home-'))
35
+ mkdirSync(join(home, '.bod'), { recursive: true })
36
+ writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
37
+ defaultInstance: 'test',
38
+ instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
39
+ }))
40
+ })
41
+
42
+ afterAll(() => {
43
+ server?.stop(true)
44
+ rmSync(home, { recursive: true, force: true })
45
+ })
46
+
47
+ beforeEach(() => {
48
+ cwd = mkdtempSync(join(tmpdir(), 'bod-pull-cwd-'))
49
+ })
50
+
51
+ async function runCli(args: string[]) {
52
+ const proc = Bun.spawn(['bun', CLI, ...args], {
53
+ cwd,
54
+ env: { ...process.env, HOME: home, BOD_INSTANCE: '', FORCE_COLOR: '0' },
55
+ stdout: 'pipe', stderr: 'pipe',
56
+ })
57
+ const [stdout, stderr, code] = await Promise.all([
58
+ new Response(proc.stdout).text(),
59
+ new Response(proc.stderr).text(),
60
+ proc.exited,
61
+ ])
62
+ return { stdout, stderr, code }
63
+ }
64
+
65
+ const envPath = () => join(cwd, '.env')
66
+ const backups = () => readdirSync(cwd).filter(f => f.startsWith('.env.backup-'))
67
+
68
+ /** A hand-maintained file: a comment, a local-only key, and a key the server also has
69
+ * but with a DIFFERENT (local) value. Exactly the shape that got destroyed. */
70
+ const LOCAL = '# my notes\nLOCAL_ONLY_SECRET=irreplaceable\nSHARED_KEY=my-local-value\n'
71
+
72
+ test('no existing file → writes it, 0600', async () => {
73
+ const { code, stdout } = await runCli(['env', 'pull', APP_NAME])
74
+ expect(code).toBe(0)
75
+ expect(stdout).toContain('Wrote 3 var(s)')
76
+ expect(readFileSync(envPath(), 'utf8')).toBe('BOD_AI_URL=https://ai.bod.ee\nBLANK_WALLET_MODE=platform\nSHARED_KEY=server-value\n')
77
+ expect(statSync(envPath()).mode & 0o777).toBe(0o600)
78
+ })
79
+
80
+ test('existing file → REFUSES, leaves the file byte-identical, names the next step', async () => {
81
+ writeFileSync(envPath(), LOCAL)
82
+ const { code, stderr } = await runCli(['env', 'pull', APP_NAME])
83
+ expect(code).toBe(1)
84
+ // The whole point: the user's bytes are untouched.
85
+ expect(readFileSync(envPath(), 'utf8')).toBe(LOCAL)
86
+ expect(backups()).toEqual([])
87
+ expect(stderr).toContain('Refusing to overwrite')
88
+ expect(stderr).toContain('.env')
89
+ // It must say WHAT would change, by key.
90
+ expect(stderr).toContain('BOD_AI_URL') // added
91
+ expect(stderr).toContain('SHARED_KEY') // changed
92
+ expect(stderr).toContain('LOCAL_ONLY_SECRET') // would be removed
93
+ // ...and never leak the local secret's value while refusing.
94
+ expect(stderr).not.toContain('irreplaceable')
95
+ expect(stderr).toContain('--merge')
96
+ expect(stderr).toContain('--force')
97
+ })
98
+
99
+ test('--merge → appends only missing keys, keeps local lines and local values, backs up', async () => {
100
+ writeFileSync(envPath(), LOCAL)
101
+ const { code, stdout } = await runCli(['env', 'pull', APP_NAME, '--merge'])
102
+ expect(code).toBe(0)
103
+ const after = readFileSync(envPath(), 'utf8')
104
+ // Original content preserved verbatim as a prefix — comment, local-only key, local value.
105
+ expect(after.startsWith(LOCAL)).toBe(true)
106
+ expect(after).toContain('BOD_AI_URL=https://ai.bod.ee')
107
+ expect(after).toContain('BLANK_WALLET_MODE=platform')
108
+ // The server's value for a key I already have must NOT be applied.
109
+ expect(after).not.toContain('SHARED_KEY=server-value')
110
+ expect(stdout).toContain('Added 2 var(s)')
111
+ expect(stdout).toContain('Kept your local value')
112
+ // A backup of the pre-merge file exists and holds the original.
113
+ expect(backups()).toHaveLength(1)
114
+ expect(readFileSync(join(cwd, backups()[0]), 'utf8')).toBe(LOCAL)
115
+ })
116
+
117
+ test('--merge on a file with no trailing newline does not glue keys together', async () => {
118
+ writeFileSync(envPath(), 'LOCAL_ONLY_SECRET=x')
119
+ const { code } = await runCli(['env', 'pull', APP_NAME, '--merge'])
120
+ expect(code).toBe(0)
121
+ const after = readFileSync(envPath(), 'utf8')
122
+ expect(after).toContain('LOCAL_ONLY_SECRET=x\nBOD_AI_URL=')
123
+ })
124
+
125
+ test('--force → replaces, but the original survives in a backup', async () => {
126
+ writeFileSync(envPath(), LOCAL)
127
+ const { code, stdout } = await runCli(['env', 'pull', APP_NAME, '--force'])
128
+ expect(code).toBe(0)
129
+ expect(readFileSync(envPath(), 'utf8')).toBe('BOD_AI_URL=https://ai.bod.ee\nBLANK_WALLET_MODE=platform\nSHARED_KEY=server-value\n')
130
+ expect(stdout).toContain('Dropped 1 local-only key(s): LOCAL_ONLY_SECRET')
131
+ expect(backups()).toHaveLength(1)
132
+ expect(readFileSync(join(cwd, backups()[0]), 'utf8')).toBe(LOCAL)
133
+ // The backup carries the same secrets — it must not be world-readable.
134
+ expect(statSync(join(cwd, backups()[0])).mode & 0o777).toBe(0o600)
135
+ })
136
+
137
+ test('--stdout → prints to stdout and writes nothing', async () => {
138
+ writeFileSync(envPath(), LOCAL)
139
+ const { code, stdout, stderr } = await runCli(['env', 'pull', APP_NAME, '--stdout'])
140
+ expect(stderr).toBe('')
141
+ expect(code).toBe(0)
142
+ expect(stdout).toContain('BOD_AI_URL=https://ai.bod.ee')
143
+ expect(readFileSync(envPath(), 'utf8')).toBe(LOCAL)
144
+ expect(readdirSync(cwd)).toEqual(['.env'])
145
+ })
146
+
147
+ test('file already up to date → no write, no backup, exit 0', async () => {
148
+ const identical = 'BOD_AI_URL=https://ai.bod.ee\nBLANK_WALLET_MODE=platform\nSHARED_KEY=server-value\n'
149
+ writeFileSync(envPath(), identical)
150
+ const { code, stdout } = await runCli(['env', 'pull', APP_NAME])
151
+ expect(code).toBe(0)
152
+ expect(stdout).toContain('nothing to do')
153
+ expect(readFileSync(envPath(), 'utf8')).toBe(identical)
154
+ expect(backups()).toEqual([])
155
+ })
156
+
157
+ test('--merge --force → rejected as mutually exclusive, file untouched', async () => {
158
+ writeFileSync(envPath(), LOCAL)
159
+ const { code, stderr } = await runCli(['env', 'pull', APP_NAME, '--merge', '--force'])
160
+ expect(code).toBe(1)
161
+ expect(stderr).toContain('mutually exclusive')
162
+ expect(readFileSync(envPath(), 'utf8')).toBe(LOCAL)
163
+ })
164
+
165
+ test('-o <other file> is guarded too, not just .env', async () => {
166
+ writeFileSync(join(cwd, 'prod.env'), LOCAL)
167
+ const { code, stderr } = await runCli(['env', 'pull', APP_NAME, '-o', 'prod.env'])
168
+ expect(code).toBe(1)
169
+ expect(stderr).toContain('prod.env')
170
+ expect(readFileSync(join(cwd, 'prod.env'), 'utf8')).toBe(LOCAL)
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
+ })