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,295 @@
1
+ import { performance } from 'node:perf_hooks'
2
+
3
+ import { runBuffered, type CommandResult } from './process.ts'
4
+
5
+ export type ContainerRuntimeReason =
6
+ | 'docker-cli-unavailable'
7
+ | 'docker-daemon-unavailable'
8
+ | 'buildx-builder-unavailable'
9
+
10
+ export type ContainerRuntimeMode = 'buildx' | 'fallback'
11
+ export type ContainerRuntimeCommandResult = CommandResult
12
+ export type ContainerRuntimeCommandRunner = (
13
+ command: string,
14
+ args: string[],
15
+ timeoutMs?: number
16
+ ) => Promise<ContainerRuntimeCommandResult>
17
+
18
+ export interface ContainerRuntimeFailure {
19
+ reason: ContainerRuntimeReason
20
+ command: string[]
21
+ detail: string
22
+ timedOut?: true
23
+ }
24
+
25
+ export type ContainerRuntimeProbe =
26
+ | { state: 'ready', mode: ContainerRuntimeMode, warnings: string[] }
27
+ | { state: 'waiting', failure: ContainerRuntimeFailure }
28
+ | { state: 'failed', failure: ContainerRuntimeFailure }
29
+
30
+ const BUILDX_FALLBACK_WARNING = 'Docker Buildx is unavailable; the Dev Containers CLI will use its classic-build fallback.'
31
+
32
+ async function runContainerRuntimeCommand (
33
+ command: string,
34
+ args: string[],
35
+ timeoutMs?: number
36
+ ): Promise<ContainerRuntimeCommandResult> {
37
+ return runBuffered(command, args, {
38
+ mirrorStdout: false,
39
+ mirrorStderr: false,
40
+ timeoutMs
41
+ })
42
+ }
43
+
44
+ function compactCommandOutput (result: ContainerRuntimeCommandResult): string {
45
+ const output = result.stderr.trim().length > 0 ? result.stderr : result.stdout
46
+ const compact = output.trim().replace(/\s+/gu, ' ').slice(0, 500)
47
+ return compact.length > 0 ? compact : `Command exited with code ${result.code}`
48
+ }
49
+
50
+ function failure (
51
+ reason: ContainerRuntimeReason,
52
+ command: string[],
53
+ result: ContainerRuntimeCommandResult
54
+ ): ContainerRuntimeFailure {
55
+ return {
56
+ reason,
57
+ command,
58
+ detail: compactCommandOutput(result),
59
+ ...(result.timedOut === true ? { timedOut: true as const } : {})
60
+ }
61
+ }
62
+
63
+ export async function probeContainerRuntime (
64
+ runCommand: ContainerRuntimeCommandRunner = runContainerRuntimeCommand
65
+ ): Promise<ContainerRuntimeProbe> {
66
+ const dockerVersionCommand = ['docker', '--version']
67
+ const dockerVersion = await runCommand(dockerVersionCommand[0] as string, dockerVersionCommand.slice(1))
68
+ if (dockerVersion.code !== 0) {
69
+ return {
70
+ state: 'failed',
71
+ failure: failure('docker-cli-unavailable', dockerVersionCommand, dockerVersion)
72
+ }
73
+ }
74
+
75
+ const dockerInfoCommand = ['docker', 'info']
76
+ const dockerInfo = await runCommand(dockerInfoCommand[0] as string, dockerInfoCommand.slice(1))
77
+ if (dockerInfo.code !== 0) {
78
+ return {
79
+ state: 'waiting',
80
+ failure: failure('docker-daemon-unavailable', dockerInfoCommand, dockerInfo)
81
+ }
82
+ }
83
+
84
+ const buildxVersionCommand = ['docker', 'buildx', 'version']
85
+ const buildxVersion = await runCommand(buildxVersionCommand[0] as string, buildxVersionCommand.slice(1))
86
+ if (buildxVersion.timedOut === true) {
87
+ return {
88
+ state: 'waiting',
89
+ failure: failure('buildx-builder-unavailable', buildxVersionCommand, buildxVersion)
90
+ }
91
+ }
92
+ if (buildxVersion.code !== 0) {
93
+ return {
94
+ state: 'ready',
95
+ mode: 'fallback',
96
+ warnings: [BUILDX_FALLBACK_WARNING]
97
+ }
98
+ }
99
+
100
+ const buildxInspectCommand = ['docker', 'buildx', 'inspect', '--bootstrap']
101
+ const buildxInspect = await runCommand(buildxInspectCommand[0] as string, buildxInspectCommand.slice(1))
102
+ if (buildxInspect.code !== 0) {
103
+ return {
104
+ state: 'waiting',
105
+ failure: failure('buildx-builder-unavailable', buildxInspectCommand, buildxInspect)
106
+ }
107
+ }
108
+
109
+ return { state: 'ready', mode: 'buildx', warnings: [] }
110
+ }
111
+
112
+ export type ContainerRuntimeWaitResult =
113
+ | { state: 'ready', mode: ContainerRuntimeMode, warnings: string[] }
114
+ | {
115
+ state: 'failed'
116
+ failure: ContainerRuntimeFailure
117
+ timedOut: boolean
118
+ timeoutMs: number
119
+ }
120
+
121
+ export interface WaitForContainerRuntimeOptions {
122
+ runCommand?: ContainerRuntimeCommandRunner
123
+ onTransition?: (probe: ContainerRuntimeProbe) => void
124
+ }
125
+
126
+ /** @internal */
127
+ export interface InternalContainerRuntimeWaitOptions extends WaitForContainerRuntimeOptions {
128
+ timeoutMs?: number
129
+ pollIntervalMs?: number
130
+ now?: () => number
131
+ sleep?: (milliseconds: number) => Promise<void>
132
+ }
133
+
134
+ function defaultSleep (milliseconds: number): Promise<void> {
135
+ return new Promise((resolve) => setTimeout(resolve, milliseconds))
136
+ }
137
+
138
+ function transitionKey (probe: ContainerRuntimeProbe): string {
139
+ return probe.state === 'ready' ? 'ready' : `${probe.state}:${probe.failure.reason}`
140
+ }
141
+
142
+ class ContainerRuntimeDeadlineError extends Error {
143
+ readonly command: string[]
144
+
145
+ constructor (command: string[]) {
146
+ super(`Container runtime deadline expired before ${command.join(' ')}`)
147
+ this.command = command
148
+ }
149
+ }
150
+
151
+ function deadlineFailure (command: string[]): ContainerRuntimeFailure {
152
+ const reason: ContainerRuntimeReason = command[1] === 'info'
153
+ ? 'docker-daemon-unavailable'
154
+ : command[1] === 'buildx'
155
+ ? 'buildx-builder-unavailable'
156
+ : 'docker-cli-unavailable'
157
+
158
+ return {
159
+ reason,
160
+ command,
161
+ detail: `Readiness deadline expired before ${command.join(' ')} could start.`,
162
+ timedOut: true
163
+ }
164
+ }
165
+
166
+ /** @internal */
167
+ export async function waitForContainerRuntimeInternal (
168
+ options: InternalContainerRuntimeWaitOptions = {}
169
+ ): Promise<ContainerRuntimeWaitResult> {
170
+ const timeoutMs = options.timeoutMs ?? 60_000
171
+ const pollIntervalMs = options.pollIntervalMs ?? 1_000
172
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 60_000) {
173
+ throw new Error('timeoutMs must be a finite number between 0 and 60000 milliseconds')
174
+ }
175
+ if (pollIntervalMs !== 1_000) {
176
+ throw new Error('pollIntervalMs must be exactly 1000 milliseconds')
177
+ }
178
+ const now = options.now ?? Date.now
179
+ const sleep = options.sleep ?? defaultSleep
180
+ const deadline = now() + timeoutMs
181
+ let lastTransition: string | undefined
182
+ let lastWaitingFailure: ContainerRuntimeFailure | undefined
183
+
184
+ while (true) {
185
+ if (lastWaitingFailure !== undefined && deadline - now() <= 0) {
186
+ return { state: 'failed', failure: lastWaitingFailure, timedOut: true, timeoutMs }
187
+ }
188
+
189
+ const runCommand = options.runCommand ?? runContainerRuntimeCommand
190
+ let lastStartedCommand: string[] | undefined
191
+ let probe: ContainerRuntimeProbe
192
+ try {
193
+ probe = await probeContainerRuntime(async (command, args) => {
194
+ const commandArgs = [command, ...args]
195
+ const remainingMs = Math.floor(deadline - now())
196
+ if (remainingMs <= 0) throw new ContainerRuntimeDeadlineError(commandArgs)
197
+ lastStartedCommand = commandArgs
198
+ return runCommand(command, args, remainingMs)
199
+ })
200
+ } catch (error) {
201
+ if (!(error instanceof ContainerRuntimeDeadlineError)) throw error
202
+ return {
203
+ state: 'failed',
204
+ failure: lastWaitingFailure ?? deadlineFailure(error.command),
205
+ timedOut: true,
206
+ timeoutMs
207
+ }
208
+ }
209
+
210
+ if (deadline - now() <= 0) {
211
+ const currentFailure = probe.state === 'ready'
212
+ ? deadlineFailure(lastStartedCommand ?? ['docker', '--version'])
213
+ : probe.failure
214
+ return {
215
+ state: 'failed',
216
+ failure: lastWaitingFailure ?? currentFailure,
217
+ timedOut: true,
218
+ timeoutMs
219
+ }
220
+ }
221
+
222
+ if (probe.state === 'ready') return probe
223
+
224
+ const currentTransition = transitionKey(probe)
225
+ if (currentTransition !== lastTransition) {
226
+ options.onTransition?.(probe)
227
+ lastTransition = currentTransition
228
+ }
229
+
230
+ if (probe.state === 'failed') {
231
+ return {
232
+ state: 'failed',
233
+ failure: probe.failure,
234
+ timedOut: probe.failure.timedOut === true,
235
+ timeoutMs
236
+ }
237
+ }
238
+
239
+ lastWaitingFailure = probe.failure
240
+
241
+ const remainingMs = deadline - now()
242
+ if (remainingMs <= 0) {
243
+ return { state: 'failed', failure: probe.failure, timedOut: true, timeoutMs }
244
+ }
245
+
246
+ await sleep(Math.min(pollIntervalMs, remainingMs))
247
+ }
248
+ }
249
+
250
+ export async function waitForContainerRuntime (
251
+ options: WaitForContainerRuntimeOptions = {}
252
+ ): Promise<ContainerRuntimeWaitResult> {
253
+ return waitForContainerRuntimeInternal({
254
+ runCommand: options.runCommand,
255
+ onTransition: options.onTransition,
256
+ timeoutMs: 60_000,
257
+ pollIntervalMs: 1_000,
258
+ now: () => performance.now(),
259
+ sleep: defaultSleep
260
+ })
261
+ }
262
+
263
+ function commandText (command: readonly string[]): string {
264
+ return command.join(' ')
265
+ }
266
+
267
+ export function formatContainerRuntimeFailure (
268
+ result: Extract<ContainerRuntimeWaitResult, { state: 'failed' }>,
269
+ options: { logPath?: string } = {}
270
+ ): string {
271
+ const seconds = result.timeoutMs / 1_000
272
+ const descriptions: Record<ContainerRuntimeReason, string> = {
273
+ 'docker-cli-unavailable': 'Docker CLI is required but was not available.',
274
+ 'docker-daemon-unavailable': result.timedOut
275
+ ? `Docker daemon did not become ready within ${seconds} seconds.`
276
+ : 'Docker daemon is required but was not reachable.',
277
+ 'buildx-builder-unavailable': result.timedOut
278
+ ? `Docker Buildx builder did not become ready within ${seconds} seconds.`
279
+ : 'Docker Buildx builder was not operational.'
280
+ }
281
+ const manualCheck = result.failure.reason === 'buildx-builder-unavailable'
282
+ ? 'docker buildx inspect'
283
+ : result.failure.reason === 'docker-daemon-unavailable'
284
+ ? 'docker info'
285
+ : 'docker --version'
286
+ const lines = [
287
+ descriptions[result.failure.reason],
288
+ `Last check: ${commandText(result.failure.command)}`,
289
+ `Detail: ${result.failure.detail}`,
290
+ `Check ${result.failure.reason === 'buildx-builder-unavailable' ? 'Buildx' : 'Docker'} with: ${manualCheck}`
291
+ ]
292
+
293
+ if (options.logPath !== undefined) lines.push(`Command log: ${options.logPath}`)
294
+ return lines.join('\n')
295
+ }
@@ -21,6 +21,7 @@ export interface StartOptions {
21
21
  progress?: ProgressReporter
22
22
  logger?: WorkspaceCommandLogger
23
23
  reuseRunning?: boolean
24
+ runDevcontainerUp?: typeof runProgressCommand
24
25
  }
