bod-cli 0.10.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.10.7",
3
+ "version": "0.10.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -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'
@@ -307,10 +309,32 @@ const rmCmd = defineCommand({
307
309
  },
308
310
  })
309
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
+
310
333
  const pullCmd = defineCommand({
311
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.' },
312
335
  args: {
313
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 },
314
338
  env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
315
339
  output: { type: 'string', alias: 'o', description: 'Output file (default .env)' },
316
340
  stdout: { type: 'boolean', description: 'Print to stdout instead of writing any file' },
@@ -325,11 +349,15 @@ const pullCmd = defineCommand({
325
349
 
326
350
  const { url, apiKey } = getResolvedInstance(loadConfig())
327
351
  const client = new BodClient(url, apiKey)
328
- 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))
329
354
  const qs = args.env ? `?environment=${encodeURIComponent(args.env)}` : ''
330
355
  const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
331
356
  const values = res.values ?? {}
332
- const out = args.output ?? '.env'
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'))
333
361
 
334
362
  const body = renderDotEnv(values)
335
363
 
@@ -340,6 +368,7 @@ const pullCmd = defineCommand({
340
368
  }
341
369
 
342
370
  if (!existsSync(out)) {
371
+ mkdirSync(dirname(out), { recursive: true })
343
372
  writeFileSync(out, body, { mode: 0o600 })
344
373
  console.log(chalk.green(`✓ Wrote ${Object.keys(values).length} var(s) to ${out}`))
345
374
  return
@@ -363,6 +392,7 @@ const pullCmd = defineCommand({
363
392
  const addition = renderDotEnv(Object.fromEntries(d.added.map(k => [k, values[k]])))
364
393
  const sep = existingText.length && !existingText.endsWith('\n') ? '\n' : ''
365
394
  appendFileSync(out, `${sep}${addition}`)
395
+ chmodSync(out, 0o600)
366
396
  console.log(chalk.green(`✓ Added ${d.added.length} var(s) to ${out}: ${d.added.join(', ')}`))
367
397
  if (d.changed.length) console.log(chalk.yellow(` Kept your local value for ${d.changed.length} differing key(s): ${d.changed.join(', ')}`))
368
398
  if (backup) console.log(chalk.dim(` Backup: ${backup}`))
@@ -372,6 +402,9 @@ const pullCmd = defineCommand({
372
402
  if (args.force) {
373
403
  const backup = backupFile(out)
374
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)
375
408
  console.log(chalk.green(`✓ Replaced ${out} with ${Object.keys(values).length} var(s)`))
376
409
  if (d.removed.length) console.log(chalk.yellow(` Dropped ${d.removed.length} local-only key(s): ${d.removed.join(', ')}`))
377
410
  if (backup) console.log(chalk.dim(` Backup: ${backup}`))
@@ -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
  })
@@ -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
+ }
@@ -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
+ })