boxdown 1.0.0 → 1.2.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 (69) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +49 -17
  3. package/assets/devcontainer/devcontainer.json +24 -7
  4. package/assets/devcontainer/hooks/initialize.sh +32 -0
  5. package/assets/devcontainer/hooks/post-create.sh +59 -12
  6. package/assets/devcontainer/hooks/post-start.sh +21 -4
  7. package/assets/devcontainer/ssh-config-install.sh +12 -3
  8. package/assets/devcontainer/start.sh +721 -44
  9. package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
  10. package/assets/devcontainer/utils/deps-install.sh +68 -0
  11. package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
  12. package/assets/devcontainer/utils/git-signing-bootstrap.sh +56 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  15. package/dist/bin/cli.cjs +1 -1
  16. package/dist/bin/cli.mjs +1 -1
  17. package/dist/main-Df4E8ARj.cjs +5498 -0
  18. package/dist/main-XMBsKjIK.mjs +5458 -0
  19. package/dist/main-XMBsKjIK.mjs.map +1 -0
  20. package/dist/main.cjs +5 -2
  21. package/dist/main.d.cts +495 -4
  22. package/dist/main.d.cts.map +1 -1
  23. package/dist/main.d.mts +495 -4
  24. package/dist/main.d.mts.map +1 -1
  25. package/dist/main.mjs +2 -2
  26. package/docs/README.md +1 -0
  27. package/docs/architecture.md +32 -0
  28. package/docs/development.md +13 -6
  29. package/docs/features/README.md +2 -0
  30. package/docs/features/commit-signing.md +23 -0
  31. package/docs/features/generated-config-and-state.md +57 -4
  32. package/docs/features/github-auth-refresh.md +12 -0
  33. package/docs/features/lifecycle.md +97 -11
  34. package/docs/features/setup.md +66 -0
  35. package/docs/features/ssh-config-and-proxy.md +228 -7
  36. package/docs/features/start-and-shell.md +40 -5
  37. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  38. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  39. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +396 -0
  40. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  41. package/docs/testing.md +35 -2
  42. package/docs/todo.md +2 -2
  43. package/package.json +1 -1
  44. package/src/claude-app-config.ts +304 -0
  45. package/src/cli-style.ts +43 -0
  46. package/src/codex-app-config.ts +656 -0
  47. package/src/config.ts +68 -10
  48. package/src/constants.ts +5 -0
  49. package/src/devcontainer.ts +500 -64
  50. package/src/doctor.ts +195 -30
  51. package/src/git-signing.ts +87 -0
  52. package/src/interactive-prompts.ts +692 -0
  53. package/src/list.ts +16 -2
  54. package/src/logging.ts +154 -0
  55. package/src/main.ts +1213 -63
  56. package/src/metadata.ts +52 -3
  57. package/src/package-info.ts +18 -0
  58. package/src/paths.ts +50 -10
  59. package/src/process.ts +80 -2
  60. package/src/progress.ts +593 -0
  61. package/src/purge.ts +208 -0
  62. package/src/shell.ts +19 -0
  63. package/src/ssh-config.ts +134 -16
  64. package/src/ssh-install-targets.ts +111 -0
  65. package/src/ssh-key.ts +53 -13
  66. package/src/status.ts +5 -0
  67. package/dist/main-BuEptwlL.cjs +0 -1707
  68. package/dist/main-ZFTrSVgt.mjs +0 -1685
  69. package/dist/main-ZFTrSVgt.mjs.map +0 -1
