boxdown 1.2.1 → 1.3.0

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.
Files changed (32) hide show
  1. package/README.md +10 -0
  2. package/assets/devcontainer/README.md +12 -1
  3. package/assets/devcontainer/devcontainer.json +1 -1
  4. package/assets/devcontainer/hooks/post-create.sh +12 -1
  5. package/dist/bin/cli.cjs +1 -1
  6. package/dist/bin/cli.mjs +1 -1
  7. package/dist/{main-BDgyf2t5.cjs → main-CT2n9qcb.cjs} +448 -67
  8. package/dist/{main-J4_2Up3o.mjs → main-O__JaiXe.mjs} +431 -68
  9. package/dist/main-O__JaiXe.mjs.map +1 -0
  10. package/dist/main.cjs +4 -1
  11. package/dist/main.d.cts +92 -26
  12. package/dist/main.d.cts.map +1 -1
  13. package/dist/main.d.mts +92 -26
  14. package/dist/main.d.mts.map +1 -1
  15. package/dist/main.mjs +2 -2
  16. package/docs/features/lifecycle.md +13 -0
  17. package/docs/features/setup.md +5 -0
  18. package/docs/features/start-and-shell.md +5 -0
  19. package/docs/superpowers/plans/2026-07-18-architecture-aware-1password-installer.md +163 -0
  20. package/docs/superpowers/plans/2026-07-18-devcontainer-node-image-digest.md +341 -0
  21. package/docs/superpowers/plans/2026-07-19-container-runtime-readiness.md +1536 -0
  22. package/docs/superpowers/specs/2026-07-18-architecture-aware-1password-installer-design.md +63 -0
  23. package/docs/superpowers/specs/2026-07-18-devcontainer-node-image-digest-design.md +108 -0
  24. package/docs/superpowers/specs/2026-07-19-container-runtime-readiness-design.md +353 -0
  25. package/package.json +1 -1
  26. package/src/container-runtime.ts +295 -0
  27. package/src/devcontainer.ts +20 -4
  28. package/src/doctor.ts +48 -22
  29. package/src/main.ts +95 -11
  30. package/src/process.ts +50 -10
  31. package/src/progress.ts +63 -8
  32. package/dist/main-J4_2Up3o.mjs.map +0 -1
