bod-cli 0.10.5 → 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.
- package/.cursor/skills/using-bod-cli/SKILL.md +20 -1
- package/.github/workflows/publish.yml +14 -1
- package/CLAUDE.md +5 -1
- package/package.json +1 -1
- package/src/commands/db.ts +12 -5
- package/src/commands/deploy.ts +46 -42
- package/src/commands/env.ts +89 -7
- package/src/commands/init/init.ts +1 -0
- package/src/utils/safe-write.ts +45 -0
- package/test/db-output-guard.test.ts +77 -0
- package/test/deploy-restore.test.ts +41 -0
- package/test/env-pull-guard.test.ts +171 -0
|
@@ -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
|
|
|
@@ -39,10 +39,23 @@ jobs:
|
|
|
39
39
|
git add package.json
|
|
40
40
|
git commit -m "chore: release v${VERSION} [skip ci]"
|
|
41
41
|
|
|
42
|
+
- name: Pull NPM_TOKEN from bodify secrets store
|
|
43
|
+
# The npm token is NOT a per-repo secret — a repo-scoped NPM_TOKEN goes stale silently and every
|
|
44
|
+
# publish then dies on a 404. Pull the canonical token from bodify at publish time, so rotating it
|
|
45
|
+
# in ONE place fixes every repo. Only GitHub secret needed: BODIFY_API_KEY.
|
|
46
|
+
env:
|
|
47
|
+
BODIFY_API_KEY: ${{ secrets.BODIFY_API_KEY }}
|
|
48
|
+
run: |
|
|
49
|
+
TOKEN=$(curl -fsS -H "Authorization: Bearer $BODIFY_API_KEY" \
|
|
50
|
+
"https://bodify.bod.ee/api/apps/9ddba0fb-03bc-4490-8577-5e094b5b6792/env/NPM_TOKEN?environment=prod" \
|
|
51
|
+
| jq -r '.value')
|
|
52
|
+
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || { echo "::error::could not fetch NPM_TOKEN from bodify"; exit 1; }
|
|
53
|
+
echo "::add-mask::$TOKEN"
|
|
54
|
+
echo "NODE_AUTH_TOKEN=$TOKEN" >> "$GITHUB_ENV"
|
|
42
55
|
- name: Publish to npm
|
|
43
56
|
run: npm publish --access public
|
|
44
57
|
env:
|
|
45
|
-
NODE_AUTH_TOKEN: ${{
|
|
58
|
+
NODE_AUTH_TOKEN: ${{ env.NODE_AUTH_TOKEN }}
|
|
46
59
|
|
|
47
60
|
- name: Push tag
|
|
48
61
|
run: |
|
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
package/src/commands/db.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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)
|
package/src/commands/env.ts
CHANGED
|
@@ -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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
|
@@ -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
|
+
})
|