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 +1 -1
- package/src/commands/env.ts +36 -3
- package/src/commands/host.ts +20 -1
- package/src/utils/media-preview.ts +158 -0
- package/test/env-pull-guard.test.ts +119 -0
- package/test/host-no-preview.test.ts +75 -0
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'
|
|
@@ -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
|
|
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
|
-
|
|
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}`))
|
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
|
+
}
|
|
@@ -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
|
+
})
|