@@ -5,8 +5,12 @@ import { buildGeneratedDevcontainerConfig, publishContainerPortFromConfig, write
5
5
  import { codingAgentBinary, type CodingAgentCli } from './coding-agents.ts'
6
6
  import { resolveDevcontainerCli } from './devcontainer-cli.ts'
7
7
  import { configureWorkspaceGithubGitAuth } from './github-git-auth.ts'
8
+ import { resolveGitSigningPlan } from './git-signing.ts'
9
+ import type { WorkspaceCommandLogger } from './logging.ts'
10
+ import { recordWorkspaceDockerImage } from './metadata.ts'
8
11
  import type { WorkspaceContext } from './paths.ts'
9
12
  import { runBuffered, runInteractive } from './process.ts'
13
+ import { assertProgressCommandSucceeded, type ProgressReporter, runProgressCommand } from './progress.ts'
10
14
  import { interactiveCommandScript, interactiveShellEnvArgs, interactiveShellScript } from './shell.ts'
11
15
  import { ensureHostSshKey } from './ssh-key.ts'
12
16
  import { type ContainerSummary, parseDockerPsJsonLines } from './status.ts'
@@ -14,9 +18,36 @@ import { type ContainerSummary, parseDockerPsJsonLines } from './status.ts'
14
18
  export interface StartOptions {
15
19
  recreate?: boolean
16
20
  proxyMode?: boolean
21
+ progress?: ProgressReporter
22
+ logger?: WorkspaceCommandLogger
17
23
  reuseRunning?: boolean
18
24
  }
19
25
 
26
+ export interface ContainerCommandOptions {
27
+ progress?: ProgressReporter
28
+ logger?: WorkspaceCommandLogger
29
+ }
30
+
31
+ export interface TunnelPortForward {
32
+ localPort: number
33
+ remotePort: number
34
+ }
35
+
36
+ export interface SshTunnelOptions {
37
+ bindAddress?: string
38
+ remoteHost?: string
39
+ }
40
+
41
+ export interface DockerImageInfo {
42
+ id: string
43
+ name?: string
44
+ }
45
+
46
+ interface DockerInspectContainer {
47
+ Image?: unknown
48
+ Config?: unknown
49
+ }
50
+
20
51
  function devcontainerWorkspaceArgs (context: WorkspaceContext): string[] {
21
52
  return [
22
53
  '--workspace-folder',
@@ -34,11 +65,23 @@ function log (message: string, proxyMode = false): void {
34
65
  }
35
66
  }
36
67
 
68
+ function containerProgressEnvArgs (progress?: ProgressReporter): string[] {
69
+ if (progress === undefined) {
70
+ return []
71
+ }
72
+
73
+ return [
74
+ 'env',
75
+ `BOXDOWN_VERBOSE=${progress.verbose ? '1' : '0'}`,
76
+ `BOXDOWN_PROGRESS=${progress.verbose ? '0' : '1'}`
77
+ ]
78
+ }
79
+
37
80
  export function parseContainerIdFromUpOutput (output: string): string | undefined {
38
81
  return /"containerId"\s*:\s*"([^"]+)"/.exec(output)?.[1]
39
82
  }
40
83
 
