boxdown 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +10 -0
  2. package/assets/devcontainer/README.md +29 -6
  3. package/assets/devcontainer/devcontainer.json +4 -9
  4. package/assets/devcontainer/hooks/initialize.sh +49 -27
  5. package/assets/devcontainer/hooks/post-create.sh +23 -1
  6. package/assets/devcontainer/hooks/post-start.sh +0 -10
  7. package/assets/devcontainer/utils/git-signing-bootstrap.sh +58 -5
  8. package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
  9. package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
  10. package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
  11. package/dist/bin/cli.cjs +1 -1
  12. package/dist/bin/cli.mjs +1 -1
  13. package/dist/{main-Df4E8ARj.cjs → main-CT2n9qcb.cjs} +907 -266
  14. package/dist/{main-XMBsKjIK.mjs → main-O__JaiXe.mjs} +891 -268
  15. package/dist/main-O__JaiXe.mjs.map +1 -0
  16. package/dist/main.cjs +4 -1
  17. package/dist/main.d.cts +98 -26
  18. package/dist/main.d.cts.map +1 -1
  19. package/dist/main.d.mts +98 -26
  20. package/dist/main.d.mts.map +1 -1
  21. package/dist/main.mjs +2 -2
  22. package/docs/features/commit-signing.md +71 -0
  23. package/docs/features/generated-config-and-state.md +19 -4
  24. package/docs/features/github-auth-refresh.md +3 -2
  25. package/docs/features/lifecycle.md +19 -0
  26. package/docs/features/setup.md +5 -0
  27. package/docs/features/start-and-shell.md +10 -0
  28. package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
  29. package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
  30. package/docs/superpowers/plans/2026-07-18-architecture-aware-1password-installer.md +163 -0
  31. package/docs/superpowers/plans/2026-07-18-devcontainer-node-image-digest.md +341 -0
  32. package/docs/superpowers/plans/2026-07-19-container-runtime-readiness.md +1536 -0
  33. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +20 -0
  34. package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
  35. package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
  36. package/docs/superpowers/specs/2026-07-18-architecture-aware-1password-installer-design.md +63 -0
  37. package/docs/superpowers/specs/2026-07-18-devcontainer-node-image-digest-design.md +108 -0
  38. package/docs/superpowers/specs/2026-07-19-container-runtime-readiness-design.md +353 -0
  39. package/package.json +1 -1
  40. package/src/config.ts +14 -2
  41. package/src/constants.ts +7 -0
  42. package/src/container-runtime.ts +295 -0
  43. package/src/devcontainer.ts +57 -30
  44. package/src/doctor.ts +169 -46
  45. package/src/git-signing.ts +205 -25
  46. package/src/logging.ts +11 -1
  47. package/src/main.ts +97 -12
  48. package/src/paths.ts +21 -1
  49. package/src/process.ts +50 -10
  50. package/src/progress.ts +63 -8
  51. package/src/purge.ts +8 -0
  52. package/src/shell.ts +6 -0
  53. package/dist/main-XMBsKjIK.mjs.map +0 -1
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,7 +14,8 @@ 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'
16
- import { purgeWorkspace } from './purge.ts'
17
+ import { runBuffered } from './process.ts'
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'
19
21
  import { createStatusInfo, formatStatusText, statusIsHealthy } from './status.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[] = []
@@ -831,6 +845,7 @@ async function runDownCommand (workspaces: string[] | undefined, options: RunCli
831
845
  const context = createWorkspaceContext({ workspace })
832
846
  await runLoggedLifecycle(context, 'down', ['down', ...(workspace === undefined ? [] : ['--workspace', workspace])], async (logger) => {
833
847
  await removeWorkspaceContainer(context, { logger })
848
+ removeWorkspaceRuntimeState(context)
834
849
  })
835
850
  } catch (error) {
836
851
  failed = true
@@ -1056,7 +1071,7 @@ function createCliProgress (
1056
1071
  })
1057
1072
  }
