bod-cli 0.10.11 → 0.10.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/deploy.ts +60 -12
- package/src/utils/media-preview.ts +8 -2
- package/test/deploy-attribution.test.ts +146 -0
package/package.json
CHANGED
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
340
|
-
await uploadDeploy(client, appId, branch)
|
|
341
|
-
|
|
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
|
})
|
|
@@ -89,10 +89,16 @@ ${o.poster ? `<meta name="twitter:image" content="${posterUrl}">` : ''}
|
|
|
89
89
|
html,body{margin:0;height:100%;background:#0b0b0d;color:#e8e8ea;
|
|
90
90
|
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
91
91
|
body{display:grid;place-items:center;padding:16px;box-sizing:border-box}
|
|
92
|
-
video,audio{max-width:100%;max-height:
|
|
92
|
+
video,audio{max-width:100%;max-height:78vh;border-radius:12px;display:block}
|
|
93
|
+
.wrap{display:flex;flex-direction:column;align-items:center;gap:14px;width:100%}
|
|
94
|
+
a.dl{display:block;box-sizing:border-box;width:min(420px,100%);text-align:center;
|
|
95
|
+
text-decoration:none;padding:14px 20px;border-radius:12px;font-weight:600;font-size:15px;
|
|
96
|
+
background:#e8e8ea;color:#0b0b0d}
|
|
93
97
|
</style>
|
|
94
98
|
</head>
|
|
95
|
-
<body>${media}
|
|
99
|
+
<body><div class="wrap">${media}
|
|
100
|
+
<a class="dl" href="${f}?download" download="${esc(o.fileName)}">Download</a>
|
|
101
|
+
</div></body>
|
|
96
102
|
</html>
|
|
97
103
|
`
|
|
98
104
|
}
|
|
@@ -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)
|