41
- export async function findWorkspaceContainer (context: WorkspaceContext): Promise<ContainerSummary | undefined> {
84
+ export async function findWorkspaceContainer (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<ContainerSummary | undefined> {
42
85
  const result = await runBuffered('docker', [
43
86
  'ps',
44
87
  '-a',
@@ -47,6 +90,7 @@ export async function findWorkspaceContainer (context: WorkspaceContext): Promis
47
90
  '--format',
48
91
  '{{json .}}'
49
92
  ], {
93
+ logger: options.logger,
50
94
  mirrorStdout: false,
51
95
  mirrorStderr: false
52
96
  })
@@ -78,7 +122,7 @@ export async function listWorkspaceContainers (): Promise<ContainerSummary[] | u
78
122
  return parseDockerPsJsonLines(result.stdout)
79
123
  }
80
124
 
81
- export async function findRunningContainerId (context: WorkspaceContext): Promise<string | undefined> {
125
+ export async function findRunningContainerId (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<string | undefined> {
82
126
  const result = await runBuffered('docker', [
83
127
  'ps',
84
128
  '--filter',
@@ -86,6 +130,7 @@ export async function findRunningContainerId (context: WorkspaceContext): Promis
86
130
  '--format',
87
131
  '{{.ID}}'
88
132
  ], {
133
+ logger: options.logger,
89
134
  mirrorStdout: false,
90
135
  mirrorStderr: false
91
136
  })
@@ -97,8 +142,72 @@ export async function findRunningContainerId (context: WorkspaceContext): Promis
97
142
  return result.stdout.split(/\r?\n/).find((line) => line.length > 0)
98
143
  }
99
144
 
100
- export async function stopWorkspaceContainer (context: WorkspaceContext): Promise<void> {
101
- const container = await findWorkspaceContainer(context)
145
+ function isRecord (value: unknown): value is Record<string, unknown> {
146
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
147
+ }
148
+
149
+ function parseDockerInspectImage (output: string, containerId: string): DockerImageInfo | undefined {
150
+ const trimmed = output.trim()
151
+
152
+ if (trimmed.length === 0) {
153
+ return undefined
154
+ }
155
+
156
+ let parsed: DockerInspectContainer
157
+
158
+ try {
159
+ parsed = JSON.parse(trimmed) as DockerInspectContainer
160
+ } catch (error) {
161
+ throw new Error(`Could not parse docker inspect output for ${containerId}`, { cause: error })
162
+ }
163
+
164
+ if (typeof parsed.Image !== 'string' || parsed.Image.length === 0) {
165
+ return undefined
166
+ }
167
+
168
+ const configImage = isRecord(parsed.Config) && typeof parsed.Config.Image === 'string' && parsed.Config.Image.length > 0
169
+ ? parsed.Config.Image
170
+ : undefined
171
+
172
+ return {
173
+ id: parsed.Image,
174
+ ...(configImage === undefined ? {} : { name: configImage })
175
+ }
176
+ }
177
+
178
+ export async function inspectContainerImage (containerId: string, options: { logger?: WorkspaceCommandLogger } = {}): Promise<DockerImageInfo | undefined> {
179
+ const result = await runBuffered('docker', [
180
+ 'inspect',
181
+ '--format',
182
+ '{{json .}}',
183
+ containerId
184
+ ], {
185
+ logger: options.logger,
186
+ mirrorStdout: false,
187
+ mirrorStderr: false
188
+ })
189
+
190
+ if (result.code !== 0) {
191
+ throw new Error(`Could not inspect devcontainer image for ${containerId}`)
192
+ }
193
+
194
+ return parseDockerInspectImage(result.stdout, containerId)
195
+ }
196
+
197
+ async function recordContainerImageIfPresent (context: WorkspaceContext, containerId: string, logger?: WorkspaceCommandLogger): Promise<void> {
198
+ try {
199
+ const image = await inspectContainerImage(containerId, { logger })
200
+
201
+ if (image !== undefined) {
202
+ recordWorkspaceDockerImage(context, image)
203
+ }
204
+ } catch {
205
+ process.stderr.write(`Warning: could not record devcontainer image metadata for ${containerId}.\n`)
206
+ }
207
+ }
208
+
209
+ export async function stopWorkspaceContainer (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<void> {
210
+ const container = await findWorkspaceContainer(context, { logger: options.logger })
102
211
 
103
212
  if (container === undefined) {
104
213
  process.stdout.write(`No devcontainer found for: ${context.workspaceFolder}\n`)
@@ -111,6 +220,7 @@ export async function stopWorkspaceContainer (context: WorkspaceContext): Promis
111
220
  }
112
221
 
113
222
  const result = await runBuffered('docker', ['stop', container.id], {
223
+ logger: options.logger,
114
224
  mirrorStdout: false,
115
225
  mirrorStderr: false
116
226
  })
@@ -122,41 +232,127 @@ export async function stopWorkspaceContainer (context: WorkspaceContext): Promis
122
232
  process.stdout.write(`Stopped devcontainer: ${container.id}\n`)
123
233
  }
124
234
 
125
- export async function removeWorkspaceContainer (context: WorkspaceContext): Promise<void> {
126
- const container = await findWorkspaceContainer(context)
235
+ export async function removeWorkspaceContainer (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<void> {
236
+ const container = await findWorkspaceContainer(context, { logger: options.logger })
127
237
 
128
238
  if (container === undefined) {
129
239
  process.stdout.write(`No devcontainer found for: ${context.workspaceFolder}\n`)
130
240
  return
131
241
  }
132
242
 
133
- const result = await runBuffered('docker', ['rm', '-f', container.id], {
243
+ await removeContainerById(container.id, { logger: options.logger })
244
+ process.stdout.write(`Removed devcontainer: ${container.id}\n`)
245
+ }
246
+
247
+ export async function removeContainerById (containerId: string, options: { volumes?: boolean, logger?: WorkspaceCommandLogger } = {}): Promise<void> {
248
+ const result = await runBuffered('docker', [
249
+ 'rm',
250
+ '-f',
251
+ ...(options.volumes === true ? ['-v'] : []),
252
+ containerId
253
+ ], {
254
+ logger: options.logger,
134
255
  mirrorStdout: false,
135
256
  mirrorStderr: false
136
257
  })
137
258
 
138
259
  if (result.code !== 0) {
139
- throw new Error(`Could not remove devcontainer ${container.id}`)
260
+ throw new Error(`Could not remove devcontainer ${containerId}`)
140
261
  }
262
+ }
141
263
 
142
- process.stdout.write(`Removed devcontainer: ${container.id}\n`)
264
+ function dockerImageMissing (stderr: string): boolean {
265
+ return /No such image/i.test(stderr) || /not found/i.test(stderr)
266
+ }
267
+
268
+ export async function removeDockerImage (imageId: string, options: { logger?: WorkspaceCommandLogger } = {}): Promise<boolean> {
269
+ const result = await runBuffered('docker', ['image', 'rm', '-f', imageId], {
270
+ logger: options.logger,
271
+ mirrorStdout: false,
272
+ mirrorStderr: false
273
+ })
274
+
275
+ if (result.code !== 0) {
276
+ if (dockerImageMissing(result.stderr)) {
277
+ process.stdout.write(`Docker image already absent: ${imageId}\n`)
278
+ return false
279
+ }
280
+
281
+ throw new Error(`Could not remove Docker image ${imageId}`)
282
+ }
283
+
284
+ process.stdout.write(`Removed Docker image: ${imageId}\n`)
285
+ return true
143
286
  }
144
287
 
145
288
  export async function startDevcontainer (context: WorkspaceContext, options: StartOptions = {}): Promise<string> {
146
- await ensureHostSshKey(context, options.proxyMode ?? false)
147
- writeGeneratedDevcontainerConfig(context)
289
+ const progress = options.progress
290
+ const proxyMode = options.proxyMode ?? false
291
+ const hasSshIdentityStep = progress?.hasStep('ssh-identity') === true
292
+ const hasConfigStep = progress?.hasStep('devcontainer-config') === true
293
+ const hasStartStep = progress?.hasStep('devcontainer-start') === true
294
+
295
+ if (hasSshIdentityStep) {
296
+ progress?.startStep('ssh-identity')
297
+ } else {
298
+ progress?.item('Preparing SSH identity')
299
+ }
300
+
301
+ try {
302
+ await ensureHostSshKey(context, {
303
+ quiet: proxyMode,
304
+ progress
305
+ })
306
+ if (hasSshIdentityStep) {
307
+ progress?.completeStep('ssh-identity')
308
+ }
309
+ } catch (error) {
310
+ if (hasSshIdentityStep) {
311
+ progress?.failStep('ssh-identity')
312
+ }
313
+ throw error
314
+ }
315
+
316
+ if (hasConfigStep) {
317
+ progress?.startStep('devcontainer-config')
318
+ } else if (progress !== undefined) {
319
+ progress.item('Writing generated devcontainer config')
320
+ progress.detail(context.generatedConfigPath)
321
+ }
322
+ try {
323
+ writeGeneratedDevcontainerConfig(context, await resolveGitSigningPlan(context))
324
+ if (hasConfigStep) {
325
+ progress?.completeStep('devcontainer-config')
326
+ }
327
+ } catch (error) {
328
+ if (hasConfigStep) {
329
+ progress?.failStep('devcontainer-config')
330
+ }
331
+ throw error
332
+ }
148
333
 
149
334
  if (options.reuseRunning === true && options.recreate !== true) {
150
- const runningContainerId = await findRunningContainerId(context)
335
+ const runningContainerId = await findRunningContainerId(context, { logger: options.logger })
151
336
 
152
337
  if (runningContainerId !== undefined) {
153
- log(`Using running devcontainer for: ${context.workspaceFolder}`, options.proxyMode)
338
+ if (progress === undefined) {
339
+ log(`Using running devcontainer for: ${context.workspaceFolder}`, proxyMode)
340
+ } else if (hasStartStep) {
341
+ progress.startStep('devcontainer-start')
342
+ progress.completeStep('devcontainer-start')
343
+ } else {
344
+ progress.item('Using running devcontainer')
345
+ progress.detail(runningContainerId)
346
+ }
347
+ await recordContainerImageIfPresent(context, runningContainerId, options.logger)
154
348
  return runningContainerId
155
349
  }
156
350
  }
157
351
 
158
352
  const cli = resolveDevcontainerCli(context)
159
- log(`Starting devcontainer for: ${context.workspaceFolder}`, options.proxyMode)
353
+ if (progress === undefined) {
354
+ log(`Starting devcontainer for: ${context.workspaceFolder}`, proxyMode)
355
+ }
160
356
 
161
357
  const args = [
162
358
  'up',
@@ -165,28 +361,50 @@ export async function startDevcontainer (context: WorkspaceContext, options: Sta
165
361
 
166
362
  if (options.recreate === true) {
167
363
  args.push('--remove-existing-container')
168
- log('Removing existing dev container so create-time settings apply.', options.proxyMode)
364
+ if (progress === undefined) {
365
+ log('Removing existing dev container so create-time settings apply.', proxyMode)
366
+ } else if (!hasStartStep) {
367
+ progress.item('Removing existing devcontainer before start')
368
+ }
169
369
  }
170
370
 
171
- const result = await runBuffered(cli.command, [...cli.argsPrefix, ...args], {
172
- mirrorStdout: options.proxyMode === true ? 'stderr' : 'stdout',
173
- mirrorStderr: 'stderr'
174
- })
175
-
176
- if (result.code !== 0) {
371
+ const result = progress === undefined
372
+ ? await runBuffered(cli.command, [...cli.argsPrefix, ...args], {
373
+ mirrorStdout: proxyMode ? 'stderr' : 'stdout',
374
+ mirrorStderr: 'stderr',
375
+ logger: options.logger
376
+ })
377
+ : await runProgressCommand('devcontainer up', cli.command, [...cli.argsPrefix, ...args], {
378
+ progress,
379
+ spinnerLabel: 'Starting devcontainer',
380
+ stepId: 'devcontainer-start',
381
+ verboseStdout: proxyMode ? 'stderr' : 'stdout',
382
+ verboseStderr: 'stderr',
383
+ logger: options.logger
384
+ })
385
+
386
+ if (progress === undefined && result.code !== 0) {
177
387
  throw new Error(`devcontainer up failed for ${context.workspaceFolder}`)
178
388
  }
179
389
 
180
- const containerId = parseContainerIdFromUpOutput(`${result.stdout}\n${result.stderr}`) ?? await findRunningContainerId(context)
390
+ if (progress !== undefined) {
391
+ assertProgressCommandSucceeded('devcontainer up', result, `devcontainer up failed for ${context.workspaceFolder}`)
392
+ }
393
+
394
+ const containerId = parseContainerIdFromUpOutput(`${result.stdout}\n${result.stderr}`) ?? await findRunningContainerId(context, { logger: options.logger })
181
395
 
182
396
  if (containerId === undefined) {
397
+ if (hasStartStep) {
398
+ progress?.failStep('devcontainer-start')
399
+ }
183
400
  throw new Error(`Could not resolve devcontainer ID for ${context.workspaceFolder}`)
184
401
  }
185
402
 
403
+ await recordContainerImageIfPresent(context, containerId, options.logger)
186
404
  return containerId
187
405
  }
188
406
 
189
- export async function printPortHint (context: WorkspaceContext, containerId: string): Promise<void> {
407
+ export async function printPortHint (context: WorkspaceContext, containerId: string, options: { logger?: WorkspaceCommandLogger } = {}): Promise<void> {
190
408
  const config = buildGeneratedDevcontainerConfig(context)
191
409
  const containerPort = publishContainerPortFromConfig(config)
192
410
 
@@ -196,6 +414,7 @@ export async function printPortHint (context: WorkspaceContext, containerId: str
196
414
  }
197
415
 
198
416
  const result = await runBuffered('docker', ['port', containerId, `${containerPort}/tcp`], {
417
+ logger: options.logger,
199
418
  mirrorStdout: false,
200
419
  mirrorStderr: false
201
420
  })
@@ -209,7 +428,7 @@ export async function printPortHint (context: WorkspaceContext, containerId: str
209
428
  }
210
429
  }
211
430
 
212
- export async function openShell (context: WorkspaceContext): Promise<number> {
431
+ export async function openShell (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<number> {
213
432
  const cli = resolveDevcontainerCli(context)
214
433
  process.stdout.write('Dropping into container shell...\n')
215
434
 
@@ -223,7 +442,7 @@ export async function openShell (context: WorkspaceContext): Promise<number> {
223
442
  'bash',
224
443
  '-c',
225
444
  interactiveShellScript()
226
- ])
445
+ ], { logger: options.logger })
227
446
  }
228
447
 
229
448
  export function codingAgentDevcontainerExecArgs (context: WorkspaceContext, agent: CodingAgentCli, agentArgs: string[] = []): string[] {
@@ -242,58 +461,140 @@ export function codingAgentDevcontainerExecArgs (context: WorkspaceContext, agen
242
461
  ]
243
462
  }
244
463
 
245
- export async function openCodingAgentCli (context: WorkspaceContext, agent: CodingAgentCli, agentArgs: string[] = []): Promise<number> {
464
+ export async function openCodingAgentCli (context: WorkspaceContext, agent: CodingAgentCli, agentArgs: string[] = [], options: { logger?: WorkspaceCommandLogger } = {}): Promise<number> {
246
465
  const cli = resolveDevcontainerCli(context)
247
466
  process.stdout.write(`Dropping into ${codingAgentBinary(agent)} inside the devcontainer...\n`)
248
467
 
249
468
  return runInteractive(cli.command, [
250
469
  ...cli.argsPrefix,
251
470
  ...codingAgentDevcontainerExecArgs(context, agent, agentArgs)
252
- ])
471
+ ], { logger: options.logger })
253
472
  }
254
473
 
255
- export async function ensureContainerSshRuntime (context: WorkspaceContext): Promise<void> {
474
+ export async function ensureContainerSshRuntime (context: WorkspaceContext, options: ContainerCommandOptions = {}): Promise<void> {
256
475
  const cli = resolveDevcontainerCli(context)
257
- const result = await runBuffered(cli.command, [
476
+ const args = [
258
477
  ...cli.argsPrefix,
259
478
  'exec',
260
479
  ...devcontainerWorkspaceArgs(context),
261
480
  '--',
481
+ ...containerProgressEnvArgs(options.progress),
262
482
  'bash',
263
483
  `${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/utils/ssh-bootstrap.sh`,
264
484
  'runtime'
265
- ], {
266
- mirrorStdout: 'stderr',
267
- mirrorStderr: 'stderr'
268
- })
269
-
270
- if (result.code !== 0) {
485
+ ]
486
+ const result = options.progress === undefined
487
+ ? await runBuffered(cli.command, args, {
488
+ mirrorStdout: 'stderr',
489
+ mirrorStderr: 'stderr',
490
+ logger: options.logger
491
+ })
492
+ : await runProgressCommand('prepare SSH runtime', cli.command, args, {
493
+ progress: options.progress,
494
+ spinnerLabel: 'Preparing container SSH runtime',
495
+ stepId: 'ssh-runtime',
496
+ verboseStdout: 'stderr',
497
+ verboseStderr: 'stderr',
498
+ logger: options.logger
499
+ })
500
+
501
+ if (options.progress === undefined && result.code !== 0) {
271
502
  throw new Error('Failed to prepare devcontainer SSH runtime')
272
503
  }
504
+
505
+ if (options.progress !== undefined) {
506
+ assertProgressCommandSucceeded('prepare SSH runtime', result, 'Failed to prepare devcontainer SSH runtime')
507
+ }
273
508
  }
274
509
 
275
- export async function refreshContainerCodingAgentClis (context: WorkspaceContext, proxyMode = false, agents: CodingAgentCli[] = []): Promise<void> {
510
+ export async function refreshContainerCodingAgentClis (
511
+ context: WorkspaceContext,
512
+ proxyMode = false,
513
+ agents: CodingAgentCli[] = [],
514
+ options: ContainerCommandOptions = {}
515
+ ): Promise<void> {
276
516
  const cli = resolveDevcontainerCli(context)
277
- const result = await runBuffered(cli.command, [
517
+ const spinnerLabel = agents.length === 0
518
+ ? 'Refreshing default coding-agent CLIs'
519
+ : `Refreshing coding-agent CLIs: ${agents.map(codingAgentBinary).join(', ')}`
520
+ const args = [
278
521
  ...cli.argsPrefix,
279
522
  'exec',
280
523
  ...devcontainerWorkspaceArgs(context),
281
524
  '--',
525
+ ...containerProgressEnvArgs(options.progress),
282
526
  'bash',
283
527
  `${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/utils/coding-agent-cli-update.sh`,
284
528
  'maybe-update',
285
529
  ...agents
286
- ], {
287
- mirrorStdout: proxyMode ? 'stderr' : 'stdout',
288
- mirrorStderr: 'stderr'
289
- })
530
+ ]
531
+ const result = options.progress === undefined
532
+ ? await runBuffered(cli.command, args, {
533
+ mirrorStdout: proxyMode ? 'stderr' : 'stdout',
534
+ mirrorStderr: 'stderr',
535
+ logger: options.logger
536
+ })
537
+ : await runProgressCommand('refresh coding-agent CLIs', cli.command, args, {
538
+ progress: options.progress,
539
+ spinnerLabel,
540
+ stepId: 'coding-agent-refresh',
541
+ verboseStdout: proxyMode ? 'stderr' : 'stdout',
542
+ verboseStderr: 'stderr',
543
+ logger: options.logger
544
+ })
290
545
 
291
546
  if (result.code !== 0) {
292
- process.stderr.write('Warning: could not refresh one or more coding-agent CLIs inside the devcontainer.\n')
547
+ if (options.progress === undefined) {
548
+ process.stderr.write('Warning: could not refresh one or more coding-agent CLIs inside the devcontainer.\n')
549
+ } else {
550
+ options.progress.warn('Could not refresh one or more coding-agent CLIs inside the devcontainer.')
551
+ }
552
+ }
553
+ }
554
+
555
+ export async function ensureContainerCodingAgentCli (
556
+ context: WorkspaceContext,
557
+ agent: CodingAgentCli,
558
+ options: ContainerCommandOptions = {}
559
+ ): Promise<void> {
560
+ const cli = resolveDevcontainerCli(context)
561
+ const spinnerLabel = `Preparing ${codingAgentBinary(agent)} inside the devcontainer`
562
+ const args = [
563
+ ...cli.argsPrefix,
564
+ 'exec',
565
+ ...devcontainerWorkspaceArgs(context),
566
+ '--',
567
+ ...containerProgressEnvArgs(options.progress),
568
+ 'bash',
569
+ `${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/utils/coding-agent-cli-update.sh`,
570
+ 'ensure',
571
+ agent
572
+ ]
573
+ const result = options.progress === undefined
574
+ ? await runBuffered(cli.command, args, {
575
+ mirrorStdout: 'stdout',
576
+ mirrorStderr: 'stderr',
577
+ logger: options.logger
578
+ })
579
+ : await runProgressCommand(`prepare ${codingAgentBinary(agent)}`, cli.command, args, {
580
+ progress: options.progress,
581
+ spinnerLabel,
582
+ stepId: 'agent-cli',
583
+ verboseStdout: 'stdout',
584
+ verboseStderr: 'stderr',
585
+ logger: options.logger
586
+ })
587
+
588
+ if (options.progress === undefined && result.code !== 0) {
589
+ throw new Error(`Could not install or refresh ${codingAgentBinary(agent)} inside the devcontainer`)
590
+ }
591
+
592
+ if (options.progress !== undefined) {
593
+ assertProgressCommandSucceeded(`prepare ${codingAgentBinary(agent)}`, result, `Could not install or refresh ${codingAgentBinary(agent)} inside the devcontainer`)
293
594
  }
294
595
  }
295
596
 
296
- export async function runSshdProxy (containerId: string): Promise<number> {
597
+ export async function runSshdProxy (containerId: string, options: { logger?: WorkspaceCommandLogger } = {}): Promise<number> {
297
598
  return runInteractive('docker', [
298
599
  'exec',
299
600
  '-i',
@@ -314,7 +615,28 @@ export async function runSshdProxy (containerId: string): Promise<number> {
314
615
  'AllowStreamLocalForwarding=yes',
315
616
  '-o',
316
617
  'PermitTTY=yes'
317
- ])
618
+ ], { logger: options.logger })
619
+ }
620
+
621
+ export function sshTunnelArgs (alias: string, ports: TunnelPortForward[], options: SshTunnelOptions = {}): string[] {
622
+ const bindAddress = options.bindAddress ?? '127.0.0.1'
623
+ const remoteHost = options.remoteHost ?? 'localhost'
624
+
625
+ return [
626
+ '-N',
627
+ '-o',
628
+ 'ExitOnForwardFailure=yes',
629
+ ...ports.flatMap((port) => [
630
+ '-L',
631
+ `${bindAddress}:${port.localPort}:${remoteHost}:${port.remotePort}`
632
+ ]),
633
+ alias
634
+ ]
635
+ }
636
+
637
+ export async function openSshTunnel (alias: string, ports: TunnelPortForward[], options: SshTunnelOptions & { logger?: WorkspaceCommandLogger } = {}): Promise<number> {
638
+ const { logger, ...tunnelOptions } = options
639
+ return runInteractive('ssh', sshTunnelArgs(alias, ports, tunnelOptions), { logger })
318
640
  }
319
641
 
320
642
  async function hostGhTokenOrEmpty (): Promise<string> {
@@ -330,18 +652,69 @@ async function hostGhTokenOrEmpty (): Promise<string> {
330
652
  return result.stdout.trim()
331
653
  }
332
654
 
333
- export async function refreshContainerGhAuth (context: WorkspaceContext): Promise<void> {
334
- await ensureHostSshKey(context, true)
335
- writeGeneratedDevcontainerConfig(context)
655
+ export async function refreshContainerGhAuth (context: WorkspaceContext, options: ContainerCommandOptions = {}): Promise<void> {
656
+ const progress = options.progress
657
+ const hasConfigStep = progress?.hasStep('gh-auth-config') === true
658
+ const hasTokenStep = progress?.hasStep('gh-token-read') === true
659
+ const hasGitAuthStep = progress?.hasStep('gh-git-auth') === true
660
+
661
+ if (hasConfigStep) {
662
+ progress?.startStep('gh-auth-config')
663
+ } else {
664
+ progress?.item('Preparing generated config for GitHub auth refresh')
665
+ }
666
+
667
+ try {
668
+ await ensureHostSshKey(context, {
669
+ quiet: true,
670
+ progress
671
+ })
672
+ writeGeneratedDevcontainerConfig(context, await resolveGitSigningPlan(context))
673
+ if (hasConfigStep) {
674
+ progress?.completeStep('gh-auth-config')
675
+ }
676
+ } catch (error) {
677
+ if (hasConfigStep) {
678
+ progress?.failStep('gh-auth-config')
679
+ }
680
+ throw error
681
+ }
682
+
683
+ if (hasTokenStep) {
684
+ progress?.startStep('gh-token-read')
685
+ } else {
686
+ progress?.item('Reading host GitHub CLI token')
687
+ }
336
688
 
337
- const token = await hostGhTokenOrEmpty()
689
+ let token: string
690
+ try {
691
+ token = await hostGhTokenOrEmpty()
692
+ if (hasTokenStep) {
693
+ progress?.completeStep('gh-token-read')
694
+ }
695
+ } catch (error) {
696
+ if (hasTokenStep) {
697
+ progress?.failStep('gh-token-read')
698
+ }
699
+ throw error
700
+ }
338
701
 
339
702
  if (token.length === 0) {
703
+ progress?.skipStep('gh-auth-refresh')
704
+ progress?.skipStep('gh-git-auth')
705
+ progress?.skipStep('gh-auth-verify')
706
+ if (progress === undefined) {
707
+ process.stderr.write('Warning: Host GitHub CLI token unavailable; skipping container auth refresh.\n')
708
+ } else {
709
+ progress.warn('Host GitHub CLI token unavailable; skipping container auth refresh.')
710
+ }
340
711
  return
341
712
  }
342
713
 
714
+ options.logger?.addRedaction(token)
715
+
343
716
  const cli = resolveDevcontainerCli(context)
344
- const login = await runBuffered(cli.command, [
717
+ const loginArgs = [
345
718
  ...cli.argsPrefix,
346
719
  'exec',
347
720
  ...devcontainerWorkspaceArgs(context),
@@ -355,23 +728,67 @@ export async function refreshContainerGhAuth (context: WorkspaceContext): Promis
355
728
  'https',
356
729
  '--with-token',
357
730
  '--insecure-storage'
358
- ], {
359
- input: `${token}\n`,
360
- mirrorStdout: false,
361
- mirrorStderr: false
362
- })
731
+ ]
732
+ const login = progress === undefined
733
+ ? await runBuffered(cli.command, loginArgs, {
734
+ input: `${token}\n`,
735
+ logger: options.logger,
736
+ mirrorStdout: false,
737
+ mirrorStderr: false
738
+ })
739
+ : await runProgressCommand('refresh GitHub CLI auth', cli.command, loginArgs, {
740
+ input: `${token}\n`,
741
+ progress,
742
+ logger: options.logger,
743
+ spinnerLabel: 'Refreshing GitHub CLI auth inside the devcontainer',
744
+ stepId: 'gh-auth-refresh',
745
+ verboseStdout: false,
746
+ verboseStderr: false
747
+ })
363
748
 
364
749
  if (login.code !== 0) {
365
- process.stderr.write('Warning: could not refresh GitHub CLI auth inside the devcontainer.\n')
750
+ progress?.skipStep('gh-git-auth')
751
+ progress?.skipStep('gh-auth-verify')
752
+ if (progress === undefined) {
753
+ process.stderr.write('Warning: could not refresh GitHub CLI auth inside the devcontainer.\n')
754
+ } else {
755
+ progress.warn('Could not refresh GitHub CLI auth inside the devcontainer.')
756
+ }
366
757
  return
367
758
  }
368
759
 
369
- const gitAuthConfigured = await configureWorkspaceGithubGitAuth(context.workspaceFolder)
760
+ if (hasGitAuthStep) {
761
+ progress?.startStep('gh-git-auth')
762
+ } else {
763
+ progress?.item('Configuring workspace GitHub Git auth')
764
+ }
765
+
766
+ let gitAuthConfigured: boolean
767
+ try {
768
+ gitAuthConfigured = await configureWorkspaceGithubGitAuth(context.workspaceFolder)
769
+ if (hasGitAuthStep) {
770
+ if (gitAuthConfigured) {
771
+ progress?.completeStep('gh-git-auth')
772
+ } else {
773
+ progress?.failStep('gh-git-auth')
774
+ }
775
+ }
776
+ } catch (error) {
777
+ if (hasGitAuthStep) {
778
+ progress?.failStep('gh-git-auth')
779
+ }
780
+ throw error
781
+ }
782
+
370
783
  if (!gitAuthConfigured) {
371
- process.stderr.write('Warning: GitHub CLI auth refreshed, but GitHub Git auth was not configured for this workspace.\n')
784
+ if (progress === undefined) {
785
+ process.stderr.write('Warning: GitHub CLI auth refreshed, but GitHub Git auth was not configured for this workspace.\n')
786
+ } else {
787
+ progress.warn('GitHub CLI auth refreshed, but GitHub Git auth was not configured for this workspace.')
788
+ }
372
789
  }
373
790
 
374
- const verify = await runBuffered(cli.command, [
791
+ const verifyArgs = [
375
792
  ...cli.argsPrefix,
376
793
  'exec',
377
794
  ...devcontainerWorkspaceArgs(context),
@@ -381,14 +798,33 @@ export async function refreshContainerGhAuth (context: WorkspaceContext): Promis
381
798
  'status',
382
799
  '--hostname',
383
800
  'github.com'
384
- ], {
385
- mirrorStdout: false,
386
- mirrorStderr: false
387
- })
801
+ ]
802
+ const verify = progress === undefined
803
+ ? await runBuffered(cli.command, verifyArgs, {
804
+ mirrorStdout: false,
805
+ mirrorStderr: false,
806
+ logger: options.logger
807
+ })
808
+ : await runProgressCommand('verify GitHub CLI auth', cli.command, verifyArgs, {
809
+ progress,
810
+ logger: options.logger,
811
+ spinnerLabel: 'Verifying GitHub CLI auth inside the devcontainer',
812
+ stepId: 'gh-auth-verify',
813
+ verboseStdout: false,
814
+ verboseStderr: false
815
+ })
388
816
 
389
817
  if (verify.code === 0) {
390
- process.stdout.write('GitHub CLI auth refreshed inside the devcontainer.\n')
818
+ if (progress === undefined) {
819
+ process.stdout.write('GitHub CLI auth refreshed inside the devcontainer.\n')
820
+ } else if (!progress.hasStep('gh-auth-verify')) {
821
+ progress.item('GitHub CLI auth refreshed inside the devcontainer')
822
+ }
391
823
  } else {
392
- process.stderr.write('Warning: GitHub CLI auth refresh completed, but verification failed.\n')
824
+ if (progress === undefined) {
825
+ process.stderr.write('Warning: GitHub CLI auth refresh completed, but verification failed.\n')
826
+ } else {
827
+ progress.warn('GitHub CLI auth refresh completed, but verification failed.')
828
+ }
393
829
  }
394
830
  }