bod-cli 0.10.6 → 0.10.7

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.7",
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,11 @@
1
1
  import { defineCommand } from 'citty'
2
2
  import chalk from 'chalk'
3
- import { writeFileSync } from 'fs'
3
+ import { writeFileSync, appendFileSync, readFileSync, existsSync } from 'fs'
4
4
  import { loadConfig, getResolvedInstance } from '../config'
5
5
  import { BodClient } from '../client'
6
6
  import { printTable } from '../utils/output'
7
7
  import { resolveAppId, resolveAppName } from '../utils/resolve'
8
+ import { backupFile } from '../utils/safe-write'
8
9
 
9
10
  // --- Subs PLATFORM PROVIDER scope (mirror bodify subs.secrets.ts) ---
10
11
  // Provider credentials (Stripe secret key, Apple shared secret, Play SA) are money
@@ -113,6 +114,24 @@ function quoteDotEnvValue(value: string): string {
113
114
  return value
114
115
  }
115
116
 
117
+ /** Serialize resolved values to dotenv text (trailing newline; '' for no vars). */
118
+ function renderDotEnv(values: Record<string, string>): string {
119
+ const body = Object.entries(values).map(([k, v]) => `${k}=${quoteDotEnvValue(v)}`).join('\n')
120
+ return body ? body + '\n' : ''
121
+ }
122
+
123
+ /** Key-level diff of a pull against what is already in the file. */
124
+ function diffEnv(existing: Record<string, string>, incoming: Record<string, string>) {
125
+ const added: string[] = []
126
+ const changed: string[] = []
127
+ for (const [k, v] of Object.entries(incoming)) {
128
+ if (!(k in existing)) added.push(k)
129
+ else if (existing[k] !== v) changed.push(k)
130
+ }
131
+ const removed = Object.keys(existing).filter(k => !(k in incoming))
132
+ return { added, changed, removed }
133
+ }
134
+
116
135
  const listCmd = defineCommand({
117
136
  meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group/--subs: variables in that scope (masked).' },
118
137
  args: {
@@ -289,13 +308,21 @@ const rmCmd = defineCommand({
289
308
  })
290
309
 
291
310
  const pullCmd = defineCommand({
292
- meta: { name: 'pull', description: 'Write an app\'s resolved env to a dotenv file (default .env, mode 0600).' },
311
+ 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
312
  args: {
294
313
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
295
314
  env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
296
315
  output: { type: 'string', alias: 'o', description: 'Output file (default .env)' },
316
+ stdout: { type: 'boolean', description: 'Print to stdout instead of writing any file' },
317
+ merge: { type: 'boolean', alias: 'm', description: 'Add only the keys missing from the file; never touch existing lines' },
318
+ force: { type: 'boolean', alias: 'F', description: 'Replace the whole file (a timestamped backup is written first)' },
297
319
  },
298
320
  async run({ args }) {
321
+ if (args.merge && args.force) {
322
+ console.error(chalk.red('Pass only one of --merge, --force — they are mutually exclusive.'))
323
+ process.exit(1)
324
+ }
325
+
299
326
  const { url, apiKey } = getResolvedInstance(loadConfig())
300
327
  const client = new BodClient(url, apiKey)
301
328
  const appId = await resolveAppId(client, resolveAppName(args.app))
@@ -303,11 +330,66 @@ const pullCmd = defineCommand({
303
330
  const res = await client.get<ResolvedEnv>(`/apps/${appId}/env${qs}`)
304
331
  const values = res.values ?? {}
305
332
  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}`))
333
+
334
+ const body = renderDotEnv(values)
335
+
336
+ // The always-safe escape hatch: look, don't touch.
337
+ if (args.stdout) {
338
+ process.stdout.write(body)
339
+ return
340
+ }
341
+
342
+ if (!existsSync(out)) {
343
+ writeFileSync(out, body, { mode: 0o600 })
344
+ console.log(chalk.green(`✓ Wrote ${Object.keys(values).length} var(s) to ${out}`))
345
+ return
346
+ }
347
+
348
+ // ── The file already exists. It is hand-maintained, often holds local-only
349
+ // credentials that exist nowhere else, and is NOT recoverable from the
350
+ // server. Never replace it on a bare `bod env pull`.
351
+ const existingText = readFileSync(out, 'utf8')
352
+ const existing = parseDotEnv(existingText)
353
+ const d = diffEnv(existing, values)
354
+
355
+ if (!d.added.length && !d.changed.length) {
356
+ console.log(chalk.dim(`${out} already has every pulled var (${Object.keys(values).length}) — nothing to do.`))
357
+ if (d.removed.length) console.log(chalk.dim(` ${d.removed.length} local-only key(s) not on the server: ${d.removed.join(', ')}`))
358
+ return
359
+ }
360
+
361
+ if (args.merge) {
362
+ const backup = backupFile(out)
363
+ const addition = renderDotEnv(Object.fromEntries(d.added.map(k => [k, values[k]])))
364
+ const sep = existingText.length && !existingText.endsWith('\n') ? '\n' : ''
365
+ appendFileSync(out, `${sep}${addition}`)
366
+ console.log(chalk.green(`✓ Added ${d.added.length} var(s) to ${out}: ${d.added.join(', ')}`))
367
+ if (d.changed.length) console.log(chalk.yellow(` Kept your local value for ${d.changed.length} differing key(s): ${d.changed.join(', ')}`))
368
+ if (backup) console.log(chalk.dim(` Backup: ${backup}`))
369
+ return
370
+ }
371
+
372
+ if (args.force) {
373
+ const backup = backupFile(out)
374
+ writeFileSync(out, body, { mode: 0o600 })
375
+ console.log(chalk.green(`✓ Replaced ${out} with ${Object.keys(values).length} var(s)`))
376
+ if (d.removed.length) console.log(chalk.yellow(` Dropped ${d.removed.length} local-only key(s): ${d.removed.join(', ')}`))
377
+ if (backup) console.log(chalk.dim(` Backup: ${backup}`))
378
+ return
379
+ }
380
+
381
+ // Default: refuse, and show WHAT would change (key names only — never print
382
+ // the secret values of a file we are not allowed to touch).
383
+ console.error(chalk.red(`Refusing to overwrite existing ${out} — it may hold local values that exist nowhere else.`))
384
+ console.error('')
385
+ if (d.added.length) console.error(chalk.green(` + ${d.added.length} would be added: ${d.added.join(', ')}`))
386
+ if (d.changed.length) console.error(chalk.yellow(` ~ ${d.changed.length} would be changed: ${d.changed.join(', ')}`))
387
+ if (d.removed.length) console.error(chalk.red(` - ${d.removed.length} would be removed: ${d.removed.join(', ')}`))
388
+ console.error('')
389
+ console.error(chalk.dim(' bod env pull … --merge add only the missing keys, keep your file (usually what you want)'))
390
+ console.error(chalk.dim(' bod env pull … --force replace the file (a timestamped backup is written first)'))
391
+ console.error(chalk.dim(' bod env pull … --stdout print to stdout, write nothing'))
392
+ process.exit(1)
311
393
  },
312
394
  })
313
395
 
@@ -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,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,171 @@
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
+ })