@@ -0,0 +1,1536 @@
1
+ # Container Runtime Readiness Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Give every Boxdown command that may create or start a devcontainer one bounded Docker/Buildx readiness gate, while keeping `boxdown start` usable after setup was skipped or failed and preserving actionable post-readiness failures.
6
+
7
+ **Architecture:** A focused `container-runtime` module owns one-shot probing, bounded polling, and readiness diagnostics. `doctor` consumes the one-shot probe, while CLI lifecycle branches consume the waiter before metadata or generated runtime state; the existing Dev Containers invocation remains single-attempt. The progress and command-failure layers receive only the small additions needed to surface state transitions and point users to the redacted workspace log.
8
+
9
+ **Tech Stack:** TypeScript, Node.js 24+, `node:test`, Docker CLI, Docker Buildx, `@devcontainers/cli`, pnpm, StandardJS.
10
+
11
+ ## Global Constraints
12
+
13
+ - Poll Docker daemon and a discoverable Buildx builder once per second for at most 60,000 milliseconds.
14
+ - Treat a missing Docker executable as terminal and do not sleep.
15
+ - Treat missing Buildx as a non-blocking Dev Containers CLI fallback with one warning.
16
+ - Run `docker buildx inspect --bootstrap` only after Docker, the daemon, and `docker buildx version` succeed.
17
+ - Do not launch Docker Desktop or create, select, replace, repair, or delete Buildx builders.
18
+ - Invoke `devcontainer up` once after readiness; do not retry builds, Features, registries, Dockerfiles, or lifecycle hooks.
19
+ - Apply the waiter to `setup`, `start`/`shell`, `ssh-proxy`, `tunnel`, `refresh-gh-token`, and every coding-agent command.
20
+ - Do not apply the waiter to `refresh-gh-token-running`, SSH-only commands, `doctor`, `status`, `list`, `stop`, `down`, or `purge`.
21
+ - A readiness failure must not create workspace metadata, generated devcontainer configuration, an SSH identity, or a container. A non-setup command may create only its redacted diagnostic log.
22
+ - Keep setup readiness ahead of doctor checks, prompts, metadata, and all generated state.
23
+ - Keep workspace metadata schema unchanged; do not add setup-completion state or a migration.
24
+ - Keep all automated tests independent of a real Docker daemon, registry network access, and wall-clock delays.
25
+ - Preserve current secret redaction in managed command logs.
26
+
27
+ ---
28
+
29
+ ## File Structure
30
+
31
+ - Create `src/container-runtime.ts`: Docker/Buildx command probing, bounded waiting, transition deduplication, and actionable readiness error formatting.
32
+ - Create `__tests__/container-runtime.test.ts`: deterministic unit tests using injected command results, clock, and sleep.
33
+ - Modify `src/doctor.ts`: replace duplicate Docker checks with the shared one-shot probe and add a Buildx diagnostic.
34
+ - Modify `src/main.ts`: classify gated commands, inject the waiter for tests, order readiness before metadata/state, and keep setup's preflight state-free.
35
+ - Modify `src/progress.ts`: add a mode-aware status line and improve concise nested Dev Containers failures.
36
+ - Modify `src/devcontainer.ts`: attach the managed workspace log path to lifecycle command failures.
37
+ - Modify `__tests__/app.test.ts`: cover doctor mapping, lifecycle command scope and ordering, progress output, wrapper diagnostics, and the no-retry boundary.
38
+ - Modify `README.md`, `docs/features/setup.md`, `docs/features/start-and-shell.md`, and `docs/features/lifecycle.md`: document standalone recovery, the 60-second gate, Buildx fallback, and diagnostics.
39
+
40
+ ### Task 1: Add the one-shot Docker and Buildx probe
41
+
42
+ **Files:**
43
+
44
+ - Create: `src/container-runtime.ts`
45
+ - Create: `__tests__/container-runtime.test.ts`
46
+
47
+ **Interfaces:**
48
+
49
+ - Consumes: `runBuffered(command: string, args: string[], options): Promise<CommandResult>` from `src/process.ts`.
50
+ - Produces: `ContainerRuntimeReason`, `ContainerRuntimeMode`, `ContainerRuntimeFailure`, `ContainerRuntimeProbe`, `ContainerRuntimeCommandRunner`, and `probeContainerRuntime(runCommand?)`.
51
+ - Probe command order is exactly `docker --version`, `docker info`, `docker buildx version`, then `docker buildx inspect --bootstrap`, stopping as soon as a result determines readiness.
52
+
53
+ - [ ] **Step 1: Write probe tests with an ordered fake runner**
54
+
55
+ Create `__tests__/container-runtime.test.ts` with these imports and helpers:
56
+
57
+ ```ts
58
+ import assert from 'node:assert/strict'
59
+ import { describe, test } from 'node:test'
60
+
61
+ import {
62
+ probeContainerRuntime,
63
+ type ContainerRuntimeCommandResult,
64
+ type ContainerRuntimeCommandRunner
65
+ } from '../src/container-runtime.ts'
66
+
67
+ const ok: ContainerRuntimeCommandResult = { code: 0, stdout: '', stderr: '' }
68
+
69
+ function runnerFrom (
70
+ results: readonly ContainerRuntimeCommandResult[],
71
+ calls: string[][]
72
+ ): ContainerRuntimeCommandRunner {
73
+ let index = 0
74
+ return async (command, args) => {
75
+ calls.push([command, ...args])
76
+ const result = results[index]
77
+ index += 1
78
+ assert.ok(result !== undefined, `Unexpected command: ${command} ${args.join(' ')}`)
79
+ return result
80
+ }
81
+ }
82
+ ```
83
+
84
+ Add these five tests:
85
+
86
+ ```ts
87
+ describe('container runtime probe', () => {
88
+ test('fails immediately when the Docker CLI is unavailable', async () => {
89
+ const calls: string[][] = []
90
+ const probe = await probeContainerRuntime(runnerFrom([
91
+ { code: 127, stdout: '', stderr: 'spawn docker ENOENT\n' }
92
+ ], calls))
93
+
94
+ assert.deepStrictEqual(calls, [['docker', '--version']])
95
+ assert.strictEqual(probe.state, 'failed')
96
+ assert.strictEqual(probe.failure.reason, 'docker-cli-unavailable')
97
+ assert.strictEqual(probe.failure.detail, 'spawn docker ENOENT')
98
+ })
99
+
100
+ test('waits for the daemon without probing Buildx', async () => {
101
+ const calls: string[][] = []
102
+ const probe = await probeContainerRuntime(runnerFrom([
103
+ ok,
104
+ { code: 1, stdout: '', stderr: 'Cannot connect to the Docker daemon\n' }
105
+ ], calls))
106
+
107
+ assert.deepStrictEqual(calls, [
108
+ ['docker', '--version'],
109
+ ['docker', 'info']
110
+ ])
111
+ assert.strictEqual(probe.state, 'waiting')
112
+ assert.strictEqual(probe.failure.reason, 'docker-daemon-unavailable')
113
+ assert.deepStrictEqual(probe.failure.command, ['docker', 'info'])
114
+ })
115
+
116
+ test('uses the supported fallback when Buildx is unavailable', async () => {
117
+ const calls: string[][] = []
118
+ const probe = await probeContainerRuntime(runnerFrom([
119
+ ok,
120
+ ok,
121
+ { code: 1, stdout: '', stderr: 'docker: unknown command: buildx\n' }
122
+ ], calls))
123
+
124
+ assert.deepStrictEqual(calls, [
125
+ ['docker', '--version'],
126
+ ['docker', 'info'],
127
+ ['docker', 'buildx', 'version']
128
+ ])
129
+ assert.deepStrictEqual(probe, {
130
+ state: 'ready',
131
+ mode: 'fallback',
132
+ warnings: ['Docker Buildx is unavailable; the Dev Containers CLI will use its classic-build fallback.']
133
+ })
134
+ })
135
+
136
+ test('waits when a discoverable Buildx builder cannot bootstrap', async () => {
137
+ const calls: string[][] = []
138
+ const probe = await probeContainerRuntime(runnerFrom([
139
+ ok,
140
+ ok,
141
+ ok,
142
+ { code: 1, stdout: 'builder output\n', stderr: 'failed to initialize builder\n' }
143
+ ], calls))
144
+
145
+ assert.deepStrictEqual(calls.at(-1), ['docker', 'buildx', 'inspect', '--bootstrap'])
146
+ assert.strictEqual(probe.state, 'waiting')
147
+ assert.strictEqual(probe.failure.reason, 'buildx-builder-unavailable')
148
+ assert.strictEqual(probe.failure.detail, 'failed to initialize builder')
149
+ })
150
+
151
+ test('reports Buildx readiness and compacts diagnostic output', async () => {
152
+ const ready = await probeContainerRuntime(runnerFrom([ok, ok, ok, ok], []))
153
+ assert.deepStrictEqual(ready, { state: 'ready', mode: 'buildx', warnings: [] })
154
+
155
+ const failed = await probeContainerRuntime(runnerFrom([
156
+ ok,
157
+ { code: 1, stdout: ' daemon\n still starting ', stderr: '' }
158
+ ], []))
159
+ assert.strictEqual(failed.state, 'waiting')
160
+ assert.strictEqual(failed.failure.detail, 'daemon still starting')
161
+ })
162
+ })
163
+ ```
164
+
165
+ - [ ] **Step 2: Run the focused test and confirm the module is missing**
166
+
167
+ Run:
168
+
169
+ ```sh
170
+ node --import tsx --test __tests__/container-runtime.test.ts
171
+ ```
172
+
173
+ Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/container-runtime.ts`.
174
+
175
+ - [ ] **Step 3: Implement the probe and its stable result types**
176
+
177
+ Create `src/container-runtime.ts` with:
178
+
179
+ ```ts
180
+ import { runBuffered, type CommandResult } from './process.ts'
181
+
182
+ export type ContainerRuntimeReason =
183
+ | 'docker-cli-unavailable'
184
+ | 'docker-daemon-unavailable'
185
+ | 'buildx-builder-unavailable'
186
+
187
+ export type ContainerRuntimeMode = 'buildx' | 'fallback'
188
+ export type ContainerRuntimeCommandResult = CommandResult
189
+ export type ContainerRuntimeCommandRunner = (
190
+ command: string,
191
+ args: string[]
192
+ ) => Promise<ContainerRuntimeCommandResult>
193
+
194
+ export interface ContainerRuntimeFailure {
195
+ reason: ContainerRuntimeReason
196
+ command: string[]
197
+ detail: string
198
+ }
199
+
200
+ export type ContainerRuntimeProbe =
201
+ | { state: 'ready', mode: ContainerRuntimeMode, warnings: string[] }
202
+ | { state: 'waiting', failure: ContainerRuntimeFailure }
203
+ | { state: 'failed', failure: ContainerRuntimeFailure }
204
+
205
+ const BUILDX_FALLBACK_WARNING = 'Docker Buildx is unavailable; the Dev Containers CLI will use its classic-build fallback.'
206
+
207
+ async function runContainerRuntimeCommand (
208
+ command: string,
209
+ args: string[]
210
+ ): Promise<ContainerRuntimeCommandResult> {
211
+ return runBuffered(command, args, {
212
+ mirrorStdout: false,
213
+ mirrorStderr: false
214
+ })
215
+ }
216
+
217
+ function compactCommandOutput (result: ContainerRuntimeCommandResult): string {
218
+ const output = result.stderr.trim().length > 0 ? result.stderr : result.stdout
219
+ const compact = output.trim().replace(/\s+/gu, ' ').slice(0, 500)
220
+ return compact.length > 0 ? compact : `Command exited with code ${result.code}`
221
+ }
222
+
223
+ function failure (
224
+ reason: ContainerRuntimeReason,
225
+ command: string[],
226
+ result: ContainerRuntimeCommandResult
227
+ ): ContainerRuntimeFailure {
228
+ return {
229
+ reason,
230
+ command,
231
+ detail: compactCommandOutput(result)
232
+ }
233
+ }
234
+
235
+ export async function probeContainerRuntime (
236
+ runCommand: ContainerRuntimeCommandRunner = runContainerRuntimeCommand
237
+ ): Promise<ContainerRuntimeProbe> {
238
+ const dockerVersionCommand = ['docker', '--version']
239
+ const dockerVersion = await runCommand(dockerVersionCommand[0] as string, dockerVersionCommand.slice(1))
240
+ if (dockerVersion.code !== 0) {
241
+ return {
242
+ state: 'failed',
243
+ failure: failure('docker-cli-unavailable', dockerVersionCommand, dockerVersion)
244
+ }
245
+ }
246
+
247
+ const dockerInfoCommand = ['docker', 'info']
248
+ const dockerInfo = await runCommand(dockerInfoCommand[0] as string, dockerInfoCommand.slice(1))
249
+ if (dockerInfo.code !== 0) {
250
+ return {
251
+ state: 'waiting',
252
+ failure: failure('docker-daemon-unavailable', dockerInfoCommand, dockerInfo)
253
+ }
254
+ }
255
+
256
+ const buildxVersionCommand = ['docker', 'buildx', 'version']
257
+ const buildxVersion = await runCommand(buildxVersionCommand[0] as string, buildxVersionCommand.slice(1))
258
+ if (buildxVersion.code !== 0) {
259
+ return {
260
+ state: 'ready',
261
+ mode: 'fallback',
262
+ warnings: [BUILDX_FALLBACK_WARNING]
263
+ }
264
+ }
265
+
266
+ const buildxInspectCommand = ['docker', 'buildx', 'inspect', '--bootstrap']
267
+ const buildxInspect = await runCommand(buildxInspectCommand[0] as string, buildxInspectCommand.slice(1))
268
+ if (buildxInspect.code !== 0) {
269
+ return {
270
+ state: 'waiting',
271
+ failure: failure('buildx-builder-unavailable', buildxInspectCommand, buildxInspect)
272
+ }
273
+ }
274
+
275
+ return { state: 'ready', mode: 'buildx', warnings: [] }
276
+ }
277
+ ```
278
+
279
+ - [ ] **Step 4: Run the focused test and confirm all probe cases pass**
280
+
281
+ Run:
282
+
283
+ ```sh
284
+ node --import tsx --test __tests__/container-runtime.test.ts
285
+ ```
286
+
287
+ Expected: PASS with 5 tests.
288
+
289
+ - [ ] **Step 5: Commit the probe**
290
+
291
+ ```sh
292
+ git add src/container-runtime.ts __tests__/container-runtime.test.ts
293
+ git commit -m "feat: probe container runtime readiness"
294
+ ```
295
+
296
+ ### Task 2: Add deterministic bounded waiting and readiness diagnostics
297
+
298
+ **Files:**
299
+
300
+ - Modify: `src/container-runtime.ts`
301
+ - Modify: `__tests__/container-runtime.test.ts`
302
+
303
+ **Interfaces:**
304
+
305
+ - Consumes: `probeContainerRuntime(runCommand?)` from Task 1.
306
+ - Produces: `ContainerRuntimeWaitResult`, `WaitForContainerRuntimeOptions`, `waitForContainerRuntime(options?)`, and `formatContainerRuntimeFailure(result, options?)`.
307
+ - `onTransition` fires for the first non-ready probe and only when `state` or `failure.reason` changes; repeated one-second attempts are silent.
308
+
309
+ - [ ] **Step 1: Add waiter tests using a fake clock and sleep**
310
+
311
+ Extend the import in `__tests__/container-runtime.test.ts`:
312
+
313
+ ```ts
314
+ import {
315
+ formatContainerRuntimeFailure,
316
+ probeContainerRuntime,
317
+ waitForContainerRuntime,
318
+ type ContainerRuntimeCommandResult,
319
+ type ContainerRuntimeCommandRunner,
320
+ type ContainerRuntimeProbe
321
+ } from '../src/container-runtime.ts'
322
+ ```
323
+
324
+ Add a helper that creates one complete probe attempt per supplied `docker info` result:
325
+
326
+ ```ts
327
+ function daemonSequenceRunner (
328
+ daemonResults: readonly ContainerRuntimeCommandResult[]
329
+ ): ContainerRuntimeCommandRunner {
330
+ let daemonIndex = 0
331
+ return async (_command, args) => {
332
+ if (args[0] === '--version' || args[0] === 'buildx') return ok
333
+ if (args[0] === 'info') {
334
+ const result = daemonResults[Math.min(daemonIndex, daemonResults.length - 1)]
335
+ daemonIndex += 1
336
+ assert.ok(result !== undefined)
337
+ return result
338
+ }
339
+ assert.fail(`Unexpected args: ${args.join(' ')}`)
340
+ }
341
+ }
342
+ ```
343
+
344
+ Add these tests:
345
+
346
+ ```ts
347
+ describe('container runtime waiter', () => {
348
+ test('probes immediately and sleeps exactly once per transient retry', async () => {
349
+ let now = 0
350
+ const sleeps: number[] = []
351
+ const result = await waitForContainerRuntime({
352
+ runCommand: daemonSequenceRunner([
353
+ { code: 1, stdout: '', stderr: 'starting one' },
354
+ { code: 1, stdout: '', stderr: 'starting two' },
355
+ ok
356
+ ]),
357
+ now: () => now,
358
+ sleep: async (milliseconds) => {
359
+ sleeps.push(milliseconds)
360
+ now += milliseconds
361
+ }
362
+ })
363
+
364
+ assert.deepStrictEqual(sleeps, [1_000, 1_000])
365
+ assert.strictEqual(result.state, 'ready')
366
+ assert.strictEqual(result.mode, 'buildx')
367
+ })
368
+
369
+ test('does not sleep after a terminal failure', async () => {
370
+ const sleeps: number[] = []
371
+ const result = await waitForContainerRuntime({
372
+ runCommand: runnerFrom([{ code: 127, stdout: '', stderr: 'ENOENT' }], []),
373
+ sleep: async (milliseconds) => { sleeps.push(milliseconds) }
374
+ })
375
+
376
+ assert.deepStrictEqual(sleeps, [])
377
+ assert.strictEqual(result.state, 'failed')
378
+ assert.strictEqual(result.timedOut, false)
379
+ })
380
+
381
+ test('stops at the deadline and retains the final probe', async () => {
382
+ let now = 0
383
+ const result = await waitForContainerRuntime({
384
+ runCommand: daemonSequenceRunner([
385
+ { code: 1, stdout: '', stderr: 'first diagnostic' },
386
+ { code: 1, stdout: '', stderr: 'last diagnostic' }
387
+ ]),
388
+ timeoutMs: 1_000,
389
+ pollIntervalMs: 1_000,
390
+ now: () => now,
391
+ sleep: async (milliseconds) => { now += milliseconds }
392
+ })
393
+
394
+ assert.strictEqual(result.state, 'failed')
395
+ assert.strictEqual(result.timedOut, true)
396
+ assert.strictEqual(result.timeoutMs, 1_000)
397
+ assert.strictEqual(result.failure.detail, 'last diagnostic')
398
+ })
399
+
400
+ test('emits only state and reason transitions', async () => {
401
+ let now = 0
402
+ const transitions: ContainerRuntimeProbe[] = []
403
+ const result = await waitForContainerRuntime({
404
+ runCommand: daemonSequenceRunner([
405
+ { code: 1, stdout: '', stderr: 'first' },
406
+ { code: 1, stdout: '', stderr: 'changed output' },
407
+ ok
408
+ ]),
409
+ now: () => now,
410
+ sleep: async (milliseconds) => { now += milliseconds },
411
+ onTransition: (probe) => { transitions.push(probe) }
412
+ })
413
+
414
+ assert.strictEqual(result.state, 'ready')
415
+ assert.strictEqual(transitions.length, 1)
416
+ assert.strictEqual(transitions[0]?.state, 'waiting')
417
+ })
418
+
419
+ test('formats timeout, manual check, detail, and optional log path', async () => {
420
+ const result = await waitForContainerRuntime({
421
+ runCommand: daemonSequenceRunner([{ code: 1, stdout: '', stderr: 'Cannot connect' }]),
422
+ timeoutMs: 0,
423
+ now: () => 0
424
+ })
425
+ assert.strictEqual(result.state, 'failed')
426
+
427
+ const message = formatContainerRuntimeFailure(result, { logPath: '/tmp/boxdown.log' })
428
+ assert.match(message, /Docker daemon did not become ready within 0 seconds\./)
429
+ assert.match(message, /Last check: docker info/)
430
+ assert.match(message, /Detail: Cannot connect/)
431
+ assert.match(message, /Check Docker with: docker info/)
432
+ assert.match(message, /Command log: \/tmp\/boxdown\.log/)
433
+ })
434
+ })
435
+ ```
436
+
437
+ - [ ] **Step 2: Run the tests and confirm waiter exports are missing**
438
+
439
+ Run:
440
+
441
+ ```sh
442
+ node --import tsx --test __tests__/container-runtime.test.ts
443
+ ```
444
+
445
+ Expected: FAIL because `waitForContainerRuntime` and `formatContainerRuntimeFailure` are not exported.
446
+
447
+ - [ ] **Step 3: Implement bounded waiting and transition deduplication**
448
+
449
+ Append these types and functions to `src/container-runtime.ts`:
450
+
451
+ ```ts
452
+ export type ContainerRuntimeWaitResult =
453
+ | { state: 'ready', mode: ContainerRuntimeMode, warnings: string[] }
454
+ | {
455
+ state: 'failed'
456
+ failure: ContainerRuntimeFailure
457
+ timedOut: boolean
458
+ timeoutMs: number
459
+ }
460
+
461
+ export interface WaitForContainerRuntimeOptions {
462
+ runCommand?: ContainerRuntimeCommandRunner
463
+ timeoutMs?: number
464
+ pollIntervalMs?: number
465
+ now?: () => number
466
+ sleep?: (milliseconds: number) => Promise<void>
467
+ onTransition?: (probe: ContainerRuntimeProbe) => void
468
+ }
469
+
470
+ function defaultSleep (milliseconds: number): Promise<void> {
471
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
472
+ }
473
+
474
+ function transitionKey (probe: ContainerRuntimeProbe): string {
475
+ return probe.state === 'ready' ? 'ready' : `${probe.state}:${probe.failure.reason}`
476
+ }
477
+
478
+ export async function waitForContainerRuntime (
479
+ options: WaitForContainerRuntimeOptions = {}
480
+ ): Promise<ContainerRuntimeWaitResult> {
481
+ const timeoutMs = options.timeoutMs ?? 60_000
482
+ const pollIntervalMs = options.pollIntervalMs ?? 1_000
483
+ const now = options.now ?? Date.now
484
+ const sleep = options.sleep ?? defaultSleep
485
+ const deadline = now() + timeoutMs
486
+ let lastTransition: string | undefined
487
+
488
+ while (true) {
489
+ const probe = await probeContainerRuntime(options.runCommand)
490
+
491
+ if (probe.state === 'ready') return probe
492
+
493
+ const currentTransition = transitionKey(probe)
494
+ if (currentTransition !== lastTransition) {
495
+ options.onTransition?.(probe)
496
+ lastTransition = currentTransition
497
+ }
498
+
499
+ if (probe.state === 'failed') {
500
+ return { state: 'failed', failure: probe.failure, timedOut: false, timeoutMs }
501
+ }
502
+
503
+ const remainingMs = deadline - now()
504
+ if (remainingMs <= 0) {
505
+ return { state: 'failed', failure: probe.failure, timedOut: true, timeoutMs }
506
+ }
507
+
508
+ await sleep(Math.min(pollIntervalMs, remainingMs))
509
+ }
510
+ }
511
+ ```
512
+
513
+ - [ ] **Step 4: Implement readiness failure formatting**
514
+
515
+ Append this code to `src/container-runtime.ts`:
516
+
517
+ ```ts
518
+ function commandText (command: readonly string[]): string {
519
+ return command.join(' ')
520
+ }
521
+
522
+ export function formatContainerRuntimeFailure (
523
+ result: Extract<ContainerRuntimeWaitResult, { state: 'failed' }>,
524
+ options: { logPath?: string } = {}
525
+ ): string {
526
+ const seconds = result.timeoutMs / 1_000
527
+ const descriptions: Record<ContainerRuntimeReason, string> = {
528
+ 'docker-cli-unavailable': 'Docker CLI is required but was not available.',
529
+ 'docker-daemon-unavailable': result.timedOut
530
+ ? `Docker daemon did not become ready within ${seconds} seconds.`
531
+ : 'Docker daemon is required but was not reachable.',
532
+ 'buildx-builder-unavailable': result.timedOut
533
+ ? `Docker Buildx builder did not become ready within ${seconds} seconds.`
534
+ : 'Docker Buildx builder was not operational.'
535
+ }
536
+ const manualCheck = result.failure.reason === 'buildx-builder-unavailable'
537
+ ? 'docker buildx inspect'
538
+ : result.failure.reason === 'docker-daemon-unavailable'
539
+ ? 'docker info'
540
+ : 'docker --version'
541
+ const lines = [
542
+ descriptions[result.failure.reason],
543
+ `Last check: ${commandText(result.failure.command)}`,
544
+ `Detail: ${result.failure.detail}`,
545
+ `Check ${result.failure.reason === 'buildx-builder-unavailable' ? 'Buildx' : 'Docker'} with: ${manualCheck}`
546
+ ]
547
+
548
+ if (options.logPath !== undefined) lines.push(`Command log: ${options.logPath}`)
549
+ return lines.join('\n')
550
+ }
551
+ ```
552
+
553
+ - [ ] **Step 5: Run focused tests and static checks**
554
+
555
+ Run:
556
+
557
+ ```sh
558
+ node --import tsx --test __tests__/container-runtime.test.ts
559
+ pnpm lint
560
+ pnpm build
561
+ ```
562
+
563
+ Expected: all commands exit 0; the focused file reports 10 passing tests.
564
+
565
+ - [ ] **Step 6: Commit the waiter**
566
+
567
+ ```sh
568
+ git add src/container-runtime.ts __tests__/container-runtime.test.ts
569
+ git commit -m "feat: wait for container runtime readiness"
570
+ ```
571
+
572
+ ### Task 3: Make doctor reuse the one-shot readiness probe
573
+
574
+ **Files:**
575
+
576
+ - Modify: `src/doctor.ts:12-32,186-211`
577
+ - Modify: `__tests__/app.test.ts:2883-3090`
578
+
579
+ **Interfaces:**
580
+
581
+ - Consumes: `probeContainerRuntime(runCommand)` and `ContainerRuntimeCommandRunner` from Task 1.
582
+ - Produces: `RunDoctorChecksOptions.containerRuntimeReady?: boolean`; default `false`. Setup sets it only after the shared waiter succeeds, avoiding duplicate Docker/Buildx status commands while retaining the Docker bind-mount probe.
583
+ - Doctor remains a snapshot: it calls the probe once and never calls `waitForContainerRuntime`.
584
+
585
+ - [ ] **Step 1: Add doctor tests for Buildx readiness, fallback, and broken builders**
586
+
587
+ In the existing `describe('doctor', ...)` block in `__tests__/app.test.ts`, add:
588
+
589
+ ```ts
590
+ test('doctor reports Buildx readiness from the shared runtime probe', async () => {
591
+ const context = createWorkspaceContext({
592
+ workspace: tempDir('doctor-buildx-ready-workspace'),
593
+ env: { BOXDOWN_CACHE_HOME: tempDir('doctor-buildx-ready-cache'), BOXDOWN_DATA_HOME: tempDir('doctor-buildx-ready-data') },
594
+ assetsDevcontainerDir
595
+ })
596
+ const calls: string[] = []
597
+ const checks = await runDoctorChecks(context, {
598
+ includeOptional: false,
599
+ includeDockerMountProbe: false,
600
+ runCommand: async (command, args) => {
601
+ calls.push([command, ...args].join(' '))
602
+ return { code: 0, stdout: '', stderr: '' }
603
+ }
604
+ })
605
+
606
+ assert.ok(calls.includes('docker buildx inspect --bootstrap'))
607
+ assert.deepStrictEqual(checks.find((check) => check.name === 'docker-buildx'), {
608
+ name: 'docker-buildx',
609
+ level: 'ok',
610
+ message: 'Docker Buildx builder is operational'
611
+ })
612
+ })
613
+
614
+ test('doctor warns when the Dev Containers fallback will be used', async () => {
615
+ const context = createWorkspaceContext({
616
+ workspace: tempDir('doctor-buildx-fallback-workspace'),
617
+ env: { BOXDOWN_CACHE_HOME: tempDir('doctor-buildx-fallback-cache'), BOXDOWN_DATA_HOME: tempDir('doctor-buildx-fallback-data') },
618
+ assetsDevcontainerDir
619
+ })
620
+ const checks = await runDoctorChecks(context, {
621
+ includeOptional: false,
622
+ includeDockerMountProbe: false,
623
+ runCommand: async (command, args) => ({
624
+ code: command === 'docker' && args.join(' ') === 'buildx version' ? 1 : 0,
625
+ stdout: '',
626
+ stderr: command === 'docker' && args.join(' ') === 'buildx version' ? 'unknown command: buildx' : ''
627
+ })
628
+ })
629
+
630
+ const buildx = checks.find((check) => check.name === 'docker-buildx')
631
+ assert.strictEqual(buildx?.level, 'warn')
632
+ assert.match(buildx?.message ?? '', /classic-build fallback/)
633
+ })
634
+
635
+ test('doctor fails a discoverable but unusable Buildx builder', async () => {
636
+ const context = createWorkspaceContext({
637
+ workspace: tempDir('doctor-buildx-failed-workspace'),
638
+ env: { BOXDOWN_CACHE_HOME: tempDir('doctor-buildx-failed-cache'), BOXDOWN_DATA_HOME: tempDir('doctor-buildx-failed-data') },
639
+ assetsDevcontainerDir
640
+ })
641
+ const checks = await runDoctorChecks(context, {
642
+ includeOptional: false,
643
+ includeDockerMountProbe: false,
644
+ runCommand: async (command, args) => ({
645
+ code: command === 'docker' && args.join(' ') === 'buildx inspect --bootstrap' ? 1 : 0,
646
+ stdout: '',
647
+ stderr: command === 'docker' && args.join(' ') === 'buildx inspect --bootstrap' ? 'builder is starting' : ''
648
+ })
649
+ })
650
+
651
+ assert.deepStrictEqual(checks.find((check) => check.name === 'docker-buildx'), {
652
+ name: 'docker-buildx',
653
+ level: 'fail',
654
+ message: 'Docker Buildx builder was not operational: builder is starting'
655
+ })
656
+ })
657
+
658
+ test('doctor accepts prevalidated runtime status and retains the bind-mount probe', async () => {
659
+ const context = createWorkspaceContext({
660
+ workspace: tempDir('doctor-prevalidated-runtime-workspace'),
661
+ env: { BOXDOWN_CACHE_HOME: tempDir('doctor-prevalidated-runtime-cache'), BOXDOWN_DATA_HOME: tempDir('doctor-prevalidated-runtime-data') },
662
+ assetsDevcontainerDir
663
+ })
664
+ const calls: string[] = []
665
+ let container = 0
666
+ const checks = await runDoctorChecks(context, {
667
+ includeOptional: false,
668
+ containerRuntimeReady: true,
669
+ runCommand: async (command, args) => {
670
+ calls.push([command, ...args].join(' '))
671
+ if (command === 'docker' && args[0] === 'image') return { code: 0, stdout: 'node:24\n', stderr: '' }
672
+ if (command === 'docker' && args[0] === 'create') {
673
+ container += 1
674
+ return { code: 0, stdout: `probe-${container}\n`, stderr: '' }
675
+ }
676
+ return { code: 0, stdout: '', stderr: '' }
677
+ }
678
+ })
679
+
680
+ assert.ok(!calls.includes('docker --version'))
681
+ assert.ok(!calls.includes('docker info'))
682
+ assert.ok(!calls.includes('docker buildx version'))
683
+ assert.ok(calls.includes('docker image ls --format {{.Repository}}:{{.Tag}}'))
684
+ assert.strictEqual(checks.find((check) => check.name === 'docker-bind-mounts')?.level, 'ok')
685
+ })
686
+ ```
687
+
688
+ - [ ] **Step 2: Run the doctor tests and confirm the Buildx check is absent**
689
+
690
+ Run:
691
+
692
+ ```sh
693
+ node --import tsx --test --test-name-pattern="doctor .*Buildx" __tests__/app.test.ts
694
+ ```
695
+
696
+ Expected: FAIL because no `docker-buildx` check exists.
697
+
698
+ - [ ] **Step 3: Replace the doctor command-result aliases and add the skip option**
699
+
700
+ At the top of `src/doctor.ts`, add:
701
+
702
+ ```ts
703
+ import {
704
+ probeContainerRuntime,
705
+ type ContainerRuntimeCommandResult,
706
+ type ContainerRuntimeCommandRunner
707
+ } from './container-runtime.ts'
708
+ ```
709
+
710
+ Replace the duplicate doctor command interfaces with aliases and extend the options:
711
+
712
+ ```ts
713
+ export type DoctorCommandResult = ContainerRuntimeCommandResult
714
+ export type DoctorCommandRunner = ContainerRuntimeCommandRunner
715
+
716
+ export interface RunDoctorChecksOptions {
717
+ includeOptional?: boolean
718
+ includeDockerMountProbe?: boolean
719
+ containerRuntimeReady?: boolean
720
+ runCommand?: DoctorCommandRunner
721
+ }
722
+ ```
723
+
724
+ - [ ] **Step 4: Replace the separate Docker checks with probe-to-check mapping**
725
+
726
+ In `runDoctorChecks`, replace the existing `docker --version` and `docker info` block with:
727
+
728
+ ```ts
729
+ let dockerCliWorks = options.containerRuntimeReady === true
730
+ let dockerDaemonWorks = options.containerRuntimeReady === true
731
+
732
+ if (options.containerRuntimeReady !== true) {
733
+ const runtime = await probeContainerRuntime(runCommand)
734
+ dockerCliWorks = runtime.state === 'ready' || runtime.failure.reason !== 'docker-cli-unavailable'
735
+ dockerDaemonWorks = runtime.state === 'ready' ||
736
+ (runtime.state === 'waiting' && runtime.failure.reason === 'buildx-builder-unavailable')
737
+
738
+ checks.push(check(
739
+ 'docker-cli',
740
+ dockerCliWorks,
741
+ 'Docker CLI is available',
742
+ 'Docker CLI is required but was not available'
743
+ ))
744
+ checks.push(check(
745
+ 'docker-daemon',
746
+ dockerDaemonWorks,
747
+ 'Docker daemon is reachable',
748
+ 'Docker daemon is required but was not reachable'
749
+ ))
750
+
751
+ if (!dockerCliWorks || !dockerDaemonWorks) {
752
+ checks.push({
753
+ name: 'docker-buildx',
754
+ level: 'warn',
755
+ message: 'Docker Buildx was not checked because the Docker runtime is unavailable'
756
+ })
757
+ } else if (runtime.state === 'ready' && runtime.mode === 'fallback') {
758
+ checks.push({ name: 'docker-buildx', level: 'warn', message: runtime.warnings[0] as string })
759
+ } else if (runtime.state === 'waiting') {
760
+ checks.push({
761
+ name: 'docker-buildx',
762
+ level: 'fail',
763
+ message: `Docker Buildx builder was not operational: ${runtime.failure.detail}`
764
+ })
765
+ } else {
766
+ checks.push({ name: 'docker-buildx', level: 'ok', message: 'Docker Buildx builder is operational' })
767
+ }
768
+ }
769
+ ```
770
+
771
+ Keep the Docker bind-mount probe independently controlled. Because setup sets `dockerCliWorks` and `dockerDaemonWorks` from its successful waiter, it still exercises the required mount check without repeating Docker/Buildx status commands:
772
+
773
+ ```ts
774
+ if (options.includeDockerMountProbe ?? true) {
775
+ checks.push(await checkDockerBindMounts(context, runCommand, dockerCliWorks && dockerDaemonWorks))
776
+ }
777
+ ```
778
+
779
+ - [ ] **Step 5: Run doctor and full unit tests**
780
+
781
+ Run:
782
+
783
+ ```sh
784
+ node --import tsx --test --test-name-pattern="doctor" __tests__/app.test.ts
785
+ pnpm test
786
+ ```
787
+
788
+ Expected: both commands exit 0. Update only existing assertions that intentionally enumerate doctor command calls or check names; do not weaken level/message assertions.
789
+
790
+ - [ ] **Step 6: Commit doctor integration**
791
+
792
+ ```sh
793
+ git add src/doctor.ts __tests__/app.test.ts
794
+ git commit -m "feat: report Buildx readiness in doctor"
795
+ ```
796
+
797
+ ### Task 4: Gate every container-creating CLI lifecycle before metadata
798
+
799
+ **Files:**
800
+
801
+ - Modify: `src/main.ts:1-65,502-526,1060-1125,1218-1515`
802
+ - Modify: `src/progress.ts:125-175`
803
+ - Modify: `__tests__/app.test.ts:1105-1215` and the command-classification tests near `commandWritesWorkspaceMetadata`
804
+
805
+ **Interfaces:**
806
+
807
+ - Consumes: `waitForContainerRuntime(options?)`, `formatContainerRuntimeFailure(result, options?)`, and `WorkspaceCommandLogger`.
808
+ - Produces: `commandRequiresContainerRuntime(command: BoxdownCommand): boolean`, `ProgressReporter.status(message: string): void`, `RunCliOptions.waitForContainerRuntime?: typeof waitForContainerRuntime`, `RunCliOptions.writeWorkspaceMetadata?: (context: WorkspaceContext, alias: string) => void`, `runContainerRuntimePreflight(context, progress, options, logger?)`, and `prepareContainerLifecycle(context, alias, progress, options, logger?)`.
809
+ - Every gated non-setup branch must execute `prepareContainerLifecycle`, which waits first and writes metadata second, before its first state-generating or container action.
810
+
811
+ - [ ] **Step 1: Add table-driven command-scope tests**
812
+
813
+ Import `commandRequiresContainerRuntime` from `src/main.ts` in `__tests__/app.test.ts`, then add beside the metadata classification tests:
814
+
815
+ ```ts
816
+ test('container runtime readiness scope is explicit for every command', () => {
817
+ const expected = new Map<BoxdownCommand, boolean>([
818
+ ['help', false],
819
+ ['version', false],
820
+ ['setup', true],
821
+ ['start', true],
822
+ ['list', false],
823
+ ['status', false],
824
+ ['stop', false],
825
+ ['down', false],
826
+ ['purge', false],
827
+ ['doctor', false],
828
+ ['ssh-install', false],
829
+ ['ssh-uninstall', false],
830
+ ['ssh-proxy', true],
831
+ ['tunnel', true],
832
+ ['refresh-gh-token', true],
833
+ ['refresh-gh-token-running', false],
834
+ ['coding-agent', true]
835
+ ])
836
+
837
+ for (const [command, waits] of expected) {
838
+ assert.strictEqual(commandRequiresContainerRuntime(command), waits, command)
839
+ }
840
+ })
841
+ ```
842
+
843
+ - [ ] **Step 2: Add setup ordering tests**
844
+
845
+ Extend the existing setup-preflight failure test so its injected waiter records `runtime`, returns a terminal failure, and verifies the doctor and setup action were not called:
846
+
847
+ ```ts
848
+ const calls: string[] = []
849
+ const code = await runCli(['setup', '--workspace', workspace], {
850
+ env: { CI: '1' },
851
+ waitForContainerRuntime: async () => {
852
+ calls.push('runtime')
853
+ return {
854
+ state: 'failed',
855
+ failure: {
856
+ reason: 'docker-daemon-unavailable',
857
+ command: ['docker', 'info'],
858
+ detail: 'Cannot connect'
859
+ },
860
+ timedOut: true,
861
+ timeoutMs: 60_000
862
+ }
863
+ },
864
+ runDoctorChecks: async () => {
865
+ calls.push('doctor')
866
+ return []
867
+ },
868
+ setupWorkspace: async () => { calls.push('setup') }
869
+ })
870
+
871
+ assert.strictEqual(code, 1)
872
+ assert.deepStrictEqual(calls, ['runtime'])
873
+ assert.strictEqual(existsSync(context.workspaceDataDir), false)
874
+ assert.strictEqual(existsSync(context.generatedConfigPath), false)
875
+ assert.strictEqual(existsSync(context.sshKeyPath), false)
876
+ ```
877
+
878
+ - [ ] **Step 3: Run the new CLI tests and confirm the classification/injection is absent**
879
+
880
+ Run:
881
+
882
+ ```sh
883
+ node --import tsx --test --test-name-pattern="container runtime readiness scope|setup preflight" __tests__/app.test.ts
884
+ ```
885
+
886
+ Expected: FAIL because the classifier and waiter injection do not exist.
887
+
888
+ - [ ] **Step 4: Add the command classifier and waiter injection**
889
+
890
+ Add imports to `src/main.ts`:
891
+
892
+ ```ts
893
+ import {
894
+ formatContainerRuntimeFailure,
895
+ waitForContainerRuntime,
896
+ type ContainerRuntimeProbe
897
+ } from './container-runtime.ts'
898
+ import { runBuffered } from './process.ts'
899
+ ```
900
+
901
+ Extend `RunCliOptions`:
902
+
903
+ ```ts
904
+ export interface RunCliOptions {
905
+ promptInput?: PromptInput
906
+ promptOutput?: PromptOutput
907
+ env?: NodeJS.ProcessEnv
908
+ runDoctorChecks?: typeof runDoctorChecks
909
+ setupWorkspace?: typeof setupWorkspace
910
+ waitForContainerRuntime?: typeof waitForContainerRuntime
911
+ writeWorkspaceMetadata?: (context: WorkspaceContext, alias: string) => void
912
+ }
913
+ ```
914
+
915
+ Add the exhaustive classifier next to `commandWritesWorkspaceMetadata`:
916
+
917
+ ```ts
918
+ export function commandRequiresContainerRuntime (command: BoxdownCommand): boolean {
919
+ return command === 'setup' ||
920
+ command === 'start' ||
921
+ command === 'ssh-proxy' ||
922
+ command === 'tunnel' ||
923
+ command === 'refresh-gh-token' ||
924
+ command === 'coding-agent'
925
+ }
926
+ ```
927
+
928
+ - [ ] **Step 5: Add mode-aware readiness status output**
929
+
930
+ Add this public method after `detail` in `ProgressReporter` in `src/progress.ts`:
931
+
932
+ ```ts
933
+ status (message: string): void {
934
+ if (this.mode === 'none') return
935
+ if (this.mode === 'verbose') {
936
+ this.#write(this.target, message)
937
+ return
938
+ }
939
+ this.detail(message)
940
+ }
941
+ ```
942
+
943
+ Add a progress test in `__tests__/app.test.ts`:
944
+
945
+ ```ts
946
+ test('progress status is visible once in interactive and verbose modes', () => {
947
+ const interactiveLines: string[] = []
948
+ const verboseLines: string[] = []
949
+ createProgress({ mode: 'interactive', write: (_target, message) => interactiveLines.push(message) })
950
+ .status('Waiting for Docker daemon')
951
+ createProgress({ mode: 'verbose', write: (_target, message) => verboseLines.push(message) })
952
+ .status('Waiting for Docker daemon')
953
+
954
+ assert.strictEqual(interactiveLines.length, 1)
955
+ assert.deepStrictEqual(verboseLines, ['Waiting for Docker daemon'])
956
+ })
957
+ ```
958
+
959
+ - [ ] **Step 6: Implement the shared CLI preflight adapter**
960
+
961
+ Add beside `runSetupPreflight` in `src/main.ts`:
962
+
963
+ ```ts
964
+ function runtimeTransitionMessage (probe: ContainerRuntimeProbe): string | undefined {
965
+ if (probe.state === 'waiting' && probe.failure.reason === 'docker-daemon-unavailable') {
966
+ return 'Waiting for Docker daemon'
967
+ }
968
+ if (probe.state === 'waiting' && probe.failure.reason === 'buildx-builder-unavailable') {
969
+ return 'Waiting for Docker Buildx builder'
970
+ }
971
+ return undefined
972
+ }
973
+
974
+ export async function runContainerRuntimePreflight (
975
+ context: WorkspaceContext,
976
+ progress: ProgressReporter,
977
+ options: RunCliOptions,
978
+ logger?: WorkspaceCommandLogger
979
+ ): Promise<void> {
980
+ const wait = options.waitForContainerRuntime ?? waitForContainerRuntime
981
+ progress.startStep('container-runtime')
982
+ const result = await wait({
983
+ runCommand: async (command, args) => runBuffered(command, args, {
984
+ env: options.env,
985
+ logger,
986
+ mirrorStdout: false,
987
+ mirrorStderr: false
988
+ }),
989
+ onTransition: (probe) => {
990
+ const message = runtimeTransitionMessage(probe)
991
+ if (message !== undefined) progress.status(message)
992
+ }
993
+ })
994
+
995
+ if (result.state === 'failed') {
996
+ progress.failStep('container-runtime')
997
+ throw new Error(formatContainerRuntimeFailure(result, {
998
+ logPath: logger === undefined ? undefined : context.workspaceLogPath
999
+ }))
1000
+ }
1001
+
1002
+ for (const warning of result.warnings) progress.warn(warning)
1003
+ progress.completeStep('container-runtime')
1004
+ }
1005
+
1006
+ export async function prepareContainerLifecycle (
1007
+ context: WorkspaceContext,
1008
+ alias: string,
1009
+ progress: ProgressReporter,
1010
+ options: RunCliOptions,
1011
+ logger?: WorkspaceCommandLogger
1012
+ ): Promise<void> {
1013
+ await runContainerRuntimePreflight(context, progress, options, logger)
1014
+ const writeMetadata = options.writeWorkspaceMetadata ?? writeWorkspaceMetadata
1015
+ writeMetadata(context, alias)
1016
+ }
1017
+ ```
1018
+
1019
+ - [ ] **Step 7: Test readiness/metadata ordering and recovery without Docker**
1020
+
1021
+ Import `prepareContainerLifecycle` from `src/main.ts` and `workspaceMetadataPath` from `src/metadata.ts` in `__tests__/app.test.ts`, then add:
1022
+
1023
+ ```ts
1024
+ test('container lifecycle writes metadata only after readiness succeeds', async () => {
1025
+ const context = createWorkspaceContext({
1026
+ workspace: tempDir('container-lifecycle-order-workspace'),
1027
+ env: { BOXDOWN_CACHE_HOME: tempDir('container-lifecycle-order-cache'), BOXDOWN_DATA_HOME: tempDir('container-lifecycle-order-data') },
1028
+ assetsDevcontainerDir
1029
+ })
1030
+ const progress = createProgress({ mode: 'none' })
1031
+ progress.setSteps([{ id: 'container-runtime', label: 'Checking container runtime' }])
1032
+ const calls: string[] = []
1033
+
1034
+ await prepareContainerLifecycle(context, 'boxdown-order', progress, {
1035
+ waitForContainerRuntime: async () => {
1036
+ calls.push('runtime')
1037
+ return { state: 'ready', mode: 'buildx', warnings: [] }
1038
+ },
1039
+ writeWorkspaceMetadata: () => { calls.push('metadata') }
1040
+ })
1041
+
1042
+ assert.deepStrictEqual(calls, ['runtime', 'metadata'])
1043
+ })
1044
+
1045
+ test('a readiness failure leaves no state and a later attempt decides afresh', async () => {
1046
+ const context = createWorkspaceContext({
1047
+ workspace: tempDir('container-lifecycle-recovery-workspace'),
1048
+ env: { BOXDOWN_CACHE_HOME: tempDir('container-lifecycle-recovery-cache'), BOXDOWN_DATA_HOME: tempDir('container-lifecycle-recovery-data') },
1049
+ assetsDevcontainerDir
1050
+ })
1051
+ const progress = createProgress({ mode: 'none' })
1052
+ progress.setSteps([{ id: 'container-runtime', label: 'Checking container runtime' }])
1053
+ const calls: string[] = []
1054
+
1055
+ await assert.rejects(prepareContainerLifecycle(context, 'boxdown-recovery', progress, {
1056
+ waitForContainerRuntime: async () => ({
1057
+ state: 'failed',
1058
+ failure: { reason: 'docker-daemon-unavailable', command: ['docker', 'info'], detail: 'starting' },
1059
+ timedOut: true,
1060
+ timeoutMs: 60_000
1061
+ }),
1062
+ writeWorkspaceMetadata: () => { calls.push('unexpected metadata') }
1063
+ }), /Docker daemon did not become ready/)
1064
+
1065
+ assert.deepStrictEqual(calls, [])
1066
+ assert.strictEqual(existsSync(workspaceMetadataPath(context)), false)
1067
+ assert.strictEqual(existsSync(context.generatedConfigPath), false)
1068
+ assert.strictEqual(existsSync(context.sshKeyPath), false)
1069
+
1070
+ progress.setSteps([{ id: 'container-runtime', label: 'Checking container runtime' }])
1071
+ await prepareContainerLifecycle(context, 'boxdown-recovery', progress, {
1072
+ waitForContainerRuntime: async () => {
1073
+ calls.push('fresh runtime')
1074
+ return { state: 'ready', mode: 'buildx', warnings: [] }
1075
+ },
1076
+ writeWorkspaceMetadata: () => { calls.push('metadata') }
1077
+ })
1078
+
1079
+ assert.deepStrictEqual(calls, ['fresh runtime', 'metadata'])
1080
+ })
1081
+ ```
1082
+
1083
+ - [ ] **Step 8: Split setup and bring-up progress lists so readiness appears exactly once**
1084
+
1085
+ Replace the progress-step helpers in `src/main.ts` with:
1086
+
1087
+ ```ts
1088
+ function devcontainerStartProgressSteps (): ProgressStepDefinition[] {
1089
+ return [
1090
+ { id: 'ssh-identity', label: 'Preparing SSH identity' },
1091
+ { id: 'devcontainer-config', label: 'Writing generated devcontainer config' },
1092
+ { id: 'devcontainer-start', label: 'Starting devcontainer' }
1093
+ ]
1094
+ }
1095
+
1096
+ function startProgressSteps (): ProgressStepDefinition[] {
1097
+ return [
1098
+ { id: 'container-runtime', label: 'Checking container runtime' },
1099
+ ...devcontainerStartProgressSteps()
1100
+ ]
1101
+ }
1102
+
1103
+ function setupProgressSteps (targets: readonly SshConfigInstallTarget[]): ProgressStepDefinition[] {
1104
+ return [
1105
+ ...devcontainerStartProgressSteps(),
1106
+ { id: 'ssh-alias', label: 'Installing SSH alias' },
1107
+ ...targets.map((target) => ({
1108
+ id: `ssh-target:${target}`,
1109
+ label: sshTargetProgressLabel(target)
1110
+ }))
1111
+ ]
1112
+ }
1113
+
1114
+ function setupPreflightProgressSteps (): ProgressStepDefinition[] {
1115
+ return [
1116
+ { id: 'container-runtime', label: 'Checking container runtime' },
1117
+ { id: 'setup-preflight', label: 'Checking host readiness' }
1118
+ ]
1119
+ }
1120
+ ```
1121
+
1122
+ - [ ] **Step 9: Put setup readiness before doctor, prompts, and state**
1123
+
1124
+ In `runSetupPreflight`, call readiness immediately after `setSteps`, then run doctor without a duplicate runtime probe:
1125
+
1126
+ ```ts
1127
+ progress.setSteps(setupPreflightProgressSteps())
1128
+ await runContainerRuntimePreflight(context, progress, options)
1129
+ progress.startStep('setup-preflight')
1130
+ const checks = await doctor(context, {
1131
+ includeOptional: false,
1132
+ containerRuntimeReady: true
1133
+ })
1134
+ ```
1135
+
1136
+ Leave `resolveSshInstallTargets`, `writeWorkspaceMetadata`, and `setupWorkspace` after `runSetupPreflight` in `runCli`. This retains the state-free setup failure contract.
1137
+
1138
+ Update the existing setup-warning test to inject a ready waiter so it remains independent of the host Docker daemon:
1139
+
1140
+ ```ts
1141
+ waitForContainerRuntime: async () => ({ state: 'ready', mode: 'buildx', warnings: [] })
1142
+ ```
1143
+
1144
+ - [ ] **Step 10: Move metadata into each gated non-setup branch after readiness**
1145
+
1146
+ Delete the early generic metadata block near the start of `runCli`:
1147
+
1148
+ ```ts
1149
+ if (parsed.command !== 'ssh-install' && parsed.command !== 'setup' && parsed.command !== 'tunnel' && commandWritesWorkspaceMetadata(parsed.command)) {
1150
+ writeWorkspaceMetadata(context, alias)
1151
+ }
1152
+ ```
1153
+
1154
+ In each gated branch, insert the same line immediately after `progress.setSteps(...)` and before SSH configuration, `startDevcontainer`, generated config, or identity work:
1155
+
1156
+ ```ts
1157
+ await prepareContainerLifecycle(context, alias, progress, options, logger)
1158
+ ```
1159
+
1160
+ The exact insertion points are:
1161
+
1162
+ - `ssh-proxy`: after `progress.setSteps(sshProxyProgressSteps())`, before `progress.startStep('ssh-alias')`.
1163
+ - `tunnel`: after `progress.setSteps(tunnelProgressSteps())`, before `progress.startStep('ssh-alias')`; delete the earlier `writeWorkspaceMetadata(context, alias)` before `runLoggedLifecycle`.
1164
+ - `refresh-gh-token`: after `progress.setSteps(ghAuthProgressSteps(true))`, before `startDevcontainer`.
1165
+ - `coding-agent`: after `progress.setSteps(codingAgentProgressSteps(agent))`, before `startDevcontainer`.
1166
+ - default `start`/`shell`: after `progress.setSteps(startProgressSteps())`, before `startDevcontainer`.
1167
+
1168
+ Do not add either line to `refresh-gh-token-running`. Leave `ssh install`'s explicit metadata write unchanged because it is an SSH-only inventory operation, not a container bring-up.
1169
+
1170
+ - [ ] **Step 11: Add structural ordering and exclusion assertions**
1171
+
1172
+ Add a source-level regression test beside the existing helper-source assertions. It is intentionally narrow: it protects the central ordering without trying to launch Docker.
1173
+
1174
+ ```ts
1175
+ test('gated lifecycle branches run readiness before metadata and container start', () => {
1176
+ const source = readFileSync(fileURLToPath(new URL('../src/main.ts', import.meta.url)), 'utf8')
1177
+ const gatedInsert = 'await prepareContainerLifecycle(context, alias, progress, options, logger)'
1178
+
1179
+ assert.strictEqual(source.split(gatedInsert).length - 1, 5)
1180
+ const runningBranch = source.slice(
1181
+ source.indexOf("if (parsed.command === 'refresh-gh-token-running')"),
1182
+ source.indexOf("if (parsed.command === 'refresh-gh-token')")
1183
+ )
1184
+ assert.doesNotMatch(runningBranch, /prepareContainerLifecycle|runContainerRuntimePreflight|writeWorkspaceMetadata/)
1185
+ })
1186
+ ```
1187
+
1188
+ Keep the runtime-failure setup assertion from Step 2 and the direct ordering/recovery assertions from Step 7. Together they prove setup is state-free, non-setup metadata follows readiness, and an earlier failure does not create a persistent setup prerequisite.
1189
+
1190
+ - [ ] **Step 12: Verify CLI lifecycle behavior**
1191
+
1192
+ Run:
1193
+
1194
+ ```sh
1195
+ node --import tsx --test --test-name-pattern="container runtime|setup preflight|later attempt|gated lifecycle" __tests__/app.test.ts
1196
+ pnpm test
1197
+ pnpm lint
1198
+ pnpm build
1199
+ ```
1200
+
1201
+ Expected: all commands exit 0. No test invokes real Docker or waits for real time.
1202
+
1203
+ - [ ] **Step 13: Commit lifecycle gating**
1204
+
1205
+ ```sh
1206
+ git add src/main.ts src/progress.ts __tests__/app.test.ts
1207
+ git commit -m "fix: gate container lifecycles on runtime readiness"
1208
+ ```
1209
+
1210
+ ### Task 5: Preserve useful Dev Containers diagnostics and log paths
1211
+
1212
+ **Files:**
1213
+
1214
+ - Modify: `src/progress.ts:508-594`
1215
+ - Modify: `src/devcontainer.ts:391,506,593`
1216
+ - Modify: `__tests__/app.test.ts:3560-3745`
1217
+
1218
+ **Interfaces:**
1219
+
1220
+ - Consumes: existing `CommandResult` and `WorkspaceContext.workspaceLogPath`.
1221
+ - Produces: `CommandFailureOptions { tailLines?: number, logPath?: string }`; `formatCommandFailure` and `assertProgressCommandSucceeded` both accept it.
1222
+ - A JSON line is a Dev Containers wrapper only when it parses to an object whose `outcome` is `error` and whose `message` is a string.
1223
+
1224
+ - [ ] **Step 1: Add concise failure-formatting regression tests**
1225
+
1226
+ Add to the progress/failure describe block in `__tests__/app.test.ts`:
1227
+
1228
+ ```ts
1229
+ test('zero failure-tail budget emits no output tails', () => {
1230
+ const message = formatCommandFailure('demo', {
1231
+ code: 1,
1232
+ stdout: 'stdout detail\n',
1233
+ stderr: 'stderr detail\n'
1234
+ }, { tailLines: 0 })
1235
+
1236
+ assert.doesNotMatch(message, /stdout tail|stderr tail|stdout detail|stderr detail/)
1237
+ })
1238
+
1239
+ test('specific stderr wins over a generic Dev Containers wrapper', () => {
1240
+ const wrapper = JSON.stringify({
1241
+ outcome: 'error',
1242
+ message: 'Command failed: docker buildx build --load',
1243
+ description: 'An error occurred setting up the container.'
1244
+ })
1245
+ const message = formatCommandFailure('devcontainer up', {
1246
+ code: 1,
1247
+ stdout: `${wrapper}\n`,
1248
+ stderr: 'failed to solve: registry authentication failed\n'
1249
+ }, { logPath: '/tmp/workspace/boxdown.log' })
1250
+
1251
+ assert.match(message, /registry authentication failed/)
1252
+ assert.doesNotMatch(message, /docker buildx build --load/)
1253
+ assert.match(message, /Command log: \/tmp\/workspace\/boxdown\.log/)
1254
+ })
1255
+
1256
+ test('wrapper-only failures explain the missing nested diagnostic', () => {
1257
+ const wrapper = JSON.stringify({
1258
+ outcome: 'error',
1259
+ message: 'Command failed: docker buildx build --load',
1260
+ description: 'An error occurred setting up the container.'
1261
+ })
1262
+ const message = formatCommandFailure('devcontainer up', {
1263
+ code: 1,
1264
+ stdout: `${wrapper}\n`,
1265
+ stderr: ''
1266
+ })
1267
+
1268
+ assert.match(message, /nested command failure without diagnostic output/)
1269
+ assert.doesNotMatch(message, /docker buildx build --load/)
1270
+ })
1271
+ ```
1272
+
1273
+ - [ ] **Step 2: Run the formatting tests and confirm current behavior fails**
1274
+
1275
+ Run:
1276
+
1277
+ ```sh
1278
+ node --import tsx --test --test-name-pattern="zero failure-tail|generic Dev Containers wrapper|wrapper-only" __tests__/app.test.ts
1279
+ ```
1280
+
1281
+ Expected: FAIL because `.slice(-0)` returns all lines, wrapper JSON is printed, and no log path is supported.
1282
+
1283
+ - [ ] **Step 3: Replace tail selection and wrapper detection**
1284
+
1285
+ In `src/progress.ts`, replace `tailLines` and `formatCommandFailure` with:
1286
+
1287
+ ```ts
1288
+ export interface CommandFailureOptions {
1289
+ tailLines?: number
1290
+ logPath?: string
1291
+ }
1292
+
1293
+ function outputLines (output: string): string[] {
1294
+ return output
1295
+ .split(/\r?\n/u)
1296
+ .map((line) => line.trimEnd())
1297
+ .filter((line) => line.trim().length > 0)
1298
+ }
1299
+
1300
+ function tailLines (output: string, maxLines: number): string[] {
1301
+ if (maxLines <= 0) return []
1302
+ return outputLines(output).slice(-maxLines)
1303
+ }
1304
+
1305
+ function isDevcontainerErrorEnvelope (line: string): boolean {
1306
+ try {
1307
+ const value = JSON.parse(line) as { outcome?: unknown, message?: unknown }
1308
+ return value !== null && typeof value === 'object' &&
1309
+ value.outcome === 'error' && typeof value.message === 'string'
1310
+ } catch {
1311
+ return false
1312
+ }
1313
+ }
1314
+
1315
+ export function formatCommandFailure (
1316
+ label: string,
1317
+ result: CommandResult,
1318
+ options: CommandFailureOptions = {}
1319
+ ): string {
1320
+ const maxLines = options.tailLines ?? DEFAULT_FAILURE_TAIL_LINES
1321
+ const stderrTail = tailLines(outputWithoutProgressMarkers(result.stderr), maxLines)
1322
+ const stdoutLines = outputLines(outputWithoutProgressMarkers(result.stdout))
1323
+ const wrapperPresent = stdoutLines.some(isDevcontainerErrorEnvelope)
1324
+ const specificStdout = stdoutLines.filter((line) => !isDevcontainerErrorEnvelope(line))
1325
+ const stdoutBudget = Math.max(0, maxLines - stderrTail.length)
1326
+ const stdoutTail = stdoutBudget === 0 ? [] : specificStdout.slice(-stdoutBudget)
1327
+ const lines = [
1328
+ `${label} failed with exit code ${result.code}.`,
1329
+ 'Rerun with --verbose to see full command output.'
1330
+ ]
1331
+
1332
+ if (stderrTail.length > 0) lines.push('', 'stderr tail:', ...stderrTail.map((line) => ` ${line}`))
1333
+ if (stdoutTail.length > 0) lines.push('', 'stdout tail:', ...stdoutTail.map((line) => ` ${line}`))
1334
+ if (wrapperPresent && stderrTail.length === 0 && stdoutTail.length === 0) {
1335
+ lines.push('', 'The Dev Containers CLI reported a nested command failure without diagnostic output.')
1336
+ }
1337
+ if (options.logPath !== undefined) lines.push('', `Command log: ${options.logPath}`)
1338
+
1339
+ return lines.join('\n')
1340
+ }
1341
+ ```
1342
+
1343
+ - [ ] **Step 4: Forward formatting options from command assertions**
1344
+
1345
+ Replace `assertProgressCommandSucceeded` with:
1346
+
1347
+ ```ts
1348
+ export function assertProgressCommandSucceeded (
1349
+ label: string,
1350
+ result: CommandResult,
1351
+ message: string,
1352
+ options: CommandFailureOptions = {}
1353
+ ): void {
1354
+ if (result.code !== 0) {
1355
+ throw new Error(`${message}\n${formatCommandFailure(label, result, options)}`)
1356
+ }
1357
+ }
1358
+ ```
1359
+
1360
+ - [ ] **Step 5: Attach the workspace log to all logged devcontainer helper failures**
1361
+
1362
+ Change the three progress-mode assertions in `src/devcontainer.ts` to pass the same fourth argument:
1363
+
1364
+ ```ts
1365
+ { logPath: context.workspaceLogPath }
1366
+ ```
1367
+
1368
+ For example, the bring-up assertion becomes:
1369
+
1370
+ ```ts
1371
+ assertProgressCommandSucceeded(
1372
+ 'devcontainer up',
1373
+ result,
1374
+ `devcontainer up failed for ${context.workspaceFolder}`,
1375
+ { logPath: context.workspaceLogPath }
1376
+ )
1377
+ ```
1378
+
1379
+ Apply the same formatting to `prepare SSH runtime` and `prepare ${codingAgentBinary(agent)}`. Do not change the non-progress branches in this task.
1380
+
1381
+ - [ ] **Step 6: Assert a post-readiness Dev Containers failure is not retried**
1382
+
1383
+ Add a source-boundary test in `__tests__/app.test.ts`:
1384
+
1385
+ ```ts
1386
+ test('devcontainer up remains a single-attempt operation', () => {
1387
+ const source = readFileSync(fileURLToPath(new URL('../src/devcontainer.ts', import.meta.url)), 'utf8')
1388
+ const start = source.indexOf('export async function startDevcontainer')
1389
+ const end = source.indexOf('export async function printPortHint')
1390
+ const implementation = source.slice(start, end)
1391
+
1392
+ assert.strictEqual(implementation.match(/runProgressCommand\('devcontainer up'/g)?.length, 1)
1393
+ assert.doesNotMatch(implementation, /retry|waitForContainerRuntime/)
1394
+ })
1395
+ ```
1396
+
1397
+ - [ ] **Step 7: Run formatting, logging, and full verification tests**
1398
+
1399
+ Run:
1400
+
1401
+ ```sh
1402
+ node --import tsx --test --test-name-pattern="failure-tail|Dev Containers wrapper|wrapper-only|single-attempt|command logging" __tests__/app.test.ts
1403
+ pnpm test
1404
+ pnpm lint
1405
+ pnpm build
1406
+ ```
1407
+
1408
+ Expected: all commands exit 0. The existing redaction test still proves secrets are absent from the managed log.
1409
+
1410
+ - [ ] **Step 8: Commit failure reporting**
1411
+
1412
+ ```sh
1413
+ git add src/progress.ts src/devcontainer.ts __tests__/app.test.ts
1414
+ git commit -m "fix: surface actionable devcontainer failures"
1415
+ ```
1416
+
1417
+ ### Task 6: Document lifecycle recovery and run release-quality verification
1418
+
1419
+ **Files:**
1420
+
1421
+ - Modify: `README.md:164-190`
1422
+ - Modify: `docs/features/setup.md`
1423
+ - Modify: `docs/features/start-and-shell.md`
1424
+ - Modify: `docs/features/lifecycle.md`
1425
+
1426
+ **Interfaces:**
1427
+
1428
+ - Consumes: the final CLI behavior from Tasks 1-5.
1429
+ - Produces: user-facing lifecycle guarantees and troubleshooting commands; no code interface.
1430
+
1431
+ - [ ] **Step 1: Update the README readiness section**
1432
+
1433
+ Add this text to the README near the setup/start workflow:
1434
+
1435
+ ```markdown
1436
+ `boxdown start` is standalone: it can create or reuse the devcontainer even if
1437
+ `boxdown setup` was skipped or its preflight failed. Setup-only SSH aliases and
1438
+ Codex/Claude application integrations are still installed only by `setup`.
1439
+
1440
+ Before a command creates or starts a container, Boxdown waits up to 60 seconds
1441
+ for the Docker daemon and the selected Docker Buildx builder. If Buildx is not
1442
+ installed, the bundled Dev Containers CLI uses its supported classic-build
1443
+ fallback and Boxdown continues with a warning. Boxdown does not retry an actual
1444
+ Dev Containers build failure.
1445
+ ```
1446
+
1447
+ - [ ] **Step 2: Update setup and start feature pages**
1448
+
1449
+ Add this contract to `docs/features/setup.md`:
1450
+
1451
+ ```markdown
1452
+ Setup readiness runs before prompts or workspace state is written. A missing
1453
+ Docker CLI fails immediately; a starting Docker daemon or discoverable Buildx
1454
+ builder is polled once per second for up to 60 seconds. If this preflight fails,
1455
+ setup leaves no workspace metadata, generated devcontainer config, or SSH key.
1456
+ ```
1457
+
1458
+ Add this contract to `docs/features/start-and-shell.md`:
1459
+
1460
+ ```markdown
1461
+ Start does not require a completed setup. It performs a fresh runtime-readiness
1462
+ check, then writes workspace inventory metadata and creates the generated state
1463
+ needed for the devcontainer. It does not install the setup-only SSH alias or
1464
+ external application integrations.
1465
+ ```
1466
+
1467
+ - [ ] **Step 3: Add lifecycle troubleshooting guidance**
1468
+
1469
+ Add this section to `docs/features/lifecycle.md`:
1470
+
1471
+ ```markdown
1472
+ ## Container runtime readiness
1473
+
1474
+ Commands that may create or start a devcontainer wait for Docker before writing
1475
+ workspace metadata. Docker daemon and Buildx builder startup races are retried
1476
+ for up to 60 seconds; the underlying `devcontainer up` command is still run only
1477
+ once after readiness succeeds.
1478
+
1479
+ For a daemon timeout, run `docker info`. For a Buildx timeout, run
1480
+ `docker buildx inspect`. Logged lifecycle errors also print the workspace
1481
+ command-log path, which contains full redacted stdout and stderr. A generic Dev
1482
+ Containers JSON error without nested output means the command log is the next
1483
+ place to inspect.
1484
+ ```
1485
+
1486
+ - [ ] **Step 4: Run documentation lint and the complete project checks**
1487
+
1488
+ Run:
1489
+
1490
+ ```sh
1491
+ pnpm run lint:markdown
1492
+ pnpm test
1493
+ pnpm lint
1494
+ pnpm build
1495
+ git diff --check
1496
+ ```
1497
+
1498
+ Expected: all commands exit 0 with no lint, test, type/build, or whitespace errors.
1499
+
1500
+ - [ ] **Step 5: Inspect the final diff for scope and state guarantees**
1501
+
1502
+ Run:
1503
+
1504
+ ```sh
1505
+ git diff --stat
1506
+ git diff -- src/container-runtime.ts src/doctor.ts src/main.ts src/progress.ts src/devcontainer.ts __tests__/container-runtime.test.ts __tests__/app.test.ts README.md docs/features/setup.md docs/features/start-and-shell.md docs/features/lifecycle.md
1507
+ ```
1508
+
1509
+ Confirm all of the following from the diff:
1510
+
1511
+ - `devcontainer up` has no retry loop.
1512
+ - `refresh-gh-token-running` has no readiness waiter.
1513
+ - Setup readiness precedes prompts and every state write.
1514
+ - Every gated non-setup branch writes metadata only after readiness.
1515
+ - No metadata schema field or migration was added.
1516
+ - No dependency or Dev Container image/Feature version changed.
1517
+
1518
+ - [ ] **Step 6: Commit documentation**
1519
+
1520
+ ```sh
1521
+ git add README.md docs/features/setup.md docs/features/start-and-shell.md docs/features/lifecycle.md
1522
+ git commit -m "docs: explain container runtime readiness"
1523
+ ```
1524
+
1525
+ - [ ] **Step 7: Record final verification evidence**
1526
+
1527
+ Run once more from a clean worktree:
1528
+
1529
+ ```sh
1530
+ git status --short
1531
+ pnpm test
1532
+ pnpm lint
1533
+ pnpm build
1534
+ ```
1535
+
1536
+ Expected: `git status --short` prints nothing, and all three pnpm commands exit 0. Record the passing test count and command outputs in the implementation handoff or pull-request description.