bod-cli 0.10.10 → 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.10",
3
+ "version": "0.10.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -6,14 +6,40 @@ import { formatSize } from '../utils/output'
6
6
  import { resolveExcludes, tarballOffenders } from '../utils/excludes'
7
7
  import { resolveAppId, resolveAppName, readInstanceFromYaml, readDeployModeFromYaml, readExcludesFromYaml, detectSiblingDeps, readYamlConfig, resolveRepoFromYaml } from '../utils/resolve'
8
8
 
9
- async function detectBranch(explicit?: string): Promise<string> {
9
+ /**
10
+ * The branch to deploy — never the literal string `HEAD`.
11
+ *
12
+ * `git rev-parse --abbrev-ref HEAD` prints `HEAD` in a DETACHED checkout, which is what
13
+ * every GitHub Actions run and every detached worktree is. That string was then deployed
14
+ * as an ordinary branch name: a real deployment, a live `head--<app>` preview host, DNS
15
+ * and a Caddy route, for a branch that does not exist. Three of them existed on the blank
16
+ * app on 2026-09-05, and the server now refuses one outright (400), so resolving it here
17
+ * is what keeps CI deploying instead of failing.
18
+ *
19
+ * CI knows the answer even when git does not: Actions sets GITHUB_HEAD_REF on a
20
+ * pull_request run and GITHUB_REF_NAME on a push. Those are consulted before giving up.
21
+ * If nothing can name the branch we REFUSE rather than guessing `main` — silently
22
+ * promoting an arbitrary detached commit to production is far worse than stopping.
23
+ */
24
+ export async function detectBranch(explicit?: string): Promise<string> {
10
25
  if (explicit) return explicit
11
26
  const proc = Bun.spawnSync(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
12
27
  const branch = proc.exitCode === 0 ? proc.stdout.toString().trim() : ''
13
- return branch || 'main'
28
+ if (branch && branch.toUpperCase() !== 'HEAD') return branch
29
+
30
+ const fromCi = (process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || '').trim()
31
+ if (fromCi && fromCi.toUpperCase() !== 'HEAD') return fromCi
32
+
33
+ if (branch.toUpperCase() === 'HEAD') {
34
+ throw new Error(
35
+ 'Detached HEAD: git reports no branch, and no CI branch variable is set. ' +
36
+ 'Pass --branch explicitly (in GitHub Actions: --branch "${{ github.head_ref || github.ref_name }}").'
37
+ )
38
+ }
39
+ return 'main' // not a git repo at all — the historical default
14
40
  }
15
41
 
16
- export async function uploadDeploy(client: BodClient, appId: string, branch: string) {
42
+ export async function uploadDeploy(client: BodClient, appId: string, branch: string): Promise<string | undefined> {
17
43
  const allExcludes = resolveExcludes(readExcludesFromYaml())
18
44
  const excludeFlags = allExcludes.map(e => `--exclude=${e}`)
19
45
 
@@ -121,7 +147,7 @@ export async function uploadDeploy(client: BodClient, appId: string, branch: str
121
147
  console.log(chalk.dim(`Uploading ${formatSize(size)}...`))
122
148
 
123
149
  const start = Date.now()
124
- await client.upload<any>(
150
+ const res = await client.upload<any>(
125
151
  `/apps/${appId}/deploy/upload`,
126
152
  file.stream(),
127
153
  { 'x-branch': branch },
@@ -131,10 +157,12 @@ export async function uploadDeploy(client: BodClient, appId: string, branch: str
131
157
 
132
158
  // Cleanup temp file
133
159
  try { (await import('fs')).unlinkSync(tmpFile) } catch {}
160
+ return typeof res?.sha === 'string' ? res.sha : undefined
134
161
  }
135
162
 
136
- async function gitDeploy(client: BodClient, appId: string, branch: string) {
137
- 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
138
166
  }
139
167
 
140
168
  function branchToSubdomain(branch: string, domain: string, productionBranch: string): string {
@@ -178,7 +206,29 @@ async function getWithRetry<T>(client: BodClient, path: string, attempts = 4): P
178
206
  throw lastErr
179
207
  }
180
208
 
181
- 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) {
182
232
  console.log(chalk.dim('Waiting for deployment...'))
183
233
  let lastStatus = ''
184
234
  let lastLogTs = since
@@ -198,7 +248,7 @@ async function pollDeploy(client: BodClient, appId: string, since: number, insta
198
248
  return
199
249
  }
200
250
  const deps = detail.deployments ?? []
201
- const latest = deps.find((d: any) => (d.createdAt ?? 0) >= since)
251
+ const latest = selectOwnDeployment(deps, { sha: own.sha, branch: own.branch, since })
202
252
  if (!latest) continue
203
253
  const status = latest.status
204
254
  if (status !== lastStatus) {
@@ -270,6 +320,7 @@ export default defineCommand({
270
320
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
271
321
  branch: { type: 'string', description: 'Branch to deploy (default: current git branch)' },
272
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 },
273
324
  },
274
325
  async run({ args }) {
275
326
  const { url, apiKey, name: instanceName, instance } = getResolvedInstance(loadConfig(), readInstanceFromYaml())
@@ -294,6 +345,31 @@ export default defineCommand({
294
345
  await client.put(`/apps/${appId}`, yamlConfig).catch(() => {})
295
346
  }
296
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
+
297
373
  const forceUpload = args.upload || readDeployModeFromYaml() === 'upload'
298
374
 
299
375
  let hasRepo = !!detail.repo
@@ -310,13 +386,11 @@ export default defineCommand({
310
386
  console.log(chalk.dim(`Deploying ${appName} (branch: ${branch})${useUpload ? ' via upload' : ''}...`))
311
387
 
312
388
  const since = Date.now() - 5000 // buffer for clock skew
313
- if (useUpload) {
314
- await uploadDeploy(client, appId, branch)
315
- } else {
316
- await gitDeploy(client, appId, branch)
317
- }
389
+ const sha = useUpload
390
+ ? await uploadDeploy(client, appId, branch)
391
+ : await gitDeploy(client, appId, branch)
318
392
 
319
393
  console.log(chalk.green(`✓ Deployment queued`))
320
- await pollDeploy(client, appId, since, instance.capabilities, localConfig)
394
+ await pollDeploy(client, appId, since, { sha, branch }, instance.capabilities, localConfig)
321
395
  },
322
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)
@@ -0,0 +1,76 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
2
+ import { mkdtempSync, rmSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import { detectBranch } from '../src/commands/deploy'
6
+
7
+ /**
8
+ * A DETACHED HEAD IS NOT A BRANCH CALLED "HEAD".
9
+ *
10
+ * `git rev-parse --abbrev-ref HEAD` prints the literal string `HEAD` in a detached
11
+ * checkout — every GitHub Actions run, and any detached worktree. `bod deploy` used to
12
+ * send that as the branch, and the platform obligingly created a real deployment, a live
13
+ * `head--<app>` host, DNS and a Caddy route for it. Three existed on the blank app on
14
+ * 2026-09-05.
15
+ *
16
+ * This uses a REAL git repo in a real detached state, not a stubbed `git` — the whole bug
17
+ * lives in what git actually prints.
18
+ */
19
+
20
+ let dir: string
21
+ let origCwd: string
22
+ const git = (...args: string[]) => Bun.spawnSync(['git', ...args], { cwd: dir })
23
+
24
+ beforeAll(() => {
25
+ origCwd = process.cwd()
26
+ dir = mkdtempSync(join(tmpdir(), 'bod-detect-branch-'))
27
+ git('init', '-q', '-b', 'feature-x')
28
+ git('config', 'user.email', 't@t.t'); git('config', 'user.name', 't')
29
+ Bun.spawnSync(['touch', join(dir, 'f')])
30
+ git('add', '.'); git('commit', '-qm', 'c1')
31
+ })
32
+
33
+ afterAll(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }) })
34
+
35
+ const withEnv = async (env: Record<string, string | undefined>, fn: () => Promise<unknown>) => {
36
+ const saved: Record<string, string | undefined> = {}
37
+ for (const k of Object.keys(env)) { saved[k] = process.env[k]; if (env[k] === undefined) delete process.env[k]; else process.env[k] = env[k]! }
38
+ try { return await fn() } finally {
39
+ for (const k of Object.keys(saved)) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]! }
40
+ }
41
+ }
42
+ const noCi = { GITHUB_HEAD_REF: undefined, GITHUB_REF_NAME: undefined }
43
+
44
+ describe('detectBranch', () => {
45
+ test('an attached checkout reports its real branch', async () => {
46
+ process.chdir(dir)
47
+ expect(await withEnv(noCi, () => detectBranch())).toBe('feature-x')
48
+ })
49
+
50
+ test('an explicit branch always wins', async () => {
51
+ process.chdir(dir)
52
+ expect(await withEnv(noCi, () => detectBranch('release'))).toBe('release')
53
+ })
54
+
55
+ test('a DETACHED checkout refuses instead of deploying a branch named HEAD', async () => {
56
+ process.chdir(dir)
57
+ const sha = Bun.spawnSync(['git', 'rev-parse', 'HEAD'], { cwd: dir }).stdout.toString().trim()
58
+ git('checkout', '-q', sha)
59
+ // Prove the rig: git really does print the literal string here.
60
+ expect(Bun.spawnSync(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).stdout.toString().trim()).toBe('HEAD')
61
+
62
+ await expect(withEnv(noCi, () => detectBranch())).rejects.toThrow(/Detached HEAD/)
63
+ })
64
+
65
+ test('...but a GitHub Actions run resolves from its own env instead of failing', async () => {
66
+ process.chdir(dir) // still detached from the previous test
67
+ expect(await withEnv({ ...noCi, GITHUB_REF_NAME: 'main' }, () => detectBranch())).toBe('main')
68
+ // A pull_request run: head_ref is the source branch and wins over the merge ref.
69
+ expect(await withEnv({ GITHUB_HEAD_REF: 'feat/pr', GITHUB_REF_NAME: '123/merge' }, () => detectBranch())).toBe('feat/pr')
70
+ })
71
+
72
+ test('a CI variable that is itself HEAD is not accepted either', async () => {
73
+ process.chdir(dir)
74
+ await expect(withEnv({ ...noCi, GITHUB_REF_NAME: 'HEAD' }, () => detectBranch())).rejects.toThrow(/Detached HEAD/)
75
+ })
76
+ })