bod-cli 0.10.11 → 0.10.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.10.11",
3
+ "version": "0.10.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -39,7 +39,7 @@ export async function detectBranch(explicit?: string): Promise<string> {
39
39
  return 'main' // not a git repo at all — the historical default
40
40
  }
41
41
 
42
- export async function uploadDeploy(client: BodClient, appId: string, branch: string) {
42
+ export async function uploadDeploy(client: BodClient, appId: string, branch: string): Promise<string | undefined> {
43
43
  const allExcludes = resolveExcludes(readExcludesFromYaml())
44
44
  const excludeFlags = allExcludes.map(e => `--exclude=${e}`)
45
45
 
@@ -147,7 +147,7 @@ export async function uploadDeploy(client: BodClient, appId: string, branch: str
147
147
  console.log(chalk.dim(`Uploading ${formatSize(size)}...`))
148
148
 
149
149
  const start = Date.now()
150
- await client.upload<any>(
150
+ const res = await client.upload<any>(
151
151
  `/apps/${appId}/deploy/upload`,
152
152
  file.stream(),
153
153
  { 'x-branch': branch },
@@ -157,10 +157,12 @@ export async function uploadDeploy(client: BodClient, appId: string, branch: str
157
157
 
158
158
  // Cleanup temp file
159
159
  try { (await import('fs')).unlinkSync(tmpFile) } catch {}
160
+ return typeof res?.sha === 'string' ? res.sha : undefined
160
161
  }
161
162
 
162
- async function gitDeploy(client: BodClient, appId: string, branch: string) {
163
- await client.post<any>(`/apps/${appId}/deploy`, { branch })
163
+ async function gitDeploy(client: BodClient, appId: string, branch: string): Promise<string | undefined> {
164
+ const res = await client.post<any>(`/apps/${appId}/deploy`, { branch })
165
+ return typeof res?.sha === 'string' ? res.sha : undefined
164
166
  }
165
167
 
166
168
  function branchToSubdomain(branch: string, domain: string, productionBranch: string): string {
@@ -204,7 +206,29 @@ async function getWithRetry<T>(client: BodClient, path: string, attempts = 4): P
204
206
  throw lastErr
205
207
  }
206
208
 
207
- async function pollDeploy(client: BodClient, appId: string, since: number, instanceCaps?: { baseDomain?: string | null }, localConfig?: { localDomain?: string; caddyPort?: number } | null) {
209
+ /**
210
+ * THE deployment this command created — never merely "the newest one on the app".
211
+ *
212
+ * The poll used to take the first deployment newer than the command's start time. Apps are
213
+ * deployed CONCURRENTLY (a CI run on main while a developer pushes a branch preview), so that
214
+ * picked up a stranger's deployment and reported it as this command's result. On 2026-09-05 a
215
+ * `bod deploy --upload --branch feat/…` printed "✓ Deployment live! → https://blank.bod.ee"
216
+ * because a GitHub Actions deploy of main landed 3 seconds earlier — and the branch deploy was
217
+ * then blamed for a production release it never made, and prod was "rolled back" over it.
218
+ *
219
+ * The server answers every deploy with the `sha` it minted for that job, so match on that.
220
+ * Without a sha (an older server) fall back to the BRANCH, which at least cannot cross the
221
+ * preview/production line — and never to "whatever is newest".
222
+ */
223
+ export function selectOwnDeployment(
224
+ deployments: any[],
225
+ own: { sha?: string; branch: string; since: number },
226
+ ): any | undefined {
227
+ if (own.sha) return deployments.find((d: any) => d.sha === own.sha)
228
+ return deployments.find((d: any) => d.branch === own.branch && (d.createdAt ?? 0) >= own.since)
229
+ }
230
+
231
+ async function pollDeploy(client: BodClient, appId: string, since: number, own: { sha?: string; branch: string }, instanceCaps?: { baseDomain?: string | null }, localConfig?: { localDomain?: string; caddyPort?: number } | null) {
208
232
  console.log(chalk.dim('Waiting for deployment...'))
209
233
  let lastStatus = ''
210
234
  let lastLogTs = since
@@ -224,7 +248,7 @@ async function pollDeploy(client: BodClient, appId: string, since: number, insta
224
248
  return
225
249
  }
226
250
  const deps = detail.deployments ?? []
227
- const latest = deps.find((d: any) => (d.createdAt ?? 0) >= since)
251
+ const latest = selectOwnDeployment(deps, { sha: own.sha, branch: own.branch, since })
228
252
  if (!latest) continue
229
253
  const status = latest.status
230
254
  if (status !== lastStatus) {
@@ -296,6 +320,7 @@ export default defineCommand({
296
320
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
297
321
  branch: { type: 'string', description: 'Branch to deploy (default: current git branch)' },
298
322
  upload: { type: 'boolean', description: 'Upload local source instead of git clone', default: false },
323
+ yes: { type: 'boolean', alias: 'y', description: 'Skip the confirmation for a production deploy that was not asked for by name', default: false },
299
324
  },
300
325
  async run({ args }) {
301
326
  const { url, apiKey, name: instanceName, instance } = getResolvedInstance(loadConfig(), readInstanceFromYaml())
@@ -320,6 +345,31 @@ export default defineCommand({
320
345
  await client.put(`/apps/${appId}`, yamlConfig).catch(() => {})
321
346
  }
322
347
 
348
+ // Publishing the LIVE SITE is never a side effect of forgetting a flag. Without `--branch`
349
+ // the branch comes from whatever this checkout happens to be on, so `bod deploy` in a main
350
+ // checkout silently ships production — the same one-word difference that put an unreviewed
351
+ // branch on blank.bod.ee on 2026-09-05. Naming the production branch (`--branch main`, which
352
+ // is what CI does) is an explicit act and passes straight through; guessing it is not.
353
+ const productionBranch = detail.productionBranch ?? 'main'
354
+ if (!args.branch && branch === productionBranch) {
355
+ const cmd = `bod deploy${args.app ? ` ${args.app}` : ''}${args.upload ? ' --upload' : ''} --branch ${productionBranch}`
356
+ if (!args.yes && !process.stdout.isTTY) {
357
+ console.error(chalk.red(`Refusing to deploy PRODUCTION (branch "${productionBranch}") without being asked to.`))
358
+ console.error(chalk.red(`This checkout is on "${branch}", which is ${appName}'s production branch, and no --branch was given.`))
359
+ console.error(`Run: ${cmd}`)
360
+ console.error(` or: deploy a preview instead, e.g. --branch <your-branch>`)
361
+ process.exit(1)
362
+ }
363
+ if (!args.yes) {
364
+ const { confirm } = await import('@inquirer/prompts')
365
+ const ok = await confirm({
366
+ message: `Deploy PRODUCTION for "${appName}" (branch ${productionBranch})? No --branch was given.`,
367
+ default: false,
368
+ })
369
+ if (!ok) { console.log(chalk.yellow('Aborted.')); return }
370
+ }
371
+ }
372
+
323
373
  const forceUpload = args.upload || readDeployModeFromYaml() === 'upload'
324
374
 
325
375
  let hasRepo = !!detail.repo
@@ -336,13 +386,11 @@ export default defineCommand({
336
386
  console.log(chalk.dim(`Deploying ${appName} (branch: ${branch})${useUpload ? ' via upload' : ''}...`))
337
387
 
338
388
  const since = Date.now() - 5000 // buffer for clock skew
339
- if (useUpload) {
340
- await uploadDeploy(client, appId, branch)
341
- } else {
342
- await gitDeploy(client, appId, branch)
343
- }
389
+ const sha = useUpload
390
+ ? await uploadDeploy(client, appId, branch)
391
+ : await gitDeploy(client, appId, branch)
344
392
 
345
393
  console.log(chalk.green(`✓ Deployment queued`))
346
- await pollDeploy(client, appId, since, instance.capabilities, localConfig)
394
+ await pollDeploy(client, appId, since, { sha, branch }, instance.capabilities, localConfig)
347
395
  },
348
396
  })
@@ -0,0 +1,146 @@
1
+ /**
2
+ * TWO WAYS `bod deploy` TOLD THE OPERATOR SOMETHING UNTRUE ABOUT PRODUCTION (2026-09-05).
3
+ *
4
+ * 1. ATTRIBUTION. The poll reported "the newest deployment on this app", not the one this
5
+ * command created. A GitHub Actions deploy of `main` landed 3 seconds inside the window of a
6
+ * `bod deploy --upload --branch feat/…`, so the branch deploy printed
7
+ * "✓ Deployment live! → https://blank.bod.ee" — the production URL, for a deployment it had
8
+ * not made. The branch flag was blamed for a production release, and prod was then "rolled
9
+ * back" over CI's legitimate build. Nothing was wrong with the branch header at all.
10
+ *
11
+ * 2. AN UNASKED PRODUCTION TARGET. With no `--branch`, the branch is whatever the checkout is
12
+ * on — so a bare `bod deploy` in a main checkout publishes the live site as a side effect of
13
+ * a missing flag.
14
+ *
15
+ * Both are asserted through the REAL CLI against a stub control plane: the assertions are the
16
+ * bytes the operator reads and the requests the server actually receives.
17
+ */
18
+ import { test, expect, afterAll } from 'bun:test'
19
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
20
+ import { tmpdir } from 'os'
21
+ import { join } from 'path'
22
+ import { selectOwnDeployment } from '../src/commands/deploy'
23
+
24
+ // ---------------------------------------------------------------- unit: the selector itself
25
+
26
+ const OURS = { sha: 'upload-dc524d78', branch: 'feat/onboarding', createdAt: 2_000, status: 'live' }
27
+ const STRANGER = { sha: 'upload-d95087b2', branch: 'main', createdAt: 3_000, status: 'live' } // CI, mid-flight
28
+
29
+ test('the poll picks THIS deploy, not a concurrent production deploy that landed later', () => {
30
+ const deps = [STRANGER, OURS] // server returns newest-first
31
+ expect(selectOwnDeployment(deps, { sha: OURS.sha, branch: OURS.branch, since: 1_000 })?.branch)
32
+ .toBe('feat/onboarding')
33
+ // Non-vacuous: the rule this replaced ("first one newer than `since`") really did pick main.
34
+ expect(deps.find(d => d.createdAt >= 1_000)?.branch).toBe('main')
35
+ })
36
+
37
+ test('without a sha it still cannot cross the preview/production line', () => {
38
+ expect(selectOwnDeployment([STRANGER], { branch: 'feat/onboarding', since: 1_000 })).toBeUndefined()
39
+ expect(selectOwnDeployment([STRANGER, OURS], { branch: 'feat/onboarding', since: 1_000 })?.sha).toBe(OURS.sha)
40
+ })
41
+
42
+ // ------------------------------------------------------- end-to-end: the real CLI, real bytes
43
+
44
+ const CLI = join(import.meta.dir, '../src/cli.ts')
45
+ const roots: string[] = []
46
+ afterAll(() => { for (const r of roots) rmSync(r, { recursive: true, force: true }) })
47
+
48
+ /** A checkout on `branch`, with a HOME holding a bod config pointing at `url`. */
49
+ function workspace(branch: string, url: string) {
50
+ const root = mkdtempSync(join(tmpdir(), 'bod-attr-'))
51
+ roots.push(root)
52
+ const app = join(root, 'app')
53
+ mkdirSync(app)
54
+ writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'stubapp' }))
55
+ writeFileSync(join(app, 'bodify.yaml'), 'name: stubapp\ndeploy: upload\n')
56
+ for (const a of [['init', '-q', '-b', branch], ['add', '-A'], ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'x']]) {
57
+ Bun.spawnSync(['git', ...a], { cwd: app })
58
+ }
59
+ mkdirSync(join(root, 'home', '.bod'), { recursive: true })
60
+ writeFileSync(join(root, 'home', '.bod', 'config.json'), JSON.stringify({
61
+ instances: { stub: { url, apiKey: 'k', capabilities: { registryEnabled: false, baseDomain: 'stub.test', githubConfigured: false } } },
62
+ defaultInstance: 'stub',
63
+ }))
64
+ return { app, home: join(root, 'home') }
65
+ }
66
+
67
+ /** Async on purpose: `spawnSync` would block this process's event loop, and the stub control
68
+ * plane lives in it — a synchronous spawn deadlocks against its own server. */
69
+ async function runCli(args: string[], ws: { app: string; home: string }) {
70
+ const p = Bun.spawn(['bun', CLI, ...args], {
71
+ cwd: ws.app,
72
+ env: { ...process.env, HOME: ws.home, NO_COLOR: '1', FORCE_COLOR: '0' },
73
+ stdout: 'pipe', stderr: 'pipe',
74
+ })
75
+ const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
76
+ return { code: await p.exited, out: out + err }
77
+ }
78
+
79
+ test('a branch deploy reports ITS OWN deployment while a production deploy lands beside it', async () => {
80
+ const received: { branch: string | null }[] = []
81
+ // The stub answers every poll with BOTH deployments live: ours (feat) and a stranger's (main,
82
+ // created later) — the exact shape of the 2026-09-05 race.
83
+ const server = Bun.serve({
84
+ port: 0,
85
+ async fetch(req) {
86
+ const path = new URL(req.url).pathname
87
+ if (path === '/api/apps') return Response.json([{ id: 'a1', name: 'stubapp' }])
88
+ if (path === '/api/apps/a1/deploy/upload') {
89
+ received.push({ branch: req.headers.get('x-branch') })
90
+ await req.arrayBuffer()
91
+ return Response.json({ queued: true, sha: OURS.sha }, { status: 202 })
92
+ }
93
+ if (path === '/api/apps/a1') {
94
+ return Response.json({
95
+ id: 'a1', name: 'stubapp', domain: 'stubapp.stub.test', productionBranch: 'main',
96
+ deployments: [
97
+ { ...STRANGER, createdAt: Date.now() },
98
+ { ...OURS, createdAt: Date.now() - 1000 },
99
+ ],
100
+ })
101
+ }
102
+ return Response.json([])
103
+ },
104
+ })
105
+ try {
106
+ const ws = workspace('feat/onboarding', `http://localhost:${server.port}`)
107
+ const { code, out } = await runCli(['deploy', '--upload', '--branch', 'feat/onboarding'], ws)
108
+ expect(received).toEqual([{ branch: 'feat/onboarding' }])
109
+ expect(code).toBe(0)
110
+ expect(out).toContain('Deployment live!')
111
+ // The operator must read the PREVIEW host, never the production domain of someone else's deploy.
112
+ expect(out).toContain('feat-onboarding--stubapp.stub.test')
113
+ expect(out).toContain('Preview deployment (branch: feat/onboarding)')
114
+ expect(out).not.toContain('https://stubapp.stub.test')
115
+ } finally { server.stop(true) }
116
+ }, 60_000)
117
+
118
+ test('a bare deploy from a production checkout refuses instead of publishing the live site', async () => {
119
+ const uploads: unknown[] = []
120
+ const server = Bun.serve({
121
+ port: 0,
122
+ async fetch(req) {
123
+ const path = new URL(req.url).pathname
124
+ if (path === '/api/apps') return Response.json([{ id: 'a1', name: 'stubapp' }])
125
+ if (path.endsWith('/deploy/upload')) { uploads.push(1); return Response.json({ queued: true, sha: 'x' }, { status: 202 }) }
126
+ if (path === '/api/apps/a1') return Response.json({
127
+ id: 'a1', name: 'stubapp', domain: 'stubapp.stub.test', productionBranch: 'main',
128
+ deployments: uploads.length ? [{ sha: 'x', branch: 'main', createdAt: Date.now(), status: 'live' }] : [],
129
+ })
130
+ return Response.json([])
131
+ },
132
+ })
133
+ try {
134
+ const ws = workspace('main', `http://localhost:${server.port}`)
135
+ const { code, out } = await runCli(['deploy', '--upload'], ws)
136
+ expect(code).toBe(1)
137
+ expect(out).toContain('Refusing to deploy PRODUCTION')
138
+ expect(out).toContain('--branch main') // the resolved command, not an instruction to go think
139
+ expect(uploads).toEqual([]) // nothing was published
140
+
141
+ // Naming it is an explicit act and still deploys — the guard is not a blanket refusal.
142
+ const named = await runCli(['deploy', '--upload', '--branch', 'main'], ws)
143
+ expect(uploads.length).toBe(1)
144
+ expect(named.out).toContain('Deployment queued')
145
+ } finally { server.stop(true) }
146
+ }, 60_000)