25
26
 
26
27
  export interface ContainerCommandOptions {
@@ -374,7 +375,7 @@ export async function startDevcontainer (context: WorkspaceContext, options: Sta
374
375
  mirrorStderr: 'stderr',
375
376
  logger: options.logger
376
377
  })
377
- : await runProgressCommand('devcontainer up', cli.command, [...cli.argsPrefix, ...args], {
378
+ : await (options.runDevcontainerUp ?? runProgressCommand)('devcontainer up', cli.command, [...cli.argsPrefix, ...args], {
378
379
  progress,
379
380
  spinnerLabel: 'Starting devcontainer',
380
381
  stepId: 'devcontainer-start',
@@ -388,7 +389,12 @@ export async function startDevcontainer (context: WorkspaceContext, options: Sta
388
389
  }
389
390
 
390
391
  if (progress !== undefined) {
391
- assertProgressCommandSucceeded('devcontainer up', result, `devcontainer up failed for ${context.workspaceFolder}`)
392
+ assertProgressCommandSucceeded(
393
+ 'devcontainer up',
394
+ result,
395
+ `devcontainer up failed for ${context.workspaceFolder}`,
396
+ { logPath: options.logger?.logPath }
397
+ )
392
398
  }
393
399
 
394
400
  const containerId = parseContainerIdFromUpOutput(`${result.stdout}\n${result.stderr}`) ?? await findRunningContainerId(context, { logger: options.logger })
@@ -503,7 +509,12 @@ export async function ensureContainerSshRuntime (context: WorkspaceContext, opti
503
509
  }
504
510
 
505
511
  if (options.progress !== undefined) {
506
- assertProgressCommandSucceeded('prepare SSH runtime', result, 'Failed to prepare devcontainer SSH runtime')
512
+ assertProgressCommandSucceeded(
513
+ 'prepare SSH runtime',
514
+ result,
515
+ 'Failed to prepare devcontainer SSH runtime',
516
+ { logPath: options.logger?.logPath }
517
+ )
507
518
  }
508
519
  }
509
520
 
@@ -590,7 +601,12 @@ export async function ensureContainerCodingAgentCli (
590
601
  }
591
602
 
592
603
  if (options.progress !== undefined) {
593
- assertProgressCommandSucceeded(`prepare ${codingAgentBinary(agent)}`, result, `Could not install or refresh ${codingAgentBinary(agent)} inside the devcontainer`)
604
+ assertProgressCommandSucceeded(
605
+ `prepare ${codingAgentBinary(agent)}`,
606
+ result,
607
+ `Could not install or refresh ${codingAgentBinary(agent)} inside the devcontainer`,
608
+ { logPath: options.logger?.logPath }
609
+ )
594
610
  }
595
611
  }
596
612
 
package/src/doctor.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
 
4
+ import {
5
+ probeContainerRuntime,
6
+ type ContainerRuntimeCommandResult,
7
+ type ContainerRuntimeCommandRunner
8
+ } from './container-runtime.ts'
4
9
  import { resolveDevcontainerCli } from './devcontainer-cli.ts'
5
10
  import { buildGeneratedDevcontainerConfig, type DevcontainerConfig } from './config.ts'
6
11
  import { BOXDOWN_SECRET_ENV_NAMES } from './constants.ts'
@@ -16,17 +21,13 @@ export interface DoctorCheck {
16
21
  message: string
17
22
  }
18
23
 
19
- export interface DoctorCommandResult {
20
- code: number
21
- stdout: string
22
- stderr: string
23
- }
24
-
25
- export type DoctorCommandRunner = (command: string, args: string[]) => Promise<DoctorCommandResult>
24
+ export type DoctorCommandResult = ContainerRuntimeCommandResult
25
+ export type DoctorCommandRunner = ContainerRuntimeCommandRunner
26
26
 
27
27
  export interface RunDoctorChecksOptions {
28
28
  includeOptional?: boolean
29
29
  includeDockerMountProbe?: boolean
30
+ containerRuntimeReady?: boolean
30
31
  runCommand?: DoctorCommandRunner
31
32
  }
32
33
 
@@ -183,21 +184,46 @@ export async function runDoctorChecks (context: WorkspaceContext, options: RunDo
183
184
  'Packaged @devcontainers/cli is required but was not available'
184
185
  ))
185
186
 
186
- const dockerCliWorks = await commandWorks(runCommand, 'docker', ['--version'])
187
- checks.push(check(
188
- 'docker-cli',
189
- dockerCliWorks,
190
- 'Docker CLI is available',
191
- 'Docker CLI is required but was not available'
192
- ))
193
-
194
- const dockerDaemonWorks = await commandWorks(runCommand, 'docker', ['info'])
195
- checks.push(check(
196
- 'docker-daemon',
197
- dockerDaemonWorks,
198
- 'Docker daemon is reachable',
199
- 'Docker daemon is required but was not reachable'
200
- ))
187
+ let dockerCliWorks = options.containerRuntimeReady === true
188
+ let dockerDaemonWorks = options.containerRuntimeReady === true
189
+
190
+ if (options.containerRuntimeReady !== true) {
191
+ const runtime = await probeContainerRuntime(runCommand)
192
+ dockerCliWorks = runtime.state === 'ready' || runtime.failure.reason !== 'docker-cli-unavailable'
193
+ dockerDaemonWorks = runtime.state === 'ready' ||
194
+ (runtime.state === 'waiting' && runtime.failure.reason === 'buildx-builder-unavailable')
195
+
196
+ checks.push(check(
197
+ 'docker-cli',
198
+ dockerCliWorks,
199
+ 'Docker CLI is available',
200
+ 'Docker CLI is required but was not available'
201
+ ))
202
+ checks.push(check(
203
+ 'docker-daemon',
204
+ dockerDaemonWorks,
205
+ 'Docker daemon is reachable',
206
+ 'Docker daemon is required but was not reachable'
207
+ ))
208
+
209
+ if (!dockerCliWorks || !dockerDaemonWorks) {
210
+ checks.push({
211
+ name: 'docker-buildx',
212
+ level: 'warn',
213
+ message: 'Docker Buildx was not checked because the Docker runtime is unavailable'
214
+ })
215
+ } else if (runtime.state === 'ready' && runtime.mode === 'fallback') {
216
+ checks.push({ name: 'docker-buildx', level: 'warn', message: runtime.warnings[0] as string })
217
+ } else if (runtime.state === 'waiting') {
218
+ checks.push({
219
+ name: 'docker-buildx',
220
+ level: 'fail',
221
+ message: `Docker Buildx builder was not operational: ${runtime.failure.detail}`
222
+ })
223
+ } else {
224
+ checks.push({ name: 'docker-buildx', level: 'ok', message: 'Docker Buildx builder is operational' })
225
+ }
226
+ }
201
227
 
202
228
  checks.push(check(
203
229
  'ssh',
package/src/main.ts CHANGED
@@ -4,6 +4,7 @@ import { claudeSshConfigEntryForWorkspace, uninstallClaudeSshConfigHost } from '
4
4
  import { codexProjectEntryForWorkspace, legacyCodexRemotePathForWorkspace, uninstallCodexAppConfigProject, uninstallCodexGlobalStateProject } from './codex-app-config.ts'
5
5
  import { codingAgentBinary, codingAgentFromCommand, type CodingAgentCli } from './coding-agents.ts'
6
6
  import { buildGeneratedDevcontainerConfig, publishContainerPortFromConfig } from './config.ts'
7
+ import { formatContainerRuntimeFailure, waitForContainerRuntime, type ContainerRuntimeProbe } from './container-runtime.ts'
7
8
  import { doctorHasFailures, formatDoctorText, runDoctorChecks, type DoctorCheck } from './doctor.ts'
8
9
  import { startDevcontainer, printPortHint, openShell, openCodingAgentCli, ensureContainerSshRuntime, runSshdProxy, refreshContainerGhAuth, refreshContainerCodingAgentClis, ensureContainerCodingAgentCli, findRunningContainerId, findWorkspaceContainer, stopWorkspaceContainer, removeWorkspaceContainer, listWorkspaceContainers, openSshTunnel, type TunnelPortForward } from './devcontainer.ts'
9
10
  import { canPromptInteractively, promptConfirm, promptMultiSelect, promptText, type PromptInput, type PromptOutput } from './interactive-prompts.ts'
@@ -13,6 +14,7 @@ import { listWorkspaceMetadata, readWorkspaceMetadata, writeWorkspaceMetadata, t
13
14
  import { readPackageVersion } from './package-info.ts'
14
15
  import { createWorkspaceContext, createWorkspaceContextFromIdentity, defaultDataRoot, type WorkspaceContext } from './paths.ts'
15
16
  import { createProgress, resolveProgressMode, type ProgressReporter, type ProgressOutputTarget, type ProgressStepDefinition } from './progress.ts'
17
+ import { runBuffered } from './process.ts'
16
18
  import { purgeWorkspace, removeWorkspaceRuntimeState } from './purge.ts'
17
19
  import { defaultSshAlias, installSshConfig, uninstallSshConfig } from './ssh-config.ts'
18
20
  import { dedupeSshInstallTargets, installSshInstallTarget, isSshConfigInstallTarget, SSH_INSTALL_TARGETS, sshInstallTargetFlagHintsText, supportedSshInstallTargetsText, type SshConfigInstallTarget } from './ssh-install-targets.ts'
@@ -59,6 +61,10 @@ export interface RunCliOptions {
59
61
  env?: NodeJS.ProcessEnv
60
62
  runDoctorChecks?: typeof runDoctorChecks
61
63
  setupWorkspace?: typeof setupWorkspace
64
+ waitForContainerRuntime?: typeof waitForContainerRuntime
65
+ writeWorkspaceMetadata?: (context: WorkspaceContext, alias: string) => void
66
+ prepareContainerLifecycle?: typeof prepareContainerLifecycle
67
+ findRunningContainerId?: typeof findRunningContainerId
62
68
  }
63
69
 
64
70
  export const USAGE = `Usage:
@@ -151,11 +157,19 @@ export function commandWritesWorkspaceMetadata (command: BoxdownCommand): boolea
151
157
  'ssh-proxy',
152
158
  'tunnel',
153
159
  'refresh-gh-token',
154
- 'refresh-gh-token-running',
155
160
  'coding-agent'
156
161
  ].includes(command)
157
162
  }
158
163
 
164
+ export function commandRequiresContainerRuntime (command: BoxdownCommand): boolean {
165
+ return command === 'setup' ||
166
+ command === 'start' ||
167
+ command === 'ssh-proxy' ||
168
+ command === 'tunnel' ||
169
+ command === 'refresh-gh-token' ||
170
+ command === 'coding-agent'
171
+ }
172
+
159
173
  export function parseCliArgs (argv: string[]): ParsedCli {
160
174
  const args = [...argv]
161
175
  const workspaces: string[] = []
@@ -1057,7 +1071,7 @@ function createCliProgress (
1057
1071
  })
1058
1072
  }
1059
1073
 
1060
- function startProgressSteps (): ProgressStepDefinition[] {
1074
+ function devcontainerStartProgressSteps (): ProgressStepDefinition[] {
1061
1075
  return [
1062
1076
  { id: 'ssh-identity', label: 'Preparing SSH identity' },
1063
1077
  { id: 'devcontainer-config', label: 'Writing generated devcontainer config' },
@@ -1065,6 +1079,13 @@ function startProgressSteps (): ProgressStepDefinition[] {
1065
1079
  ]
1066
1080
  }
1067
1081
 
1082
+ function startProgressSteps (): ProgressStepDefinition[] {
1083
+ return [
1084
+ { id: 'container-runtime', label: 'Checking container runtime' },
1085
+ ...devcontainerStartProgressSteps()
1086
+ ]
1087
+ }
1088
+
1068
1089
  function sshTargetProgressLabel (target: SshConfigInstallTarget): string {
1069
1090
  const label = SSH_INSTALL_TARGETS.find((candidate) => candidate.value === target)?.label ?? target
1070
1091
  return `Installing ${label} SSH target`
@@ -1072,7 +1093,7 @@ function sshTargetProgressLabel (target: SshConfigInstallTarget): string {
1072
1093
 
1073
1094
  function setupProgressSteps (targets: readonly SshConfigInstallTarget[]): ProgressStepDefinition[] {
1074
1095
  return [
1075
- ...startProgressSteps(),
1096
+ ...devcontainerStartProgressSteps(),
1076
1097
  { id: 'ssh-alias', label: 'Installing SSH alias' },
1077
1098
  ...targets.map((target) => ({
1078
1099
  id: `ssh-target:${target}`,
@@ -1082,7 +1103,10 @@ function setupProgressSteps (targets: readonly SshConfigInstallTarget[]): Progre
1082
1103
  }
1083
1104
 
1084
1105
  function setupPreflightProgressSteps (): ProgressStepDefinition[] {
1085
- return [{ id: 'setup-preflight', label: 'Checking host readiness' }]
1106
+ return [
1107
+ { id: 'container-runtime', label: 'Checking container runtime' },
1108
+ { id: 'setup-preflight', label: 'Checking host readiness' }
1109
+ ]
1086
1110
  }
1087
1111
 
1088
1112
  function setupPreflightFailureMessage (checks: DoctorCheck[]): string {
@@ -1093,6 +1117,62 @@ function setupPreflightFailureMessage (checks: DoctorCheck[]): string {
1093
1117
  return `Setup preflight failed:\n${failures.join('\n')}`
1094
1118
  }
1095
1119
 
1120
+ function runtimeTransitionMessage (probe: ContainerRuntimeProbe): string | undefined {
1121
+ if (probe.state === 'waiting' && probe.failure.reason === 'docker-daemon-unavailable') {
1122
+ return 'Waiting for Docker daemon'
1123
+ }
1124
+ if (probe.state === 'waiting' && probe.failure.reason === 'buildx-builder-unavailable') {
1125
+ return 'Waiting for Docker Buildx builder'
1126
+ }
1127
+ return undefined
1128
+ }
1129
+
1130
+ export async function runContainerRuntimePreflight (
1131
+ context: WorkspaceContext,
1132
+ progress: ProgressReporter,
1133
+ options: RunCliOptions,
1134
+ logger?: WorkspaceCommandLogger
1135
+ ): Promise<void> {
1136
+ const wait = options.waitForContainerRuntime ?? waitForContainerRuntime
1137
+ progress.startStep('container-runtime')
1138
+ const result = await wait({
1139
+ runCommand: async (command, args, timeoutMs) => runBuffered(command, args, {
1140
+ env: options.env,
1141
+ logger,
1142
+ mirrorStdout: false,
1143
+ mirrorStderr: false,
1144
+ timeoutMs
1145
+ }),
1146
+ onTransition: (probe) => {
1147
+ const message = runtimeTransitionMessage(probe)
1148
+ if (message !== undefined) progress.status(message)
1149
+ }
1150
+ })
1151
+
1152
+ if (result.state === 'failed') {
1153
+ progress.failStep('container-runtime')
1154
+ throw new Error(formatContainerRuntimeFailure(result, {
1155
+ logPath: logger === undefined ? undefined : context.workspaceLogPath
1156
+ }))
1157
+ }
1158
+
1159
+ for (const warning of result.warnings) progress.warn(warning)
1160
+ if (progress.mode === 'verbose') progress.status('Container runtime ready')
1161
+ progress.completeStep('container-runtime')
1162
+ }
1163
+
1164
+ export async function prepareContainerLifecycle (
1165
+ context: WorkspaceContext,
1166
+ alias: string,
1167
+ progress: ProgressReporter,
1168
+ options: RunCliOptions,
1169
+ logger?: WorkspaceCommandLogger
1170
+ ): Promise<void> {
1171
+ await runContainerRuntimePreflight(context, progress, options, logger)
1172
+ const writeMetadata = options.writeWorkspaceMetadata ?? writeWorkspaceMetadata
1173
+ writeMetadata(context, alias)
1174
+ }
1175
+
1096
1176
  async function runSetupPreflight (
1097
1177
  context: WorkspaceContext,
1098
1178
  alias: string,
@@ -1107,8 +1187,12 @@ async function runSetupPreflight (
1107
1187
  `SSH alias: ${alias}`
1108
1188
  ], async () => {
1109
1189
  progress.setSteps(setupPreflightProgressSteps())
1190
+ await runContainerRuntimePreflight(context, progress, options)
1110
1191
  progress.startStep('setup-preflight')
1111
- const checks = await doctor(context, { includeOptional: false })
1192
+ const checks = await doctor(context, {
1193
+ includeOptional: false,
1194
+ containerRuntimeReady: true
1195
+ })
1112
1196
 
1113
1197
  for (const check of checks.filter((item) => item.level === 'warn')) {
1114
1198
  progress.warn(`${check.name}: ${check.message}`)
@@ -1221,10 +1305,6 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1221
1305
  const alias = parsed.alias ?? defaultSshAlias(context.workspaceBasename)
1222
1306
  const aliasSource = parsed.alias === undefined ? 'default' : 'provided'
1223
1307
 
1224
- if (parsed.command !== 'ssh-install' && parsed.command !== 'setup' && parsed.command !== 'tunnel' && commandWritesWorkspaceMetadata(parsed.command)) {
1225
- writeWorkspaceMetadata(context, alias)
1226
- }
1227
-
1228
1308
  if (parsed.command === 'ssh-install') {
1229
1309
  const resolvedTargets = await resolveSshInstallTargets(parsed, options)
1230
1310
 
@@ -1364,6 +1444,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1364
1444
  `SSH alias: ${alias}`
1365
1445
  ], async () => {
1366
1446
  progress.setSteps(sshProxyProgressSteps())
1447
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1367
1448
  progress.startStep('ssh-alias')
1368
1449
  try {
1369
1450
  await installSshConfig(context, alias, { quiet: true })
@@ -1400,7 +1481,6 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1400
1481
  throw new Error('tunnel requires at least one --port value')
1401
1482
  }
1402
1483
 
1403
- writeWorkspaceMetadata(context, alias)
1404
1484
  return runLoggedLifecycle(context, 'tunnel', argv, async (logger) => {
1405
1485
  const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1406
1486
  await withProgressSection(progress, 'Boxdown tunnel', [
@@ -1408,6 +1488,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1408
1488
  `SSH alias: ${alias}`
1409
1489
  ], async () => {
1410
1490
  progress.setSteps(tunnelProgressSteps())
1491
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1411
1492
  progress.startStep('ssh-alias')
1412
1493
  try {
1413
1494
  await installSshConfig(context, alias, { quiet: true })
@@ -1437,7 +1518,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1437
1518
 
1438
1519
  if (parsed.command === 'refresh-gh-token-running') {
1439
1520
  return runLoggedLifecycle(context, 'refresh-gh-token-running', argv, async (logger) => {
1440
- const containerId = await findRunningContainerId(context, { logger })
1521
+ const containerId = await (options.findRunningContainerId ?? findRunningContainerId)(context, { logger })
1441
1522
  if (containerId === undefined) {
1442
1523
  throw new Error(`No running devcontainer found for: ${context.workspaceFolder}`)
1443
1524
  }
@@ -1461,6 +1542,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1461
1542
  `Workspace: ${context.workspaceFolder}`
1462
1543
  ], async () => {
1463
1544
  progress.setSteps(ghAuthProgressSteps(true))
1545
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1464
1546
  await startDevcontainer(context, { progress, logger })
1465
1547
  await refreshContainerGhAuth(context, { progress, logger })
1466
1548
  })
@@ -1480,6 +1562,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1480
1562
  `Workspace: ${context.workspaceFolder}`
1481
1563
  ], async () => {
1482
1564
  progress.setSteps(codingAgentProgressSteps(agent))
1565
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1483
1566
  await startDevcontainer(context, {
1484
1567
  recreate: parsed.recreate,
1485
1568
  progress,
@@ -1497,6 +1580,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1497
1580
  `Workspace: ${context.workspaceFolder}`
1498
1581
  ], async () => {
1499
1582
  progress.setSteps(startProgressSteps())
1583
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1500
1584
  return await startDevcontainer(context, {
1501
1585
  recreate: parsed.recreate,
1502
1586
  progress,