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.
- package/README.md +10 -0
- package/assets/devcontainer/README.md +12 -1
- package/assets/devcontainer/devcontainer.json +1 -1
- package/assets/devcontainer/hooks/post-create.sh +12 -1
- package/dist/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/{main-BDgyf2t5.cjs → main-CT2n9qcb.cjs} +448 -67
- package/dist/{main-J4_2Up3o.mjs → main-O__JaiXe.mjs} +431 -68
- package/dist/main-O__JaiXe.mjs.map +1 -0
- package/dist/main.cjs +4 -1
- package/dist/main.d.cts +92 -26
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +92 -26
- package/dist/main.d.mts.map +1 -1
- package/dist/main.mjs +2 -2
- package/docs/features/lifecycle.md +13 -0
- package/docs/features/setup.md +5 -0
- package/docs/features/start-and-shell.md +5 -0
- package/docs/superpowers/plans/2026-07-18-architecture-aware-1password-installer.md +163 -0
- package/docs/superpowers/plans/2026-07-18-devcontainer-node-image-digest.md +341 -0
- package/docs/superpowers/plans/2026-07-19-container-runtime-readiness.md +1536 -0
- package/docs/superpowers/specs/2026-07-18-architecture-aware-1password-installer-design.md +63 -0
- package/docs/superpowers/specs/2026-07-18-devcontainer-node-image-digest-design.md +108 -0
- package/docs/superpowers/specs/2026-07-19-container-runtime-readiness-design.md +353 -0
- package/package.json +1 -1
- package/src/container-runtime.ts +295 -0
- package/src/devcontainer.ts +20 -4
- package/src/doctor.ts +48 -22
- package/src/main.ts +95 -11
- package/src/process.ts +50 -10
- package/src/progress.ts +63 -8
- 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
|
+
}
|
package/src/devcontainer.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
20
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
-
...
|
|
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 [
|
|
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, {
|
|
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,
|