boxdown 1.0.0 → 1.2.1

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 (76) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +65 -21
  3. package/assets/devcontainer/devcontainer.json +27 -15
  4. package/assets/devcontainer/hooks/initialize.sh +76 -22
  5. package/assets/devcontainer/hooks/post-create.sh +70 -12
  6. package/assets/devcontainer/hooks/post-start.sh +20 -13
  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 +109 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
  15. package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
  16. package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
  17. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  18. package/dist/bin/cli.cjs +1 -1
  19. package/dist/bin/cli.mjs +1 -1
  20. package/dist/main-BDgyf2t5.cjs +5758 -0
  21. package/dist/main-J4_2Up3o.mjs +5718 -0
  22. package/dist/main-J4_2Up3o.mjs.map +1 -0
  23. package/dist/main.cjs +5 -2
  24. package/dist/main.d.cts +501 -4
  25. package/dist/main.d.cts.map +1 -1
  26. package/dist/main.d.mts +501 -4
  27. package/dist/main.d.mts.map +1 -1
  28. package/dist/main.mjs +2 -2
  29. package/docs/README.md +1 -0
  30. package/docs/architecture.md +32 -0
  31. package/docs/development.md +13 -6
  32. package/docs/features/README.md +2 -0
  33. package/docs/features/commit-signing.md +94 -0
  34. package/docs/features/generated-config-and-state.md +73 -5
  35. package/docs/features/github-auth-refresh.md +15 -2
  36. package/docs/features/lifecycle.md +103 -11
  37. package/docs/features/setup.md +66 -0
  38. package/docs/features/ssh-config-and-proxy.md +228 -7
  39. package/docs/features/start-and-shell.md +45 -5
  40. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  41. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  42. package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
  43. package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
  44. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +416 -0
  45. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  46. package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
  47. package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
  48. package/docs/testing.md +35 -2
  49. package/docs/todo.md +2 -2
  50. package/package.json +1 -1
  51. package/src/claude-app-config.ts +304 -0
  52. package/src/cli-style.ts +43 -0
  53. package/src/codex-app-config.ts +656 -0
  54. package/src/config.ts +80 -10
  55. package/src/constants.ts +12 -0
  56. package/src/devcontainer.ts +511 -64
  57. package/src/doctor.ts +292 -30
  58. package/src/git-signing.ts +267 -0
  59. package/src/interactive-prompts.ts +692 -0
  60. package/src/list.ts +16 -2
  61. package/src/logging.ts +164 -0
  62. package/src/main.ts +1214 -63
  63. package/src/metadata.ts +52 -3
  64. package/src/package-info.ts +18 -0
  65. package/src/paths.ts +71 -11
  66. package/src/process.ts +80 -2
  67. package/src/progress.ts +593 -0
  68. package/src/purge.ts +216 -0
  69. package/src/shell.ts +25 -0
  70. package/src/ssh-config.ts +134 -16
  71. package/src/ssh-install-targets.ts +111 -0
  72. package/src/ssh-key.ts +53 -13
  73. package/src/status.ts +5 -0
  74. package/dist/main-BuEptwlL.cjs +0 -1707
  75. package/dist/main-ZFTrSVgt.mjs +0 -1685
  76. 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 { reportGitSigningPlan, 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,32 @@ 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
+
20
47
  function devcontainerWorkspaceArgs (context: WorkspaceContext): string[] {
21
48
  return [
22
49
  '--workspace-folder',
@@ -34,11 +61,23 @@ function log (message: string, proxyMode = false): void {
34
61
  }
35
62
  }
36
63
 
64
+ function containerProgressEnvArgs (progress?: ProgressReporter): string[] {
65
+ if (progress === undefined) {
66
+ return []
67
+ }
68
+
69
+ return [
70
+ 'env',
71
+ `BOXDOWN_VERBOSE=${progress.verbose ? '1' : '0'}`,
72
+ `BOXDOWN_PROGRESS=${progress.verbose ? '0' : '1'}`
73
+ ]
74
+ }
75
+
37
76
  export function parseContainerIdFromUpOutput (output: string): string | undefined {
38
77
  return /"containerId"\s*:\s*"([^"]+)"/.exec(output)?.[1]
39
78
  }
40
79
 