1058
1073
 
1059
- function startProgressSteps (): ProgressStepDefinition[] {
1074
+ function devcontainerStartProgressSteps (): ProgressStepDefinition[] {
1060
1075
  return [
1061
1076
  { id: 'ssh-identity', label: 'Preparing SSH identity' },
1062
1077
  { id: 'devcontainer-config', label: 'Writing generated devcontainer config' },
@@ -1064,6 +1079,13 @@ function startProgressSteps (): ProgressStepDefinition[] {
1064
1079
  ]
1065
1080
  }
1066
1081
 
1082
+ function startProgressSteps (): ProgressStepDefinition[] {
1083
+ return [
1084
+ { id: 'container-runtime', label: 'Checking container runtime' },
1085
+ ...devcontainerStartProgressSteps()
1086
+ ]
1087
+ }
1088
+
1067
1089
  function sshTargetProgressLabel (target: SshConfigInstallTarget): string {
1068
1090
  const label = SSH_INSTALL_TARGETS.find((candidate) => candidate.value === target)?.label ?? target
1069
1091
  return `Installing ${label} SSH target`
@@ -1071,7 +1093,7 @@ function sshTargetProgressLabel (target: SshConfigInstallTarget): string {
1071
1093
 
1072
1094
  function setupProgressSteps (targets: readonly SshConfigInstallTarget[]): ProgressStepDefinition[] {
1073
1095
  return [
1074
- ...startProgressSteps(),
1096
+ ...devcontainerStartProgressSteps(),
1075
1097
  { id: 'ssh-alias', label: 'Installing SSH alias' },
1076
1098
  ...targets.map((target) => ({
1077
1099
  id: `ssh-target:${target}`,
@@ -1081,7 +1103,10 @@ function setupProgressSteps (targets: readonly SshConfigInstallTarget[]): Progre
1081
1103
  }
1082
1104
 
1083
1105
  function setupPreflightProgressSteps (): ProgressStepDefinition[] {
1084
- return [{ id: 'setup-preflight', label: 'Checking host readiness' }]
1106
+ return [
1107
+ { id: 'container-runtime', label: 'Checking container runtime' },
1108
+ { id: 'setup-preflight', label: 'Checking host readiness' }
1109
+ ]
1085
1110
  }
1086
1111
 
1087
1112
  function setupPreflightFailureMessage (checks: DoctorCheck[]): string {
@@ -1092,6 +1117,62 @@ function setupPreflightFailureMessage (checks: DoctorCheck[]): string {
1092
1117
  return `Setup preflight failed:\n${failures.join('\n')}`
1093
1118
  }
1094
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
+
1095
1176
  async function runSetupPreflight (
1096
1177
  context: WorkspaceContext,
1097
1178
  alias: string,
@@ -1106,8 +1187,12 @@ async function runSetupPreflight (
1106
1187
  `SSH alias: ${alias}`
1107
1188
  ], async () => {
1108
1189
  progress.setSteps(setupPreflightProgressSteps())
1190
+ await runContainerRuntimePreflight(context, progress, options)
1109
1191
  progress.startStep('setup-preflight')
1110
- const checks = await doctor(context, { includeOptional: false })
1192
+ const checks = await doctor(context, {
1193
+ includeOptional: false,
1194
+ containerRuntimeReady: true
1195
+ })
1111
1196
 
1112
1197
  for (const check of checks.filter((item) => item.level === 'warn')) {
1113
1198
  progress.warn(`${check.name}: ${check.message}`)
@@ -1220,10 +1305,6 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1220
1305
  const alias = parsed.alias ?? defaultSshAlias(context.workspaceBasename)
1221
1306
  const aliasSource = parsed.alias === undefined ? 'default' : 'provided'
1222
1307
 
1223
- if (parsed.command !== 'ssh-install' && parsed.command !== 'setup' && parsed.command !== 'tunnel' && commandWritesWorkspaceMetadata(parsed.command)) {
1224
- writeWorkspaceMetadata(context, alias)
1225
- }
1226
-
1227
1308
  if (parsed.command === 'ssh-install') {
1228
1309
  const resolvedTargets = await resolveSshInstallTargets(parsed, options)
1229
1310
 
@@ -1363,6 +1444,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1363
1444
  `SSH alias: ${alias}`
1364
1445
  ], async () => {
1365
1446
  progress.setSteps(sshProxyProgressSteps())
1447
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1366
1448
  progress.startStep('ssh-alias')
1367
1449
  try {
1368
1450
  await installSshConfig(context, alias, { quiet: true })
@@ -1399,7 +1481,6 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1399
1481
  throw new Error('tunnel requires at least one --port value')
1400
1482
  }
1401
1483
 
1402
- writeWorkspaceMetadata(context, alias)
1403
1484
  return runLoggedLifecycle(context, 'tunnel', argv, async (logger) => {
1404
1485
  const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1405
1486
  await withProgressSection(progress, 'Boxdown tunnel', [
@@ -1407,6 +1488,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1407
1488
  `SSH alias: ${alias}`
1408
1489
  ], async () => {
1409
1490
  progress.setSteps(tunnelProgressSteps())
1491
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1410
1492
  progress.startStep('ssh-alias')
1411
1493
  try {
1412
1494
  await installSshConfig(context, alias, { quiet: true })
@@ -1436,7 +1518,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1436
1518
 
1437
1519
  if (parsed.command === 'refresh-gh-token-running') {
1438
1520
  return runLoggedLifecycle(context, 'refresh-gh-token-running', argv, async (logger) => {
1439
- const containerId = await findRunningContainerId(context, { logger })
1521
+ const containerId = await (options.findRunningContainerId ?? findRunningContainerId)(context, { logger })
1440
1522
  if (containerId === undefined) {
1441
1523
  throw new Error(`No running devcontainer found for: ${context.workspaceFolder}`)
1442
1524
  }
@@ -1460,6 +1542,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1460
1542
  `Workspace: ${context.workspaceFolder}`
1461
1543
  ], async () => {
1462
1544
  progress.setSteps(ghAuthProgressSteps(true))
1545
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1463
1546
  await startDevcontainer(context, { progress, logger })
1464
1547
  await refreshContainerGhAuth(context, { progress, logger })
1465
1548
  })
@@ -1479,6 +1562,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1479
1562
  `Workspace: ${context.workspaceFolder}`
1480
1563
  ], async () => {
1481
1564
  progress.setSteps(codingAgentProgressSteps(agent))
1565
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1482
1566
  await startDevcontainer(context, {
1483
1567
  recreate: parsed.recreate,
1484
1568
  progress,
@@ -1496,6 +1580,7 @@ export async function runCli (argv: string[] = process.argv.slice(2), options: R
1496
1580
  `Workspace: ${context.workspaceFolder}`
1497
1581
  ], async () => {
1498
1582
  progress.setSteps(startProgressSteps())
1583
+ await (options.prepareContainerLifecycle ?? prepareContainerLifecycle)(context, alias, progress, options, logger)
1499
1584
  return await startDevcontainer(context, {
1500
1585
  recreate: parsed.recreate,
1501
1586
  progress,
package/src/paths.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { existsSync, realpathSync, statSync } from 'node:fs'
3
- import { homedir } from 'node:os'
3
+ import { homedir, tmpdir } from 'node:os'
4
4
  import { basename, dirname, join, resolve } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
 
@@ -22,8 +22,11 @@ export interface WorkspaceContext {
22
22
  assetsDevcontainerDir: string
23
23
  cacheRoot: string
24
24
  dataRoot: string
25
+ runtimeRoot: string
25
26
  workspaceCacheDir: string
26
27
  workspaceDataDir: string
28
+ workspaceRuntimeDir: string
29
+ workspaceSecretEnvDir: string
27
30
  generatedConfigPath: string
28
31
  hostAgentsDir: string
29
32
  hostCodexAuthPath: string
@@ -92,6 +95,18 @@ export function defaultDataRoot (env: NodeJS.ProcessEnv = process.env): string {
92
95
  return join(homedir(), '.local', 'share', PACKAGE_NAME)
93
96
  }
94
97
 
98
+ export function defaultRuntimeRoot (env: NodeJS.ProcessEnv = process.env): string {
99
+ if (env.BOXDOWN_RUNTIME_HOME) {
100
+ return env.BOXDOWN_RUNTIME_HOME
101
+ }
102
+
103
+ if (env.XDG_RUNTIME_DIR) {
104
+ return join(env.XDG_RUNTIME_DIR, PACKAGE_NAME)
105
+ }
106
+
107
+ return join(tmpdir(), `${PACKAGE_NAME}-${process.getuid?.() ?? 'user'}`)
108
+ }
109
+
95
110
  export function defaultHostAgentsDir (env: NodeJS.ProcessEnv = process.env): string {
96
111
  return join(env.HOME ?? homedir(), '.agents')
97
112
  }
@@ -113,10 +128,12 @@ export function createWorkspaceContextFromIdentity (
113
128
  const assetsDevcontainerDir = options.assetsDevcontainerDir ?? env.BOXDOWN_DEVCONTAINER_ASSETS_DIR ?? join(packageRoot, 'assets', 'devcontainer')
114
129
  const cacheRoot = defaultCacheRoot(env)
115
130
  const dataRoot = defaultDataRoot(env)
131
+ const runtimeRoot = defaultRuntimeRoot(env)
116
132
  const hostAgentsDir = defaultHostAgentsDir(env)
117
133
  const hostCodexAuthPath = defaultHostCodexAuthPath(env)
118
134
  const workspaceCacheDir = join(cacheRoot, 'workspaces', identity.workspaceId)
119
135
  const workspaceDataDir = join(dataRoot, 'workspaces', identity.workspaceId)
136
+ const workspaceRuntimeDir = join(runtimeRoot, 'workspaces', identity.workspaceId)
120
137
  const hostGitconfigSnapshotDir = join(workspaceDataDir, 'gitconfig')
121
138
 
122
139
  return {
@@ -127,8 +144,11 @@ export function createWorkspaceContextFromIdentity (
127
144
  assetsDevcontainerDir,
128
145
  cacheRoot,
129
146
  dataRoot,
147
+ runtimeRoot,
130
148
  workspaceCacheDir,
131
149
  workspaceDataDir,
150
+ workspaceRuntimeDir,
151
+ workspaceSecretEnvDir: join(workspaceRuntimeDir, 'secrets'),
132
152
  generatedConfigPath: join(workspaceCacheDir, 'devcontainer.json'),
133
153
  hostAgentsDir,
134
154
  hostCodexAuthPath,
package/src/process.ts CHANGED
@@ -12,12 +12,25 @@ export interface BufferedCommandOptions {
12
12
  logger?: WorkspaceCommandLogger
13
13
  onStdout?: (chunk: Buffer) => void
14
14
  onStderr?: (chunk: Buffer) => void
15
+ timeoutMs?: number
16
+ timeoutControl?: BufferedTimeoutControl
15
17
  }
16
18
 
17
19
  export interface CommandResult {
18
20
  code: number
19
21
  stdout: string
20
22
  stderr: string
23
+ timedOut?: boolean
24
+ }
25
+
26
+ export interface BufferedTimeoutControl {
27
+ schedule: (callback: () => void, milliseconds: number) => unknown
28
+ cancel: (handle: unknown) => void
29
+ }
30
+
31
+ const realTimeoutControl: BufferedTimeoutControl = {
32
+ schedule: (callback, milliseconds) => setTimeout(callback, milliseconds),
33
+ cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>)
21
34
  }
22
35
 
23
36
  function mergedEnv (env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
@@ -84,6 +97,10 @@ function writeChunk (target: 'stdout' | 'stderr' | false, chunk: Buffer): void {
84
97
  }
85
98
 
86
99
  export function runBuffered (command: string, args: string[], options: BufferedCommandOptions = {}): Promise<CommandResult> {
100
+ if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0)) {
101
+ throw new Error('timeoutMs must be a finite non-negative number')
102
+ }
103
+
87
104
  return new Promise((resolve) => {
88
105
  const loggedCommand = options.logger?.startCommand(command, args, { cwd: options.cwd })
89
106
  const child = spawn(command, args, {
@@ -94,8 +111,13 @@ export function runBuffered (command: string, args: string[], options: BufferedC
94
111
 
95
112
  const stdoutChunks: Buffer[] = []
96
113
  const stderrChunks: Buffer[] = []
114
+ let settled = false
115
+ let timeoutHandle: unknown
116
+ let timeoutScheduled = false
117
+ const timeoutControl = options.timeoutControl ?? realTimeoutControl
97
118
 
98
119
  child.stdout.on('data', (chunk: Buffer) => {
120
+ if (settled) return
99
121
  stdoutChunks.push(chunk)
100
122
  loggedCommand?.stream('stdout', chunk)
101
123
  options.onStdout?.(chunk)
@@ -103,20 +125,18 @@ export function runBuffered (command: string, args: string[], options: BufferedC
103
125
  })
104
126
 
105
127
  child.stderr.on('data', (chunk: Buffer) => {
128
+ if (settled) return
106
129
  stderrChunks.push(chunk)
107
130
  loggedCommand?.stream('stderr', chunk)
108
131
  options.onStderr?.(chunk)
109
132
  writeChunk(options.mirrorStderr ?? 'stderr', chunk)
110
133
  })
111
134
 
112
- let resolved = false
113
-
114
135
  child.on('error', (error) => {
115
- if (resolved) {
116
- return
117
- }
136
+ if (settled) return
118
137
 
119
- resolved = true
138
+ settled = true
139
+ if (timeoutScheduled) timeoutControl.cancel(timeoutHandle)
120
140
  loggedCommand?.error(error)
121
141
  loggedCommand?.finish(127)
122
142
  resolve({
@@ -127,11 +147,10 @@ export function runBuffered (command: string, args: string[], options: BufferedC
127
147
  })
128
148
 
129
149
  child.on('close', (code) => {
130
- if (resolved) {
131
- return
132
- }
150
+ if (settled) return
133
151
 
134
- resolved = true
152
+ settled = true
153
+ if (timeoutScheduled) timeoutControl.cancel(timeoutHandle)
135
154
  loggedCommand?.finish(code ?? 1)
136
155
  resolve({
137
156
  code: code ?? 1,
@@ -140,6 +159,27 @@ export function runBuffered (command: string, args: string[], options: BufferedC
140
159
  })
141
160
  })
142
161
 
162
+ if (options.timeoutMs !== undefined) {
163
+ timeoutHandle = timeoutControl.schedule(() => {
164
+ if (settled) return
165
+
166
+ settled = true
167
+ const message = `Command timed out after ${options.timeoutMs as number} milliseconds.`
168
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8')
169
+ const stderr = Buffer.concat(stderrChunks).toString('utf8')
170
+ loggedCommand?.error(new Error(message))
171
+ loggedCommand?.finish(124)
172
+ child.kill('SIGKILL')
173
+ resolve({
174
+ code: 124,
175
+ stdout,
176
+ stderr: `${stderr}${stderr.length > 0 && !stderr.endsWith('\n') ? '\n' : ''}${message}\n`,
177
+ timedOut: true
178
+ })
179
+ }, options.timeoutMs)
180
+ timeoutScheduled = true
181
+ }
182
+
143
183
  if (options.input !== undefined) {
144
184
  child.stdin.end(options.input)
145
185
  } else {
package/src/progress.ts CHANGED
@@ -178,6 +178,16 @@ export class ProgressReporter {
178
178
  this.#writeLine(`${promptRail()} ${color(message, 'dim')}`)
179
179
  }
180
180
 
181
+ status (message: string): void {
182
+ if (this.mode === 'none') return
183
+ if (this.mode === 'verbose') {
184
+ this.#write(this.target, message)
185
+ return
186
+ }
187
+
188
+ this.#writeInteractiveLine(`${promptRail()} ${color(message, 'dim')}`)
189
+ }
190
+
181
191
  warn (message: string): void {
182
192
  if (this.mode === 'none') {
183
193
  return
@@ -188,7 +198,7 @@ export class ProgressReporter {
188
198
  return
189
199
  }
190
200
 
191
- this.#writeLine(`${promptRail()} ${color('!', 'dim')} ${message}`)
201
+ this.#writeInteractiveLine(`${promptRail()} ${color('!', 'dim')} ${message}`)
192
202
  }
193
203
 
194
204
  marker (message: string): void {
@@ -329,6 +339,18 @@ export class ProgressReporter {
329
339
  this.#write(this.target, message)
330
340
  }
331
341
 
342
+ #writeInteractiveLine (message: string): void {
343
+ if (this.#isTTY && this.#renderedStepLineCount > 0) {
344
+ this.#writeRaw(this.target, `\u001B[${this.#renderedStepLineCount}A\r\u001B[2K`)
345
+ this.#write(this.target, message)
346
+ this.#renderedStepLineCount = 0
347
+ this.#renderChecklist()
348
+ return
349
+ }
350
+
351
+ this.#writeLine(message)
352
+ }
353
+
332
354
  #renderSpinner (): void {
333
355
  const spinner = this.#spinner
334
356
  if (spinner === undefined) {
@@ -505,18 +527,38 @@ function outputWithoutProgressMarkers (output: string): string {
505
527
  .join('\n')
506
528
  }
507
529
 
508
- function tailLines (output: string, maxLines: number): string[] {
530
+ export interface CommandFailureOptions {
531
+ tailLines?: number
532
+ logPath?: string
533
+ }
534
+
535
+ function outputLines (output: string): string[] {
509
536
  return output
510
537
  .split(/\r?\n/u)
511
538
  .map((line) => line.trimEnd())
512
539
  .filter((line) => line.trim().length > 0)
513
- .slice(-maxLines)
514
540
  }
515
541
 
516
- export function formatCommandFailure (label: string, result: CommandResult, options: { tailLines?: number } = {}): string {
542
+ function isDevcontainerErrorEnvelope (line: string): boolean {
543
+ try {
544
+ const value = JSON.parse(line) as { outcome?: unknown, message?: unknown }
545
+ return value !== null && typeof value === 'object' &&
546
+ value.outcome === 'error' && typeof value.message === 'string'
547
+ } catch {
548
+ return false
549
+ }
550
+ }
551
+
552
+ export function formatCommandFailure (label: string, result: CommandResult, options: CommandFailureOptions = {}): string {
517
553
  const maxLines = options.tailLines ?? DEFAULT_FAILURE_TAIL_LINES
518
- const stderrTail = tailLines(outputWithoutProgressMarkers(result.stderr), maxLines)
519
- const stdoutTail = tailLines(outputWithoutProgressMarkers(result.stdout), Math.max(0, maxLines - stderrTail.length))
554
+ const stderrLines = outputLines(outputWithoutProgressMarkers(result.stderr))
555
+ const stderrTail = maxLines <= 0 ? [] : stderrLines.slice(-maxLines)
556
+ const stdoutLines = outputLines(outputWithoutProgressMarkers(result.stdout))
557
+ const wrapperPresent = stdoutLines.some(isDevcontainerErrorEnvelope)
558
+ const specificStdout = stdoutLines.filter((line) => !isDevcontainerErrorEnvelope(line))
559
+ const hasSpecificDiagnostic = stderrLines.length > 0 || specificStdout.length > 0
560
+ const stdoutBudget = Math.max(0, maxLines - stderrTail.length)
561
+ const stdoutTail = stdoutBudget === 0 ? [] : specificStdout.slice(-stdoutBudget)
520
562
  const lines = [
521
563
  `${label} failed with exit code ${result.code}.`,
522
564
  'Rerun with --verbose to see full command output.'
@@ -530,6 +572,14 @@ export function formatCommandFailure (label: string, result: CommandResult, opti
530
572
  lines.push('', 'stdout tail:', ...stdoutTail.map((line) => ` ${line}`))
531
573
  }
532
574
 
575
+ if (wrapperPresent && !hasSpecificDiagnostic) {
576
+ lines.push('', 'The Dev Containers CLI reported a nested command failure without diagnostic output.')
577
+ }
578
+
579
+ if (options.logPath !== undefined) {
580
+ lines.push('', `Command log: ${options.logPath}`)
581
+ }
582
+
533
583
  return lines.join('\n')
534
584
  }
535
585
 
@@ -586,8 +636,13 @@ export async function runProgressCommand (
586
636
  }
587
637
  }
588
638
 
589
- export function assertProgressCommandSucceeded (label: string, result: CommandResult, message: string): void {
639
+ export function assertProgressCommandSucceeded (
640
+ label: string,
641
+ result: CommandResult,
642
+ message: string,
643
+ options: CommandFailureOptions = {}
644
+ ): void {
590
645
  if (result.code !== 0) {
591
- throw new Error(`${message}\n${formatCommandFailure(label, result)}`)
646
+ throw new Error(`${message}\n${formatCommandFailure(label, result, options)}`)
592
647
  }
593
648
  }
package/src/purge.ts CHANGED
@@ -85,6 +85,10 @@ function removeWorkspaceStateDir (
85
85
  process.stdout.write(`Removed ${label}: ${path}\n`)
86
86
  }
87
87
 
88
+ export function removeWorkspaceRuntimeState (context: WorkspaceContext): void {
89
+ removeWorkspaceStateDir(context, 'workspace runtime directory', context.workspaceRuntimeDir, context.runtimeRoot)
90
+ }
91
+
88
92
  async function purgeAliasIntegrations (context: WorkspaceContext, alias: string): Promise<boolean> {
89
93
  let failed = false
90
94
 
@@ -194,6 +198,10 @@ export async function purgeWorkspace (context: WorkspaceContext, options: PurgeO
194
198
  }) || failed
195
199
  }
196
200
 
201
+ failed = await runPurgeStep('workspace runtime directory', () => {
202
+ removeWorkspaceRuntimeState(context)
203
+ }) || failed
204
+
197
205
  failed = await runPurgeStep('workspace cache directory', () => {
198
206
  removeWorkspaceStateDir(context, 'workspace cache', context.workspaceCacheDir, context.cacheRoot)
199
207
  }) || failed
package/src/shell.ts CHANGED
@@ -61,10 +61,15 @@ function interactiveAgentPathScript (): string {
61
61
  ].join('\n')
62
62
  }
63
63
 
64
+ function runtimeSecretEnvironmentScript (): string {
65
+ return 'source "${BASH_ENV:-/opt/boxdown/devcontainer/utils/secret-env-bootstrap.sh}"'
66
+ }
67
+
64
68
  export function interactiveShellScript (): string {
65
69
  return [
66
70
  interactiveTermSetupScript(),
67
71
  interactiveTtySetupScript(),
72
+ runtimeSecretEnvironmentScript(),
68
73
  'exec bash -i'
69
74
  ].join('\n')
70
75
  }
@@ -74,6 +79,7 @@ export function interactiveCommandScript (): string {
74
79
  interactiveTermSetupScript(),
75
80
  interactiveTtySetupScript(),
76
81
  interactiveAgentPathScript(),
82
+ runtimeSecretEnvironmentScript(),
77
83
  'exec "$@"'
78
84
  ].join('\n')
79
85
  }