41
- export async function findWorkspaceContainer (context: WorkspaceContext): Promise<ContainerSummary | undefined> {
80
+ export async function findWorkspaceContainer (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<ContainerSummary | undefined> {
42
81
  const result = await runBuffered('docker', [
43
82
  'ps',
44
83
  '-a',
@@ -47,6 +86,7 @@ export async function findWorkspaceContainer (context: WorkspaceContext): Promis
47
86
  '--format',
48
87
  '{{json .}}'
49
88
  ], {
89
+ logger: options.logger,
50
90
  mirrorStdout: false,
51
91
  mirrorStderr: false
52
92
  })
@@ -78,7 +118,7 @@ export async function listWorkspaceContainers (): Promise<ContainerSummary[] | u
78
118
  return parseDockerPsJsonLines(result.stdout)
79
119
  }
80
120
 
81
- export async function findRunningContainerId (context: WorkspaceContext): Promise<string | undefined> {
121
+ export async function findRunningContainerId (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<string | undefined> {
82
122
  const result = await runBuffered('docker', [
83
123
  'ps',
84
124
  '--filter',
@@ -86,6 +126,7 @@ export async function findRunningContainerId (context: WorkspaceContext): Promis
86
126
  '--format',
87
127
  '{{.ID}}'
88
128
  ], {
129
+ logger: options.logger,
89
130
  mirrorStdout: false,
90
131
  mirrorStderr: false
91
132
  })
@@ -97,8 +138,64 @@ export async function findRunningContainerId (context: WorkspaceContext): Promis
97
138
  return result.stdout.split(/\r?\n/).find((line) => line.length > 0)
98
139
  }
99
140
 
100
- export async function stopWorkspaceContainer (context: WorkspaceContext): Promise<void> {
101
- const container = await findWorkspaceContainer(context)
141
+ export function parseDockerInspectImage (output: string, containerId: string): DockerImageInfo | undefined {
142
+ const [rawId, rawName] = output.trim().split('|')
143
+
144
+ if (rawId === undefined || rawId.length === 0) {
145
+ return undefined
146
+ }
147
+
148
+ let id: unknown
149
+ let name: unknown
150
+
151
+ try {
152
+ id = JSON.parse(rawId)
153
+ name = rawName === undefined || rawName.length === 0 ? undefined : JSON.parse(rawName)
154
+ } catch (error) {
155
+ throw new Error(`Could not parse docker inspect output for ${containerId}`, { cause: error })
156
+ }
157
+
158
+ if (typeof id !== 'string' || id.length === 0) return undefined
159
+
160
+ return {
161
+ id,
162
+ ...(typeof name === 'string' && name.length > 0 ? { name } : {})
163
+ }
164
+ }
165
+
166
+ export async function inspectContainerImage (containerId: string, options: { logger?: WorkspaceCommandLogger } = {}): Promise<DockerImageInfo | undefined> {
167
+ const result = await runBuffered('docker', [
168
+ 'inspect',
169
+ '--format',
170
+ '{{json .Image}}|{{json .Config.Image}}',
171
+ containerId
172
+ ], {
173
+ logger: options.logger,
174
+ mirrorStdout: false,
175
+ mirrorStderr: false
176
+ })
177
+
178
+ if (result.code !== 0) {
179
+ throw new Error(`Could not inspect devcontainer image for ${containerId}`)
180
+ }
181
+
182
+ return parseDockerInspectImage(result.stdout, containerId)
183
+ }
184
+
185
+ async function recordContainerImageIfPresent (context: WorkspaceContext, containerId: string, logger?: WorkspaceCommandLogger): Promise<void> {
186
+ try {
187
+ const image = await inspectContainerImage(containerId, { logger })
188
+
189
+ if (image !== undefined) {
190
+ recordWorkspaceDockerImage(context, image)
191
+ }
192
+ } catch {
193
+ process.stderr.write(`Warning: could not record devcontainer image metadata for ${containerId}.\n`)
194
+ }
195
+ }
196
+
197
+ export async function stopWorkspaceContainer (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<void> {
198
+ const container = await findWorkspaceContainer(context, { logger: options.logger })
102
199
 
103
200
  if (container === undefined) {
104
201
  process.stdout.write(`No devcontainer found for: ${context.workspaceFolder}\n`)
@@ -111,6 +208,7 @@ export async function stopWorkspaceContainer (context: WorkspaceContext): Promis
111
208
  }
112
209
 
113
210
  const result = await runBuffered('docker', ['stop', container.id], {
211
+ logger: options.logger,
114
212
  mirrorStdout: false,
115
213
  mirrorStderr: false
116
214
  })
@@ -122,41 +220,139 @@ export async function stopWorkspaceContainer (context: WorkspaceContext): Promis
122
220
  process.stdout.write(`Stopped devcontainer: ${container.id}\n`)
123
221
  }
124
222
 
125
- export async function removeWorkspaceContainer (context: WorkspaceContext): Promise<void> {
126
- const container = await findWorkspaceContainer(context)
223
+ export async function removeWorkspaceContainer (context: WorkspaceContext, options: { logger?: WorkspaceCommandLogger } = {}): Promise<void> {
224
+ const container = await findWorkspaceContainer(context, { logger: options.logger })
127
225
 
128
226
  if (container === undefined) {
129
227
  process.stdout.write(`No devcontainer found for: ${context.workspaceFolder}\n`)
130
228
  return
131
229
  }
132
230
 
133
- const result = await runBuffered('docker', ['rm', '-f', container.id], {
231
+ await removeContainerById(container.id, { logger: options.logger })
232
+ process.stdout.write(`Removed devcontainer: ${container.id}\n`)
233
+ }
234
+
235
+ export async function removeContainerById (containerId: string, options: { volumes?: boolean, logger?: WorkspaceCommandLogger } = {}): Promise<void> {
236
+ const result = await runBuffered('docker', [
237
+ 'rm',
238
+ '-f',
239
+ ...(options.volumes === true ? ['-v'] : []),
240
+ containerId
241
+ ], {
242
+ logger: options.logger,
134
243
  mirrorStdout: false,
135
244
  mirrorStderr: false
136
245
  })
137
246
 
138
247
  if (result.code !== 0) {
139
- throw new Error(`Could not remove devcontainer ${container.id}`)
248
+ throw new Error(`Could not remove devcontainer ${containerId}`)
140
249
  }
250
+ }
141
251
 
142
- process.stdout.write(`Removed devcontainer: ${container.id}\n`)
252
+ function dockerImageMissing (stderr: string): boolean {
253
+ return /No such image/i.test(stderr) || /not found/i.test(stderr)
254
+ }
255
+
256
+ export async function removeDockerImage (imageId: string, options: { logger?: WorkspaceCommandLogger } = {}): Promise<boolean> {
257
+ const result = await runBuffered('docker', ['image', 'rm', '-f', imageId], {
258
+ logger: options.logger,
259
+ mirrorStdout: false,
260
+ mirrorStderr: false
261
+ })
262
+
263
+ if (result.code !== 0) {
264
+ if (dockerImageMissing(result.stderr)) {
265
+ process.stdout.write(`Docker image already absent: ${imageId}\n`)
266
+ return false
267
+ }
268
+
269
+ throw new Error(`Could not remove Docker image ${imageId}`)
270
+ }
271
+
272
+ process.stdout.write(`Removed Docker image: ${imageId}\n`)
273
+ return true
143
274
  }
144
275
 
145
276
  export async function startDevcontainer (context: WorkspaceContext, options: StartOptions = {}): Promise<string> {
146
- await ensureHostSshKey(context, options.proxyMode ?? false)
147
- writeGeneratedDevcontainerConfig(context)
277
+ const progress = options.progress
278
+ const proxyMode = options.proxyMode ?? false
279
+ const hasSshIdentityStep = progress?.hasStep('ssh-identity') === true
280
+ const hasConfigStep = progress?.hasStep('devcontainer-config') === true
281
+ const hasStartStep = progress?.hasStep('devcontainer-start') === true
282
+
283
+ if (hasSshIdentityStep) {
284
+ progress?.startStep('ssh-identity')
285
+ } else {
286
+ progress?.item('Preparing SSH identity')
287
+ }
288
+
289
+ try {
290
+ await ensureHostSshKey(context, {
291
+ quiet: proxyMode,
292
+ progress
293
+ })
294
+ if (hasSshIdentityStep) {
295
+ progress?.completeStep('ssh-identity')
296
+ }
297
+ } catch (error) {
298
+ if (hasSshIdentityStep) {
299
+ progress?.failStep('ssh-identity')
300
+ }
301
+ throw error
302
+ }
303
+
304
+ if (hasConfigStep) {
305
+ progress?.startStep('devcontainer-config')
306
+ } else if (progress !== undefined) {
307
+ progress.item('Writing generated devcontainer config')
308
+ progress.detail(context.generatedConfigPath)
309
+ }
310
+ try {
311
+ const signingPlan = await resolveGitSigningPlan(context)
312
+ reportGitSigningPlan(signingPlan, {
313
+ logger: options.logger,
314
+ quiet: proxyMode,
315
+ ...(progress === undefined
316
+ ? {}
317
+ : {
318
+ writeWarning: (message: string) => {
319
+ progress.warn(message.trimEnd())
320
+ }
321
+ })
322
+ })
323
+ writeGeneratedDevcontainerConfig(context, signingPlan)
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,80 @@ 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
+ }
336
666
 
337
- const token = await hostGhTokenOrEmpty()
667
+ try {
668
+ await ensureHostSshKey(context, {
669
+ quiet: true,
670
+ progress
671
+ })
672
+ const signingPlan = await resolveGitSigningPlan(context)
673
+ reportGitSigningPlan(signingPlan, {
674
+ logger: options.logger,
675
+ ...(progress === undefined
676
+ ? {}
677
+ : {
678
+ writeWarning: (message: string) => {
679
+ progress.warn(message.trimEnd())
680
+ }
681
+ })
682
+ })
683
+ writeGeneratedDevcontainerConfig(context, signingPlan)
684
+ if (hasConfigStep) {
685
+ progress?.completeStep('gh-auth-config')
686
+ }
687
+ } catch (error) {
688
+ if (hasConfigStep) {
689
+ progress?.failStep('gh-auth-config')
690
+ }
691
+ throw error
692
+ }
693
+
694
+ if (hasTokenStep) {
695
+ progress?.startStep('gh-token-read')
696
+ } else {
697
+ progress?.item('Reading host GitHub CLI token')
698
+ }
699
+
700
+ let token: string
701
+ try {
702
+ token = await hostGhTokenOrEmpty()
703
+ if (hasTokenStep) {
704
+ progress?.completeStep('gh-token-read')
705
+ }
706
+ } catch (error) {
707
+ if (hasTokenStep) {
708
+ progress?.failStep('gh-token-read')
709
+ }
710
+ throw error
711
+ }
338
712
 
339
713
  if (token.length === 0) {
714
+ progress?.skipStep('gh-auth-refresh')
715
+ progress?.skipStep('gh-git-auth')
716
+ progress?.skipStep('gh-auth-verify')
717
+ if (progress === undefined) {
718
+ process.stderr.write('Warning: Host GitHub CLI token unavailable; skipping container auth refresh.\n')
719
+ } else {
720
+ progress.warn('Host GitHub CLI token unavailable; skipping container auth refresh.')
721
+ }
340
722
  return
341
723
  }
342
724
 
725
+ options.logger?.addRedaction(token)
726
+
343
727
  const cli = resolveDevcontainerCli(context)
344
- const login = await runBuffered(cli.command, [
728
+ const loginArgs = [
345
729
  ...cli.argsPrefix,
346
730
  'exec',
347
731
  ...devcontainerWorkspaceArgs(context),
@@ -355,23 +739,67 @@ export async function refreshContainerGhAuth (context: WorkspaceContext): Promis
355
739
  'https',
356
740
  '--with-token',
357
741
  '--insecure-storage'
358
- ], {
359
- input: `${token}\n`,
360
- mirrorStdout: false,
361
- mirrorStderr: false
362
- })
742
+ ]
743
+ const login = progress === undefined
744
+ ? await runBuffered(cli.command, loginArgs, {
745
+ input: `${token}\n`,
746
+ logger: options.logger,
747
+ mirrorStdout: false,
748
+ mirrorStderr: false
749
+ })
750
+ : await runProgressCommand('refresh GitHub CLI auth', cli.command, loginArgs, {
751
+ input: `${token}\n`,
752
+ progress,
753
+ logger: options.logger,
754
+ spinnerLabel: 'Refreshing GitHub CLI auth inside the devcontainer',
755
+ stepId: 'gh-auth-refresh',
756
+ verboseStdout: false,
757
+ verboseStderr: false
758
+ })
363
759
 
364
760
  if (login.code !== 0) {
365
- process.stderr.write('Warning: could not refresh GitHub CLI auth inside the devcontainer.\n')
761
+ progress?.skipStep('gh-git-auth')
762
+ progress?.skipStep('gh-auth-verify')
763
+ if (progress === undefined) {
764
+ process.stderr.write('Warning: could not refresh GitHub CLI auth inside the devcontainer.\n')
765
+ } else {
766
+ progress.warn('Could not refresh GitHub CLI auth inside the devcontainer.')
767
+ }
366
768
  return
367
769
  }
368
770
 
369
- const gitAuthConfigured = await configureWorkspaceGithubGitAuth(context.workspaceFolder)
771
+ if (hasGitAuthStep) {
772
+ progress?.startStep('gh-git-auth')
773
+ } else {
774
+ progress?.item('Configuring workspace GitHub Git auth')
775
+ }
776
+
777
+ let gitAuthConfigured: boolean
778
+ try {
779
+ gitAuthConfigured = await configureWorkspaceGithubGitAuth(context.workspaceFolder)
780
+ if (hasGitAuthStep) {
781
+ if (gitAuthConfigured) {
782
+ progress?.completeStep('gh-git-auth')
783
+ } else {
784
+ progress?.failStep('gh-git-auth')
785
+ }
786
+ }
787
+ } catch (error) {
788
+ if (hasGitAuthStep) {
789
+ progress?.failStep('gh-git-auth')
790
+ }
791
+ throw error
792
+ }
793
+
370
794
  if (!gitAuthConfigured) {
371
- process.stderr.write('Warning: GitHub CLI auth refreshed, but GitHub Git auth was not configured for this workspace.\n')
795
+ if (progress === undefined) {
796
+ process.stderr.write('Warning: GitHub CLI auth refreshed, but GitHub Git auth was not configured for this workspace.\n')
797
+ } else {
798
+ progress.warn('GitHub CLI auth refreshed, but GitHub Git auth was not configured for this workspace.')
799
+ }
372
800
  }
373
801
 
374
- const verify = await runBuffered(cli.command, [
802
+ const verifyArgs = [
375
803
  ...cli.argsPrefix,
376
804
  'exec',
377
805
  ...devcontainerWorkspaceArgs(context),
@@ -381,14 +809,33 @@ export async function refreshContainerGhAuth (context: WorkspaceContext): Promis
381
809
  'status',
382
810
  '--hostname',
383
811
  'github.com'
384
- ], {
385
- mirrorStdout: false,
386
- mirrorStderr: false
387
- })
812
+ ]
813
+ const verify = progress === undefined
814
+ ? await runBuffered(cli.command, verifyArgs, {
815
+ mirrorStdout: false,
816
+ mirrorStderr: false,
817
+ logger: options.logger
818
+ })
819
+ : await runProgressCommand('verify GitHub CLI auth', cli.command, verifyArgs, {
820
+ progress,
821
+ logger: options.logger,
822
+ spinnerLabel: 'Verifying GitHub CLI auth inside the devcontainer',
823
+ stepId: 'gh-auth-verify',
824
+ verboseStdout: false,
825
+ verboseStderr: false
826
+ })
388
827
 
389
828
  if (verify.code === 0) {
390
- process.stdout.write('GitHub CLI auth refreshed inside the devcontainer.\n')
829
+ if (progress === undefined) {
830
+ process.stdout.write('GitHub CLI auth refreshed inside the devcontainer.\n')
831
+ } else if (!progress.hasStep('gh-auth-verify')) {
832
+ progress.item('GitHub CLI auth refreshed inside the devcontainer')
833
+ }
391
834
  } else {
392
- process.stderr.write('Warning: GitHub CLI auth refresh completed, but verification failed.\n')
835
+ if (progress === undefined) {
836
+ process.stderr.write('Warning: GitHub CLI auth refresh completed, but verification failed.\n')
837
+ } else {
838
+ progress.warn('GitHub CLI auth refresh completed, but verification failed.')
839
+ }
393
840
  }
394
841
  }