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
package/src/main.ts CHANGED
@@ -1,24 +1,39 @@
1
1
  import { existsSync } from 'node:fs'
2
2
 
3
- import { codingAgentFromCommand, type CodingAgentCli } from './coding-agents.ts'
4
- import { doctorHasFailures, formatDoctorText, runDoctorChecks } from './doctor.ts'
5
- import { startDevcontainer, printPortHint, openShell, openCodingAgentCli, ensureContainerSshRuntime, runSshdProxy, refreshContainerGhAuth, refreshContainerCodingAgentClis, findRunningContainerId, findWorkspaceContainer, stopWorkspaceContainer, removeWorkspaceContainer, listWorkspaceContainers } from './devcontainer.ts'
6
- import { createWorkspaceListEntries, formatWorkspaceListText } from './list.ts'
7
- import { listWorkspaceMetadata, writeWorkspaceMetadata } from './metadata.ts'
8
- import { createWorkspaceContext, defaultDataRoot } from './paths.ts'
9
- import { defaultSshAlias, installSshConfig } from './ssh-config.ts'
3
+ import { claudeSshConfigEntryForWorkspace, uninstallClaudeSshConfigHost } from './claude-app-config.ts'
4
+ import { codexProjectEntryForWorkspace, legacyCodexRemotePathForWorkspace, uninstallCodexAppConfigProject, uninstallCodexGlobalStateProject } from './codex-app-config.ts'
5
+ import { codingAgentBinary, codingAgentFromCommand, type CodingAgentCli } from './coding-agents.ts'
6
+ import { buildGeneratedDevcontainerConfig, publishContainerPortFromConfig } from './config.ts'
7
+ import { doctorHasFailures, formatDoctorText, runDoctorChecks, type DoctorCheck } from './doctor.ts'
8
+ import { startDevcontainer, printPortHint, openShell, openCodingAgentCli, ensureContainerSshRuntime, runSshdProxy, refreshContainerGhAuth, refreshContainerCodingAgentClis, ensureContainerCodingAgentCli, findRunningContainerId, findWorkspaceContainer, stopWorkspaceContainer, removeWorkspaceContainer, listWorkspaceContainers, openSshTunnel, type TunnelPortForward } from './devcontainer.ts'
9
+ import { canPromptInteractively, promptConfirm, promptMultiSelect, promptText, type PromptInput, type PromptOutput } from './interactive-prompts.ts'
10
+ import { createWorkspaceListEntries, formatWorkspaceListDetailsText, formatWorkspaceListText } from './list.ts'
11
+ import { createWorkspaceCommandLogger, withLoggedProcessOutput, type WorkspaceCommandLogger } from './logging.ts'
12
+ import { listWorkspaceMetadata, readWorkspaceMetadata, writeWorkspaceMetadata, type WorkspaceMetadata } from './metadata.ts'
13
+ import { readPackageVersion } from './package-info.ts'
14
+ import { createWorkspaceContext, createWorkspaceContextFromIdentity, defaultDataRoot, type WorkspaceContext } from './paths.ts'
15
+ import { createProgress, resolveProgressMode, type ProgressReporter, type ProgressOutputTarget, type ProgressStepDefinition } from './progress.ts'
16
+ import { purgeWorkspace, removeWorkspaceRuntimeState } from './purge.ts'
17
+ import { defaultSshAlias, installSshConfig, uninstallSshConfig } from './ssh-config.ts'
18
+ import { dedupeSshInstallTargets, installSshInstallTarget, isSshConfigInstallTarget, SSH_INSTALL_TARGETS, sshInstallTargetFlagHintsText, supportedSshInstallTargetsText, type SshConfigInstallTarget } from './ssh-install-targets.ts'
10
19
  import { createStatusInfo, formatStatusText, statusIsHealthy } from './status.ts'
20
+ import type { CliColor } from './cli-style.ts'
11
21
 
12
22
  export type BoxdownCommand =
13
23
  | 'help'
24
+ | 'version'
25
+ | 'setup'
14
26
  | 'start'
15
27
  | 'list'
16
28
  | 'status'
17
29
  | 'stop'
18
30
  | 'down'
31
+ | 'purge'
19
32
  | 'doctor'
20
- | 'ssh-config-install'
33
+ | 'ssh-install'
34
+ | 'ssh-uninstall'
21
35
  | 'ssh-proxy'
36
+ | 'tunnel'
22
37
  | 'refresh-gh-token'
23
38
  | 'refresh-gh-token-running'
24
39
  | 'coding-agent'
@@ -28,38 +43,57 @@ export interface ParsedCli {
28
43
  agent?: CodingAgentCli
29
44
  agentArgs?: string[]
30
45
  workspace?: string
46
+ workspaces?: string[]
31
47
  alias?: string
48
+ targets?: SshConfigInstallTarget[]
49
+ tunnelPorts?: TunnelPortForward[]
32
50
  recreate: boolean
33
51
  json: boolean
52
+ details?: boolean
53
+ verbose: boolean
54
+ }
55
+
56
+ export interface RunCliOptions {
57
+ promptInput?: PromptInput
58
+ promptOutput?: PromptOutput
59
+ env?: NodeJS.ProcessEnv
60
+ runDoctorChecks?: typeof runDoctorChecks
61
+ setupWorkspace?: typeof setupWorkspace
34
62
  }
35
63
 
36
64
  export const USAGE = `Usage:
65
+ boxdown setup [--workspace <path>] [--alias <name>] [--recreate] [--target <name>]...
37
66
  boxdown start [--workspace <path>] [--recreate]
38
67
  boxdown codex [--workspace <path>] [--recreate] [-- <codex args...>]
39
68
  boxdown claude [--workspace <path>] [--recreate] [-- <claude args...>]
40
- boxdown cc [--workspace <path>] [--recreate] [-- <claude args...>]
41
69
  boxdown opencode [--workspace <path>] [--recreate] [-- <opencode args...>]
42
70
  boxdown antigravity [--workspace <path>] [--recreate] [-- <agy args...>]
43
- boxdown list [--json]
44
- boxdown status [--workspace <path>] [--alias <name>] [--json]
71
+ boxdown list [--details] [--json|--format json]
72
+ boxdown status [--workspace <path>] [--alias <name>] [--json|--format json]
45
73
  boxdown stop [--workspace <path>]
46
- boxdown down [--workspace <path>]
74
+ boxdown down [--workspace <path>]...
75
+ boxdown purge [--workspace <path|ssh-alias|repo>] [--alias <name>]
47
76
  boxdown doctor [--workspace <path>]
48
- boxdown ssh-config install [--workspace <path>] [--alias <name>]
77
+ boxdown ssh install [--workspace <path>] [--alias <name>] [--target <name>]...
78
+ boxdown ssh uninstall [--workspace <path>] [--alias <name>]
49
79
  boxdown ssh-proxy [--workspace <path>] [--alias <name>]
80
+ boxdown tunnel [--port <port>] [--port <local:remote>] [--workspace <path>] [--alias <name>]
50
81
  boxdown refresh-gh-token [--workspace <path>]
51
82
  boxdown refresh-gh-token-running [--workspace <path>]
52
83
 
53
84
  Commands:
54
- start Start or reuse the workspace devcontainer, then open
55
- an interactive shell inside it. Alias: shell.
85
+ setup Prepare the workspace devcontainer and SSH/app
86
+ integration without opening a shell.
87
+ start, shell Start or reuse the workspace devcontainer, then open
88
+ an interactive shell inside it.
56
89
  codex Start or reuse the devcontainer, then launch Codex.
57
- claude Start or reuse the devcontainer, then launch Claude
58
- Code. Alias: cc.
90
+ claude, cc Start or reuse the devcontainer, then launch Claude
91
+ Code.
59
92
  opencode Start or reuse the devcontainer, then launch
60
- OpenCode.
93
+ OpenCode, installing it first when needed.
61
94
  antigravity Start or reuse the devcontainer, then launch
62
- Antigravity CLI (agy).
95
+ Antigravity CLI (agy), installing it first when
96
+ needed.
63
97
  list List Boxdown-known devcontainer workspaces from any
64
98
  directory.
65
99
  status Show workspace state, generated paths, SSH key paths,
@@ -67,12 +101,20 @@ Commands:
67
101
  stop Stop the workspace devcontainer if it is running.
68
102
  down Remove the workspace devcontainer. Keeps Boxdown
69
103
  cache, generated config, data, and SSH keys.
104
+ purge Remove the workspace devcontainer, exact Docker
105
+ image, managed SSH/app config, and Boxdown
106
+ cache/data for this workspace. Prompts for
107
+ tracked workspaces from untracked directories.
70
108
  doctor Check required host tools and Boxdown assets.
71
- ssh-config install Install or update an SSH host alias for the workspace
109
+ ssh install Install or update an SSH host alias for the workspace
72
110
  devcontainer.
111
+ ssh uninstall Remove Boxdown's managed SSH host alias block and
112
+ matching Codex/Claude app entries.
73
113
  ssh-proxy Internal command used by the generated SSH
74
114
  ProxyCommand. Starts or reuses the devcontainer and
75
115
  bridges SSH over docker exec.
116
+ tunnel Start or reuse the devcontainer, then keep an SSH
117
+ local port tunnel open for host/browser access.
76
118
  refresh-gh-token Start or reuse the devcontainer, then copy host
77
119
  GitHub CLI auth into the container when available.
78
120
  refresh-gh-token-running Refresh GitHub CLI auth only if the workspace
@@ -80,17 +122,34 @@ Commands:
80
122
 
81
123
  Options:
82
124
  --workspace <path> Target project directory. Defaults to the current directory.
125
+ Repeatable with down. With purge, also accepts PATH,
126
+ SSH ALIAS, or an unambiguous REPO from boxdown list.
127
+ Without --workspace, purge only targets the current
128
+ directory when it is tracked; otherwise interactive
129
+ terminals prompt for tracked workspaces.
83
130
  --alias <name> SSH host alias. Defaults to <repo-name>-devcontainer.
131
+ --target <name> Optional SSH install target. Repeatable. Supported by
132
+ setup and ssh install: codex, claude.
133
+ --port <port> Tunnel a local port to the same remote port, or use
134
+ <local:remote>. Repeatable. Supported by tunnel.
84
135
  --recreate Remove the existing devcontainer before starting.
85
136
  --json Print JSON output. Supported by status and list.
137
+ --format json Print JSON output. Equivalent to --json.
138
+ --details Print detailed human list output. Supported by list.
139
+ --verbose Stream raw Docker, devcontainer, and hook command output.
140
+ Lifecycle commands append the same managed output to the
141
+ per-workspace command log either way.
86
142
  --help, -h Show help.
143
+ --version, -v Show version.
87
144
  `
88
145
 
89
146
  export function commandWritesWorkspaceMetadata (command: BoxdownCommand): boolean {
90
147
  return [
148
+ 'setup',
91
149
  'start',
92
- 'ssh-config-install',
150
+ 'ssh-install',
93
151
  'ssh-proxy',
152
+ 'tunnel',
94
153
  'refresh-gh-token',
95
154
  'refresh-gh-token-running',
96
155
  'coding-agent'
@@ -99,38 +158,102 @@ export function commandWritesWorkspaceMetadata (command: BoxdownCommand): boolea
99
158
 
100
159
  export function parseCliArgs (argv: string[]): ParsedCli {
101
160
  const args = [...argv]
102
- let workspace: string | undefined
161
+ const workspaces: string[] = []
103
162
  let alias: string | undefined
163
+ const targets: SshConfigInstallTarget[] = []
164
+ const tunnelPorts: TunnelPortForward[] = []
104
165
  let recreate = false
105
166
  let json = false
167
+ let details = false
168
+ let verbose = false
106
169
  let passthroughArgs: string[] | undefined
107
170
  const positional: string[] = []
108
171
 
172
+ function workspaceFields (command: BoxdownCommand): Pick<ParsedCli, 'workspace' | 'workspaces'> {
173
+ if (workspaces.length > 1 && command !== 'down') {
174
+ throw new Error('--workspace can only be repeated with down')
175
+ }
176
+
177
+ return {
178
+ workspace: workspaces[0],
179
+ ...(command === 'down' && workspaces.length > 0 ? { workspaces: [...workspaces] } : {})
180
+ }
181
+ }
182
+
109
183
  function parsed (command: BoxdownCommand): ParsedCli {
184
+ if (details && json) {
185
+ throw new Error('--details cannot be combined with JSON output')
186
+ }
187
+
110
188
  if (json && command !== 'status' && command !== 'list') {
111
189
  throw new Error('--json is only supported with status and list')
112
190
  }
113
191
 
192
+ if (details && command !== 'list') {
193
+ throw new Error('--details is only supported with list')
194
+ }
195
+
114
196
  if (passthroughArgs !== undefined) {
115
197
  throw new Error('-- passthrough is only supported with coding-agent commands')
116
198
  }
117
199
 
118
- return { command, workspace, alias, recreate, json }
200
+ if (targets.length > 0 && command !== 'setup' && command !== 'ssh-install') {
201
+ throw new Error('--target is only supported with setup and ssh install')
202
+ }
203
+
204
+ if (tunnelPorts.length > 0 && command !== 'tunnel') {
205
+ throw new Error('--port is only supported with tunnel')
206
+ }
207
+
208
+ if (recreate && command === 'purge') {
209
+ throw new Error('--recreate is not supported with purge')
210
+ }
211
+
212
+ const parsedTargets = dedupeSshInstallTargets(targets)
213
+
214
+ return {
215
+ command,
216
+ ...workspaceFields(command),
217
+ alias,
218
+ ...(parsedTargets.length === 0 ? {} : { targets: parsedTargets }),
219
+ ...(tunnelPorts.length === 0 ? {} : { tunnelPorts }),
220
+ recreate,
221
+ json,
222
+ ...(details ? { details } : {}),
223
+ verbose
224
+ }
119
225
  }
120
226
 
121
227
  function parsedCodingAgent (agent: CodingAgentCli): ParsedCli {
228
+ if (details && json) {
229
+ throw new Error('--details cannot be combined with JSON output')
230
+ }
231
+
122
232
  if (json) {
123
233
  throw new Error('--json is only supported with status and list')
124
234
  }
125
235
 
236
+ if (details) {
237
+ throw new Error('--details is only supported with list')
238
+ }
239
+
240
+ if (targets.length > 0) {
241
+ throw new Error('--target is only supported with setup and ssh install')
242
+ }
243
+
244
+ if (tunnelPorts.length > 0) {
245
+ throw new Error('--port is only supported with tunnel')
246
+ }
247
+
126
248
  return {
127
249
  command: 'coding-agent',
128
250
  agent,
129
251
  agentArgs: passthroughArgs ?? [],
130
- workspace,
252
+ ...workspaceFields('coding-agent'),
131
253
  alias,
132
254
  recreate,
133
- json
255
+ json,
256
+ verbose
134
257
  }
135
258
  }
136
259
 
@@ -150,12 +273,39 @@ export function parseCliArgs (argv: string[]): ParsedCli {
150
273
  return parsed('help')
151
274
  }
152
275
 
276
+ if (arg === '--version' || arg === '-v') {
277
+ return parsed('version')
278
+ }
279
+
153
280
  if (arg === '--workspace') {
154
281
  const value = args.shift()
155
282
  if (value === undefined) {
156
283
  throw new Error('--workspace requires a value')
157
284
  }
158
- workspace = value
285
+ workspaces.push(value)
286
+ continue
287
+ }
288
+
289
+ if (arg === '--target') {
290
+ const value = args.shift()
291
+ if (value === undefined) {
292
+ throw new Error('--target requires a value')
293
+ }
294
+
295
+ if (!isSshConfigInstallTarget(value)) {
296
+ throw new Error(`Unsupported ssh install target: ${value}`)
297
+ }
298
+
299
+ targets.push(value)
300
+ continue
301
+ }
302
+
303
+ if (arg === '--port') {
304
+ const value = args.shift()
305
+ if (value === undefined) {
306
+ throw new Error('--port requires a value')
307
+ }
308
+ tunnelPorts.push(parseTunnelPort(value))
159
309
  continue
160
310
  }
161
311
 
@@ -178,6 +328,30 @@ export function parseCliArgs (argv: string[]): ParsedCli {
178
328
  continue
179
329
  }
180
330
 
331
+ if (arg === '--format') {
332
+ const value = args.shift()
333
+ if (value === undefined) {
334
+ throw new Error('--format requires a value')
335
+ }
336
+
337
+ if (value !== 'json') {
338
+ throw new Error(`Unsupported format: ${value}`)
339
+ }
340
+
341
+ json = true
342
+ continue
343
+ }
344
+
345
+ if (arg === '--details') {
346
+ details = true
347
+ continue
348
+ }
349
+
350
+ if (arg === '--verbose') {
351
+ verbose = true
352
+ continue
353
+ }
354
+
181
355
  if (arg.startsWith('-')) {
182
356
  throw new Error(`Unknown option: ${arg}`)
183
357
  }
@@ -193,6 +367,14 @@ export function parseCliArgs (argv: string[]): ParsedCli {
193
367
  return parsed('start')
194
368
  }
195
369
 
370
+ if (positional[0] === 'setup' && positional.length === 1) {
371
+ return parsed('setup')
372
+ }
373
+
374
+ if (positional[0] === 'codex' && positional[1] === 'repair') {
375
+ throw new Error(`Unknown command: ${positional.join(' ')}`)
376
+ }
377
+
196
378
  const codingAgent = codingAgentFromCommand(positional[0] ?? '')
197
379
  if (codingAgent !== undefined) {
198
380
  if (positional.length > 1) {
@@ -218,22 +400,34 @@ export function parseCliArgs (argv: string[]): ParsedCli {
218
400
  return parsed('down')
219
401
  }
220
402
 
403
+ if (positional[0] === 'purge' && positional.length === 1) {
404
+ return parsed('purge')
405
+ }
406
+
221
407
  if (positional[0] === 'doctor' && positional.length === 1) {
222
408
  return parsed('doctor')
223
409
  }
224
410
 
225
- if (positional[0] === 'ssh-config') {
411
+ if (positional[0] === 'ssh') {
226
412
  if (positional.length === 1 || (positional[1] === 'install' && positional.length === 2)) {
227
- return parsed('ssh-config-install')
413
+ return parsed('ssh-install')
228
414
  }
229
415
 
230
- throw new Error(`Unknown ssh-config command: ${positional.slice(1).join(' ')}. Usage: boxdown ssh-config [install] [--workspace <path>] [--alias <name>]`)
416
+ if (positional[1] === 'uninstall' && positional.length === 2) {
417
+ return parsed('ssh-uninstall')
418
+ }
419
+
420
+ throw new Error(`Unknown ssh command: ${positional.slice(1).join(' ')}. Usage: boxdown ssh [install|uninstall] [--workspace <path>] [--alias <name>] [--target <name>]...`)
231
421
  }
232
422
 
233
423
  if (positional[0] === 'ssh-proxy' && positional.length === 1) {
234
424
  return parsed('ssh-proxy')
235
425
  }
236
426
 
427
+ if (positional[0] === 'tunnel' && positional.length === 1) {
428
+ return parsed('tunnel')
429
+ }
430
+
237
431
  if (positional[0] === 'refresh-gh-token' && positional.length === 1) {
238
432
  return parsed('refresh-gh-token')
239
433
  }
@@ -245,7 +439,747 @@ export function parseCliArgs (argv: string[]): ParsedCli {
245
439
  throw new Error(`Unknown command: ${positional.join(' ')}`)
246
440
  }
247
441
 
248
- export async function runCli (argv: string[] = process.argv.slice(2)): Promise<number> {
442
+ function parsePortNumber (value: string): number {
443
+ if (!/^[0-9]+$/.test(value)) {
444
+ throw new Error(`Invalid tunnel port: ${value}`)
445
+ }
446
+
447
+ const port = Number(value)
448
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
449
+ throw new Error(`Invalid tunnel port: ${value}`)
450
+ }
451
+
452
+ return port
453
+ }
454
+
455
+ export function parseTunnelPort (value: string): TunnelPortForward {
456
+ const parts = value.split(':')
457
+
458
+ if (parts.length === 1) {
459
+ const port = parsePortNumber(parts[0] ?? '')
460
+ return {
461
+ localPort: port,
462
+ remotePort: port
463
+ }
464
+ }
465
+
466
+ if (parts.length === 2) {
467
+ return {
468
+ localPort: parsePortNumber(parts[0] ?? ''),
469
+ remotePort: parsePortNumber(parts[1] ?? '')
470
+ }
471
+ }
472
+
473
+ throw new Error(`Invalid tunnel port: ${value}`)
474
+ }
475
+
476
+ export function parseTunnelPortList (value: string): TunnelPortForward[] {
477
+ const tokens = value.split(/[,\s]+/u).filter((token) => token.length > 0)
478
+
479
+ if (tokens.length === 0) {
480
+ throw new Error('tunnel requires at least one --port value')
481
+ }
482
+
483
+ return tokens.map((token) => parseTunnelPort(token))
484
+ }
485
+
486
+ interface ResolvedDownWorkspaces {
487
+ workspaces: string[] | undefined
488
+ cancelled: boolean
489
+ }
490
+
491
+ interface PurgeTarget {
492
+ context: WorkspaceContext
493
+ argv: string[]
494
+ }
495
+
496
+ interface ResolvedPurgeTargets {
497
+ targets: PurgeTarget[]
498
+ cancelled: boolean
499
+ batch: boolean
500
+ }
501
+
502
+ function createLifecycleLogger (context: WorkspaceContext, command: string, argv: string[]): WorkspaceCommandLogger {
503
+ const logger = createWorkspaceCommandLogger(context)
504
+ logger.section(`boxdown ${command}`, {
505
+ argv: JSON.stringify(argv),
506
+ cwd: process.cwd()
507
+ })
508
+ return logger
509
+ }
510
+
511
+ async function runLoggedLifecycle<T> (
512
+ context: WorkspaceContext,
513
+ command: string,
514
+ argv: string[],
515
+ action: (logger: WorkspaceCommandLogger) => Promise<T>
516
+ ): Promise<T> {
517
+ const logger = createLifecycleLogger(context, command, argv)
518
+
519
+ try {
520
+ return await withLoggedProcessOutput(logger, async () => action(logger))
521
+ } catch (error) {
522
+ logger.boxdown(`Error: ${error instanceof Error ? error.message : String(error)}\n`)
523
+ throw error
524
+ }
525
+ }
526
+
527
+ function uniqueWorkspaceMetadata (metadata: WorkspaceMetadata[]): WorkspaceMetadata[] {
528
+ return [...new Map(metadata.map((entry) => [entry.workspaceId, entry])).values()]
529
+ }
530
+
531
+ function workspaceMetadataContext (metadata: WorkspaceMetadata): WorkspaceContext {
532
+ return createWorkspaceContextFromIdentity({
533
+ workspaceFolder: metadata.workspaceFolder,
534
+ workspaceBasename: metadata.workspaceBasename,
535
+ workspaceId: metadata.workspaceId
536
+ })
537
+ }
538
+
539
+ function purgeWorkspaceStateColor (state: string): CliColor {
540
+ if (state === 'running') {
541
+ return 'green'
542
+ }
543
+
544
+ if (state === 'exited') {
545
+ return 'yellow'
546
+ }
547
+
548
+ return 'red'
549
+ }
550
+
551
+ function ambiguousWorkspaceSelectorError (selector: string, matches: WorkspaceMetadata[]): Error {
552
+ const matchList = matches
553
+ .map((entry) => ` - ${entry.workspaceBasename}: ${entry.workspaceFolder} (${entry.sshAlias})`)
554
+ .join('\n')
555
+
556
+ return new Error(`Workspace selector is ambiguous: ${selector}\nUse the PATH or SSH ALIAS from "boxdown list":\n${matchList}`)
557
+ }
558
+
559
+ function resolvePurgeWorkspaceContext (workspace: string | undefined): WorkspaceContext {
560
+ if (workspace === undefined) {
561
+ return createWorkspaceContext()
562
+ }
563
+
564
+ try {
565
+ return createWorkspaceContext({ workspace })
566
+ } catch {
567
+ // Purge can target stale metadata, so values copied from `boxdown list`
568
+ // should work even when the repository path no longer exists.
569
+ }
570
+
571
+ const metadata = listWorkspaceMetadata(defaultDataRoot())
572
+
573
+ for (const matches of [
574
+ metadata.filter((entry) => entry.workspaceFolder === workspace),
575
+ metadata.filter((entry) => entry.sshAlias === workspace),
576
+ metadata.filter((entry) => entry.workspaceBasename === workspace)
577
+ ]) {
578
+ const uniqueMatches = uniqueWorkspaceMetadata(matches)
579
+
580
+ if (uniqueMatches.length === 1) {
581
+ return workspaceMetadataContext(uniqueMatches[0] as WorkspaceMetadata)
582
+ }
583
+
584
+ if (uniqueMatches.length > 1) {
585
+ throw ambiguousWorkspaceSelectorError(workspace, uniqueMatches)
586
+ }
587
+ }
588
+
589
+ throw new Error(`Workspace does not exist and no Boxdown list entry matches PATH, SSH ALIAS, or REPO: ${workspace}`)
590
+ }
591
+
592
+ async function resolvePurgeTargets (
593
+ parsed: ParsedCli,
594
+ options: RunCliOptions,
595
+ argv: string[]
596
+ ): Promise<ResolvedPurgeTargets> {
597
+ if (parsed.workspace !== undefined) {
598
+ return {
599
+ targets: [{
600
+ context: resolvePurgeWorkspaceContext(parsed.workspace),
601
+ argv
602
+ }],
603
+ cancelled: false,
604
+ batch: false
605
+ }
606
+ }
607
+
608
+ const context = createWorkspaceContext()
609
+ const metadata = readWorkspaceMetadata(context)
610
+
611
+ if (metadata?.workspaceFolder === context.workspaceFolder) {
612
+ return {
613
+ targets: [{
614
+ context,
615
+ argv
616
+ }],
617
+ cancelled: false,
618
+ batch: false
619
+ }
620
+ }
621
+
622
+ const input = options.promptInput ?? process.stdin
623
+ const output = options.promptOutput ?? process.stdout
624
+ const env = options.env ?? process.env
625
+
626
+ if (!canPromptInteractively(input, output, env)) {
627
+ throw new Error('Current directory is not a tracked Boxdown workspace. Run boxdown purge from a tracked workspace or pass --workspace <PATH|SSH ALIAS|REPO>.')
628
+ }
629
+
630
+ const entries = createWorkspaceListEntries(
631
+ listWorkspaceMetadata(defaultDataRoot()),
632
+ await listWorkspaceContainers(),
633
+ existsSync
634
+ )
635
+
636
+ if (entries.length === 0) {
637
+ throw new Error('No Boxdown workspaces found to purge.')
638
+ }
639
+
640
+ const entriesById = new Map(entries.map((entry) => [entry.workspaceId, entry]))
641
+ const result = await promptMultiSelect<string>({
642
+ title: 'Purge Boxdown workspaces?',
643
+ choices: entries.map((entry) => ({
644
+ value: entry.workspaceId,
645
+ label: entry.workspaceBasename,
646
+ description: `(${entry.state}) ${entry.workspaceFolder}`,
647
+ focusedDescription: [
648
+ { text: `(${entry.state})`, color: purgeWorkspaceStateColor(entry.state) },
649
+ { text: ` ${entry.workspaceFolder}`, color: 'dim' }
650
+ ]
651
+ })),
652
+ skipLabel: 'Cancel',
653
+ summaryLabel: 'Purge workspaces',
654
+ input,
655
+ output,
656
+ env
657
+ })
658
+
659
+ if (result.status === 'selected') {
660
+ return {
661
+ targets: result.values.map((workspaceId) => {
662
+ const entry = entriesById.get(workspaceId)
663
+
664
+ if (entry === undefined) {
665
+ throw new Error(`Selected Boxdown workspace disappeared: ${workspaceId}`)
666
+ }
667
+
668
+ return {
669
+ context: workspaceMetadataContext(entry),
670
+ argv: ['purge', '--workspace', entry.workspaceFolder]
671
+ }
672
+ }),
673
+ cancelled: false,
674
+ batch: true
675
+ }
676
+ }
677
+
678
+ if (result.status === 'non-interactive') {
679
+ throw new Error('Current directory is not a tracked Boxdown workspace. Run boxdown purge from a tracked workspace or pass --workspace <PATH|SSH ALIAS|REPO>.')
680
+ }
681
+
682
+ return {
683
+ targets: [],
684
+ cancelled: true,
685
+ batch: true
686
+ }
687
+ }
688
+
689
+ async function confirmPurgeTargets (
690
+ resolved: ResolvedPurgeTargets,
691
+ parsed: ParsedCli,
692
+ options: RunCliOptions
693
+ ): Promise<boolean> {
694
+ if (!resolved.batch) {
695
+ const target = resolved.targets[0]
696
+
697
+ if (target === undefined) {
698
+ return false
699
+ }
700
+
701
+ return confirmPurgeWorkspace(target.context, parsed, options)
702
+ }
703
+
704
+ const result = await promptConfirm({
705
+ title: 'Purge selected Boxdown workspaces?',
706
+ details: [
707
+ `${resolved.targets.length} workspaces selected:`,
708
+ ...resolved.targets.map((target) => `Workspace: ${target.context.workspaceFolder}`),
709
+ 'Removes devcontainers, recorded images, SSH/Codex entries, cache, and data.',
710
+ parsed.alias === undefined ? 'Alias: default and recorded aliases' : `Alias: ${parsed.alias}, default, and recorded aliases`
711
+ ],
712
+ confirmLabel: 'Purge selected',
713
+ cancelLabel: 'Cancel',
714
+ summaryLabel: 'Purge workspaces',
715
+ input: options.promptInput,
716
+ output: options.promptOutput,
717
+ env: options.env
718
+ })
719
+
720
+ return result.status === 'confirmed' || result.status === 'non-interactive'
721
+ }
722
+
723
+ async function runPurgeCommand (parsed: ParsedCli, argv: string[], options: RunCliOptions): Promise<number> {
724
+ const resolved = await resolvePurgeTargets(parsed, options, argv)
725
+
726
+ if (resolved.cancelled) {
727
+ process.stderr.write('Canceled purge.\n')
728
+ return 1
729
+ }
730
+
731
+ if (resolved.targets.length === 0) {
732
+ process.stderr.write('No Boxdown workspaces selected for purge.\n')
733
+ return 1
734
+ }
735
+
736
+ if (!await confirmPurgeTargets(resolved, parsed, options)) {
737
+ process.stderr.write('Canceled purge.\n')
738
+ return 1
739
+ }
740
+
741
+ let failed = false
742
+
743
+ for (const target of resolved.targets) {
744
+ try {
745
+ const code = await runLoggedLifecycle(target.context, 'purge', target.argv, async (logger) => purgeWorkspace(target.context, {
746
+ alias: parsed.alias,
747
+ logger
748
+ }))
749
+
750
+ failed = code !== 0 || failed
751
+ } catch (error) {
752
+ failed = true
753
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
754
+ }
755
+ }
756
+
757
+ return failed ? 1 : 0
758
+ }
759
+
760
+ async function resolveDownWorkspaces (
761
+ workspaces: string[] | undefined,
762
+ options: RunCliOptions
763
+ ): Promise<ResolvedDownWorkspaces> {
764
+ if (workspaces !== undefined && workspaces.length > 0) {
765
+ return { workspaces, cancelled: false }
766
+ }
767
+
768
+ const context = createWorkspaceContext()
769
+ const metadata = readWorkspaceMetadata(context)
770
+
771
+ if (metadata?.workspaceFolder === context.workspaceFolder) {
772
+ return { workspaces: undefined, cancelled: false }
773
+ }
774
+
775
+ const input = options.promptInput ?? process.stdin
776
+ const output = options.promptOutput ?? process.stdout
777
+ const env = options.env ?? process.env
778
+
779
+ if (!canPromptInteractively(input, output, env)) {
780
+ return { workspaces: undefined, cancelled: false }
781
+ }
782
+
783
+ const entries = createWorkspaceListEntries(
784
+ listWorkspaceMetadata(defaultDataRoot()),
785
+ await listWorkspaceContainers(),
786
+ existsSync
787
+ ).filter((entry) => entry.repoExists)
788
+
789
+ if (entries.length === 0) {
790
+ return { workspaces: undefined, cancelled: false }
791
+ }
792
+
793
+ const result = await promptMultiSelect<string>({
794
+ title: 'Remove Boxdown devcontainers?',
795
+ choices: entries.map((entry) => ({
796
+ value: entry.workspaceFolder,
797
+ label: entry.workspaceBasename,
798
+ description: `${entry.state} - ${entry.workspaceFolder}`
799
+ })),
800
+ skipLabel: 'Cancel',
801
+ summaryLabel: 'Down workspaces',
802
+ input,
803
+ output,
804
+ env
805
+ })
806
+
807
+ if (result.status === 'selected') {
808
+ return { workspaces: result.values, cancelled: false }
809
+ }
810
+
811
+ if (result.status === 'non-interactive') {
812
+ return { workspaces: undefined, cancelled: false }
813
+ }
814
+
815
+ return { workspaces: undefined, cancelled: true }
816
+ }
817
+
818
+ async function runDownCommand (workspaces: string[] | undefined, options: RunCliOptions): Promise<number> {
819
+ const resolved = await resolveDownWorkspaces(workspaces, options)
820
+
821
+ if (resolved.cancelled) {
822
+ process.stderr.write('Canceled down.\n')
823
+ return 1
824
+ }
825
+
826
+ const targetWorkspaces = resolved.workspaces === undefined || resolved.workspaces.length === 0 ? [undefined] : resolved.workspaces
827
+ let failed = false
828
+
829
+ for (const workspace of targetWorkspaces) {
830
+ try {
831
+ const context = createWorkspaceContext({ workspace })
832
+ await runLoggedLifecycle(context, 'down', ['down', ...(workspace === undefined ? [] : ['--workspace', workspace])], async (logger) => {
833
+ await removeWorkspaceContainer(context, { logger })
834
+ removeWorkspaceRuntimeState(context)
835
+ })
836
+ } catch (error) {
837
+ failed = true
838
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
839
+ }
840
+ }
841
+
842
+ return failed ? 1 : 0
843
+ }
844
+
845
+ interface ResolvedSshInstallTargets {
846
+ targets: SshConfigInstallTarget[]
847
+ cancelled: boolean
848
+ skippedNonInteractive: boolean
849
+ }
850
+
851
+ interface ResolvedTunnelPorts {
852
+ tunnelPorts: TunnelPortForward[]
853
+ cancelled: boolean
854
+ }
855
+
856
+ async function resolveTunnelPorts (
857
+ parsed: ParsedCli,
858
+ context: ReturnType<typeof createWorkspaceContext>,
859
+ options: RunCliOptions
860
+ ): Promise<ResolvedTunnelPorts> {
861
+ if (parsed.tunnelPorts !== undefined && parsed.tunnelPorts.length > 0) {
862
+ return {
863
+ tunnelPorts: parsed.tunnelPorts,
864
+ cancelled: false
865
+ }
866
+ }
867
+
868
+ const input = options.promptInput ?? process.stdin
869
+ const output = options.promptOutput ?? process.stdout
870
+ const env = options.env ?? process.env
871
+
872
+ if (!canPromptInteractively(input, output, env)) {
873
+ return {
874
+ tunnelPorts: [],
875
+ cancelled: false
876
+ }
877
+ }
878
+
879
+ const defaultPort = publishContainerPortFromConfig(buildGeneratedDevcontainerConfig(context))
880
+ const result = await promptText({
881
+ title: 'Tunnel port(s) to forward?',
882
+ details: ['Use a port like 3030, or a mapping like 8080:3031.'],
883
+ defaultValue: defaultPort,
884
+ summaryLabel: 'Tunnel ports',
885
+ validate: (value) => {
886
+ try {
887
+ parseTunnelPortList(value)
888
+ return undefined
889
+ } catch (error) {
890
+ return error instanceof Error ? error.message : String(error)
891
+ }
892
+ },
893
+ input,
894
+ output,
895
+ env
896
+ })
897
+
898
+ if (result.status === 'submitted') {
899
+ return {
900
+ tunnelPorts: parseTunnelPortList(result.value),
901
+ cancelled: false
902
+ }
903
+ }
904
+
905
+ return {
906
+ tunnelPorts: [],
907
+ cancelled: result.status === 'cancelled'
908
+ }
909
+ }
910
+
911
+ async function confirmPurgeWorkspace (
912
+ context: ReturnType<typeof createWorkspaceContext>,
913
+ parsed: ParsedCli,
914
+ options: RunCliOptions
915
+ ): Promise<boolean> {
916
+ const result = await promptConfirm({
917
+ title: 'Purge Boxdown workspace?',
918
+ details: [
919
+ `Workspace: ${context.workspaceFolder}`,
920
+ 'Removes devcontainer, recorded image, SSH/Codex entries, cache, and data.',
921
+ parsed.alias === undefined ? 'Alias: default and recorded aliases' : `Alias: ${parsed.alias}, default, and recorded aliases`
922
+ ],
923
+ confirmLabel: 'Purge',
924
+ cancelLabel: 'Cancel',
925
+ summaryLabel: 'Purge',
926
+ input: options.promptInput,
927
+ output: options.promptOutput,
928
+ env: options.env
929
+ })
930
+
931
+ return result.status === 'confirmed' || result.status === 'non-interactive'
932
+ }
933
+
934
+ async function resolveSshInstallTargets (
935
+ parsed: ParsedCli,
936
+ options: RunCliOptions
937
+ ): Promise<ResolvedSshInstallTargets> {
938
+ if (parsed.targets !== undefined) {
939
+ return {
940
+ targets: parsed.targets,
941
+ cancelled: false,
942
+ skippedNonInteractive: false
943
+ }
944
+ }
945
+
946
+ const result = await promptMultiSelect<SshConfigInstallTarget>({
947
+ title: 'Install optional SSH targets?',
948
+ choices: SSH_INSTALL_TARGETS.map((target) => ({
949
+ value: target.value,
950
+ label: target.label,
951
+ description: target.description
952
+ })),
953
+ skipLabel: 'Skip optional targets',
954
+ summaryLabel: 'Optional SSH targets',
955
+ input: options.promptInput,
956
+ output: options.promptOutput,
957
+ env: options.env
958
+ })
959
+
960
+ return {
961
+ targets: result.values,
962
+ cancelled: result.status === 'cancelled',
963
+ skippedNonInteractive: result.status === 'non-interactive'
964
+ }
965
+ }
966
+
967
+ function printSkippedSshInstallTargets (command: 'setup' | 'ssh install'): void {
968
+ process.stdout.write(`\nNo optional SSH install targets selected. Run boxdown ${command} ${sshInstallTargetFlagHintsText()} to install optional targets explicitly. Supported targets: ${supportedSshInstallTargetsText()}.\n`)
969
+ }
970
+
971
+ interface SetupWorkspaceOptions {
972
+ recreate?: boolean
973
+ targets?: SshConfigInstallTarget[]
974
+ progress?: ProgressReporter
975
+ logger?: WorkspaceCommandLogger
976
+ start?: typeof startDevcontainer
977
+ installSsh?: typeof installSshConfig
978
+ installTarget?: typeof installSshInstallTarget
979
+ }
980
+
981
+ export async function setupWorkspace (
982
+ context: WorkspaceContext,
983
+ alias: string,
984
+ options: SetupWorkspaceOptions = {}
985
+ ): Promise<void> {
986
+ await (options.start ?? startDevcontainer)(context, {
987
+ recreate: options.recreate,
988
+ ...(options.logger === undefined ? {} : { logger: options.logger }),
989
+ ...(options.progress === undefined ? {} : { progress: options.progress })
990
+ })
991
+
992
+ const hasSshAliasStep = options.progress?.hasStep('ssh-alias') === true
993
+
994
+ if (options.progress?.mode === 'interactive') {
995
+ if (hasSshAliasStep) {
996
+ options.progress.startStep('ssh-alias')
997
+ } else {
998
+ options.progress.item('Installing SSH alias')
999
+ options.progress.detail(alias)
1000
+ }
1001
+
1002
+ try {
1003
+ await (options.installSsh ?? installSshConfig)(context, alias, { quiet: true })
1004
+ if (hasSshAliasStep) {
1005
+ options.progress.completeStep('ssh-alias')
1006
+ }
1007
+ } catch (error) {
1008
+ if (hasSshAliasStep) {
1009
+ options.progress.failStep('ssh-alias')
1010
+ }
1011
+ throw error
1012
+ }
1013
+ } else {
1014
+ await (options.installSsh ?? installSshConfig)(context, alias)
1015
+ }
1016
+
1017
+ const installTarget = options.installTarget ?? installSshInstallTarget
1018
+ for (const target of options.targets ?? []) {
1019
+ const stepId = `ssh-target:${target}`
1020
+ const hasTargetStep = options.progress?.hasStep(stepId) === true
1021
+
1022
+ if (hasTargetStep) {
1023
+ options.progress?.startStep(stepId)
1024
+ } else if (options.progress?.mode === 'interactive') {
1025
+ options.progress.item(`Installing ${target} SSH target`)
1026
+ }
1027
+
1028
+ try {
1029
+ await installTarget(context, alias, target, {
1030
+ quiet: options.progress?.mode === 'interactive'
1031
+ })
1032
+ if (hasTargetStep) {
1033
+ options.progress?.completeStep(stepId)
1034
+ }
1035
+ } catch (error) {
1036
+ if (hasTargetStep) {
1037
+ options.progress?.failStep(stepId)
1038
+ }
1039
+ throw error
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ function createCliProgress (
1045
+ parsed: ParsedCli,
1046
+ target: ProgressOutputTarget = 'stdout',
1047
+ options: { env?: NodeJS.ProcessEnv } = {}
1048
+ ): ProgressReporter {
1049
+ return createProgress({
1050
+ mode: resolveProgressMode({
1051
+ verbose: parsed.verbose,
1052
+ json: parsed.json,
1053
+ target,
1054
+ env: options.env
1055
+ }),
1056
+ target
1057
+ })
1058
+ }
1059
+
1060
+ function startProgressSteps (): ProgressStepDefinition[] {
1061
+ return [
1062
+ { id: 'ssh-identity', label: 'Preparing SSH identity' },
1063
+ { id: 'devcontainer-config', label: 'Writing generated devcontainer config' },
1064
+ { id: 'devcontainer-start', label: 'Starting devcontainer' }
1065
+ ]
1066
+ }
1067
+
1068
+ function sshTargetProgressLabel (target: SshConfigInstallTarget): string {
1069
+ const label = SSH_INSTALL_TARGETS.find((candidate) => candidate.value === target)?.label ?? target
1070
+ return `Installing ${label} SSH target`
1071
+ }
1072
+
1073
+ function setupProgressSteps (targets: readonly SshConfigInstallTarget[]): ProgressStepDefinition[] {
1074
+ return [
1075
+ ...startProgressSteps(),
1076
+ { id: 'ssh-alias', label: 'Installing SSH alias' },
1077
+ ...targets.map((target) => ({
1078
+ id: `ssh-target:${target}`,
1079
+ label: sshTargetProgressLabel(target)
1080
+ }))
1081
+ ]
1082
+ }
1083
+
1084
+ function setupPreflightProgressSteps (): ProgressStepDefinition[] {
1085
+ return [{ id: 'setup-preflight', label: 'Checking host readiness' }]
1086
+ }
1087
+
1088
+ function setupPreflightFailureMessage (checks: DoctorCheck[]): string {
1089
+ const failures = checks
1090
+ .filter((check) => check.level === 'fail')
1091
+ .map((check) => `- ${check.name}: ${check.message}`)
1092
+
1093
+ return `Setup preflight failed:\n${failures.join('\n')}`
1094
+ }
1095
+
1096
+ async function runSetupPreflight (
1097
+ context: WorkspaceContext,
1098
+ alias: string,
1099
+ parsed: ParsedCli,
1100
+ options: RunCliOptions
1101
+ ): Promise<void> {
1102
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1103
+ const doctor = options.runDoctorChecks ?? runDoctorChecks
1104
+
1105
+ await withProgressSection(progress, 'Boxdown setup', [
1106
+ `Workspace: ${context.workspaceFolder}`,
1107
+ `SSH alias: ${alias}`
1108
+ ], async () => {
1109
+ progress.setSteps(setupPreflightProgressSteps())
1110
+ progress.startStep('setup-preflight')
1111
+ const checks = await doctor(context, { includeOptional: false })
1112
+
1113
+ for (const check of checks.filter((item) => item.level === 'warn')) {
1114
+ progress.warn(`${check.name}: ${check.message}`)
1115
+ }
1116
+
1117
+ if (doctorHasFailures(checks)) {
1118
+ progress.failStep('setup-preflight')
1119
+ throw new Error(setupPreflightFailureMessage(checks))
1120
+ }
1121
+
1122
+ progress.completeStep('setup-preflight')
1123
+ })
1124
+ }
1125
+
1126
+ function sshAliasProgressStep (label: string): ProgressStepDefinition {
1127
+ return { id: 'ssh-alias', label }
1128
+ }
1129
+
1130
+ function tunnelProgressSteps (): ProgressStepDefinition[] {
1131
+ return [
1132
+ sshAliasProgressStep('Updating SSH alias'),
1133
+ ...startProgressSteps()
1134
+ ]
1135
+ }
1136
+
1137
+ function sshProxyProgressSteps (): ProgressStepDefinition[] {
1138
+ return [
1139
+ sshAliasProgressStep('Updating SSH alias'),
1140
+ ...startProgressSteps(),
1141
+ { id: 'coding-agent-refresh', label: 'Refreshing default coding-agent CLIs' },
1142
+ { id: 'ssh-runtime', label: 'Preparing container SSH runtime' }
1143
+ ]
1144
+ }
1145
+
1146
+ function codingAgentProgressSteps (agent: CodingAgentCli): ProgressStepDefinition[] {
1147
+ return [
1148
+ ...startProgressSteps(),
1149
+ { id: 'agent-cli', label: `Preparing ${codingAgentBinary(agent)} inside the devcontainer` }
1150
+ ]
1151
+ }
1152
+
1153
+ function ghAuthProgressSteps (includeStart: boolean): ProgressStepDefinition[] {
1154
+ return [
1155
+ ...(includeStart ? startProgressSteps() : [{ id: 'devcontainer-running', label: 'Using running devcontainer' }]),
1156
+ { id: 'gh-auth-config', label: 'Preparing generated config for GitHub auth refresh' },
1157
+ { id: 'gh-token-read', label: 'Reading host GitHub CLI token' },
1158
+ { id: 'gh-auth-refresh', label: 'Refreshing GitHub CLI auth inside the devcontainer' },
1159
+ { id: 'gh-git-auth', label: 'Configuring workspace GitHub Git auth' },
1160
+ { id: 'gh-auth-verify', label: 'Verifying GitHub CLI auth inside the devcontainer' }
1161
+ ]
1162
+ }
1163
+
1164
+ async function withProgressSection<T> (
1165
+ progress: ProgressReporter,
1166
+ title: string,
1167
+ details: readonly string[],
1168
+ run: () => Promise<T>
1169
+ ): Promise<T> {
1170
+ progress.section(title)
1171
+ for (const detail of details) {
1172
+ progress.detail(detail)
1173
+ }
1174
+
1175
+ try {
1176
+ return await run()
1177
+ } finally {
1178
+ progress.end()
1179
+ }
1180
+ }
1181
+
1182
+ export async function runCli (argv: string[] = process.argv.slice(2), options: RunCliOptions = {}): Promise<number> {
249
1183
  try {
250
1184
  const parsed = parseCliArgs(argv)
251
1185
 
@@ -254,6 +1188,11 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
254
1188
  return 0
255
1189
  }
256
1190
 
1191
+ if (parsed.command === 'version') {
1192
+ process.stdout.write(`${readPackageVersion()}\n`)
1193
+ return 0
1194
+ }
1195
+
257
1196
  if (parsed.command === 'list') {
258
1197
  const metadata = listWorkspaceMetadata(defaultDataRoot())
259
1198
  const containers = await listWorkspaceContainers()
@@ -261,6 +1200,8 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
261
1200
 
262
1201
  if (parsed.json) {
263
1202
  process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`)
1203
+ } else if (parsed.details) {
1204
+ process.stdout.write(formatWorkspaceListDetailsText(entries))
264
1205
  } else {
265
1206
  process.stdout.write(formatWorkspaceListText(entries))
266
1207
  }
@@ -268,16 +1209,87 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
268
1209
  return 0
269
1210
  }
270
1211
 
1212
+ if (parsed.command === 'down') {
1213
+ return runDownCommand(parsed.workspaces, options)
1214
+ }
1215
+
1216
+ if (parsed.command === 'purge') {
1217
+ return await runPurgeCommand(parsed, argv, options)
1218
+ }
1219
+
271
1220
  const context = createWorkspaceContext({ workspace: parsed.workspace })
272
1221
  const alias = parsed.alias ?? defaultSshAlias(context.workspaceBasename)
273
1222
  const aliasSource = parsed.alias === undefined ? 'default' : 'provided'
274
1223
 
275
- if (commandWritesWorkspaceMetadata(parsed.command)) {
1224
+ if (parsed.command !== 'ssh-install' && parsed.command !== 'setup' && parsed.command !== 'tunnel' && commandWritesWorkspaceMetadata(parsed.command)) {
276
1225
  writeWorkspaceMetadata(context, alias)
277
1226
  }
278
1227
 
279
- if (parsed.command === 'ssh-config-install') {
1228
+ if (parsed.command === 'ssh-install') {
1229
+ const resolvedTargets = await resolveSshInstallTargets(parsed, options)
1230
+
1231
+ if (resolvedTargets.cancelled) {
1232
+ process.stderr.write('Canceled SSH install.\n')
1233
+ return 1
1234
+ }
1235
+
1236
+ writeWorkspaceMetadata(context, alias)
280
1237
  await installSshConfig(context, alias)
1238
+
1239
+ if (resolvedTargets.skippedNonInteractive) {
1240
+ printSkippedSshInstallTargets('ssh install')
1241
+ }
1242
+
1243
+ for (const target of resolvedTargets.targets) {
1244
+ await installSshInstallTarget(context, alias, target)
1245
+ }
1246
+
1247
+ return 0
1248
+ }
1249
+
1250
+ if (parsed.command === 'ssh-uninstall') {
1251
+ uninstallSshConfig(alias)
1252
+ const entry = codexProjectEntryForWorkspace(context, alias)
1253
+ const legacyRemotePath = legacyCodexRemotePathForWorkspace(context)
1254
+ const result = uninstallCodexAppConfigProject(entry, {
1255
+ additionalRemotePaths: [legacyRemotePath]
1256
+ })
1257
+
1258
+ process.stdout.write(`\nCodex app config: ${result.configPath}\n`)
1259
+ process.stdout.write(result.changed
1260
+ ? `Removed Codex remote project: ${entry.label} (${entry.remotePath})\n`
1261
+ : `Codex remote project not installed: ${entry.label} (${entry.remotePath})\n`)
1262
+
1263
+ if (result.backupPath !== undefined) {
1264
+ process.stdout.write(`Codex app config backup: ${result.backupPath}\n`)
1265
+ }
1266
+
1267
+ const stateResult = uninstallCodexGlobalStateProject(entry, {
1268
+ additionalRemotePaths: [legacyRemotePath]
1269
+ })
1270
+
1271
+ process.stdout.write(`\nCodex app state: ${stateResult.statePath}\n`)
1272
+ process.stdout.write(stateResult.changed
1273
+ ? `Removed Codex sidebar state: ${entry.label} (${entry.remotePath})\n`
1274
+ : `Codex sidebar state not installed: ${entry.label} (${entry.remotePath})\n`)
1275
+
1276
+ if (stateResult.backupPath !== undefined) {
1277
+ process.stdout.write(`Codex app state backup: ${stateResult.backupPath}\n`)
1278
+ }
1279
+
1280
+ const claudeEntry = claudeSshConfigEntryForWorkspace(context, alias)
1281
+ const claudeResult = uninstallClaudeSshConfigHost(claudeEntry)
1282
+
1283
+ process.stdout.write(`\nClaude SSH config: ${claudeResult.configPath}\n`)
1284
+ process.stdout.write(claudeResult.changed
1285
+ ? `Removed Claude SSH remote: ${claudeEntry.name} (${claudeEntry.sshHost})\n`
1286
+ : `Claude SSH remote not installed: ${claudeEntry.name} (${claudeEntry.sshHost})\n`)
1287
+
1288
+ if (claudeResult.backupPath !== undefined) {
1289
+ process.stdout.write(`Claude SSH config backup: ${claudeResult.backupPath}\n`)
1290
+ }
1291
+
1292
+ process.stdout.write('Restart Codex and Claude to apply the remote project removal.\n')
281
1293
  return 0
282
1294
  }
283
1295
 
@@ -295,50 +1307,165 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
295
1307
  }
296
1308
 
297
1309
  if (parsed.command === 'stop') {
298
- await stopWorkspaceContainer(context)
299
- return 0
300
- }
301
-
302
- if (parsed.command === 'down') {
303
- await removeWorkspaceContainer(context)
304
- return 0
1310
+ return runLoggedLifecycle(context, 'stop', argv, async (logger) => {
1311
+ await stopWorkspaceContainer(context, { logger })
1312
+ return 0
1313
+ })
305
1314
  }
306
1315
 
307
1316
  if (parsed.command === 'doctor') {
308
- const checks = await runDoctorChecks(context)
1317
+ const checks = await (options.runDoctorChecks ?? runDoctorChecks)(context)
309
1318
  process.stdout.write(formatDoctorText(checks))
310
1319
  return doctorHasFailures(checks) ? 1 : 0
311
1320
  }
312
1321
 
1322
+ if (parsed.command === 'setup') {
1323
+ await runSetupPreflight(context, alias, parsed, options)
1324
+ const resolvedTargets = await resolveSshInstallTargets(parsed, options)
1325
+
1326
+ if (resolvedTargets.cancelled) {
1327
+ process.stderr.write('Canceled setup.\n')
1328
+ return 1
1329
+ }
1330
+
1331
+ writeWorkspaceMetadata(context, alias)
1332
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1333
+ await runLoggedLifecycle(context, 'setup', argv, async (logger) => {
1334
+ await withProgressSection(progress, 'Boxdown setup', [
1335
+ `Workspace: ${context.workspaceFolder}`,
1336
+ `SSH alias: ${alias}`
1337
+ ], async () => {
1338
+ progress.setSteps(setupProgressSteps(resolvedTargets.targets))
1339
+ await (options.setupWorkspace ?? setupWorkspace)(context, alias, {
1340
+ recreate: parsed.recreate,
1341
+ targets: resolvedTargets.targets,
1342
+ progress,
1343
+ logger
1344
+ })
1345
+ })
1346
+ })
1347
+
1348
+ if (resolvedTargets.skippedNonInteractive) {
1349
+ printSkippedSshInstallTargets('setup')
1350
+ }
1351
+
1352
+ return 0
1353
+ }
1354
+
313
1355
  if (!existsSync(context.assetsDevcontainerDir)) {
314
1356
  throw new Error(`Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`)
315
1357
  }
316
1358
 
317
1359
  if (parsed.command === 'ssh-proxy') {
318
- await installSshConfig(context, alias, { quiet: true })
319
- const containerId = await startDevcontainer(context, {
320
- recreate: parsed.recreate,
321
- proxyMode: true,
322
- reuseRunning: true
1360
+ return runLoggedLifecycle(context, 'ssh-proxy', argv, async (logger) => {
1361
+ const progress = createCliProgress(parsed, 'stderr', { env: options.env })
1362
+ const containerId = await withProgressSection(progress, 'Boxdown SSH proxy', [
1363
+ `Workspace: ${context.workspaceFolder}`,
1364
+ `SSH alias: ${alias}`
1365
+ ], async () => {
1366
+ progress.setSteps(sshProxyProgressSteps())
1367
+ progress.startStep('ssh-alias')
1368
+ try {
1369
+ await installSshConfig(context, alias, { quiet: true })
1370
+ progress.completeStep('ssh-alias')
1371
+ } catch (error) {
1372
+ progress.failStep('ssh-alias')
1373
+ throw error
1374
+ }
1375
+ const startedContainerId = await startDevcontainer(context, {
1376
+ recreate: parsed.recreate,
1377
+ proxyMode: true,
1378
+ progress,
1379
+ logger,
1380
+ reuseRunning: true
1381
+ })
1382
+ await refreshContainerCodingAgentClis(context, true, [], { progress, logger })
1383
+ await ensureContainerSshRuntime(context, { progress, logger })
1384
+ return startedContainerId
1385
+ })
1386
+ return runSshdProxy(containerId, { logger })
323
1387
  })
324
- await refreshContainerCodingAgentClis(context, true)
325
- await ensureContainerSshRuntime(context)
326
- return runSshdProxy(containerId)
327
1388
  }
328
1389
 
329
- if (parsed.command === 'refresh-gh-token-running') {
330
- const containerId = await findRunningContainerId(context)
331
- if (containerId === undefined) {
332
- throw new Error(`No running devcontainer found for: ${context.workspaceFolder}`)
1390
+ if (parsed.command === 'tunnel') {
1391
+ const resolvedTunnelPorts = await resolveTunnelPorts(parsed, context, options)
1392
+
1393
+ if (resolvedTunnelPorts.cancelled) {
1394
+ process.stderr.write('Canceled tunnel.\n')
1395
+ return 1
333
1396
  }
334
- await refreshContainerGhAuth(context)
335
- return 0
1397
+
1398
+ const tunnelPorts = resolvedTunnelPorts.tunnelPorts
1399
+ if (tunnelPorts.length === 0) {
1400
+ throw new Error('tunnel requires at least one --port value')
1401
+ }
1402
+
1403
+ writeWorkspaceMetadata(context, alias)
1404
+ return runLoggedLifecycle(context, 'tunnel', argv, async (logger) => {
1405
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1406
+ await withProgressSection(progress, 'Boxdown tunnel', [
1407
+ `Workspace: ${context.workspaceFolder}`,
1408
+ `SSH alias: ${alias}`
1409
+ ], async () => {
1410
+ progress.setSteps(tunnelProgressSteps())
1411
+ progress.startStep('ssh-alias')
1412
+ try {
1413
+ await installSshConfig(context, alias, { quiet: true })
1414
+ progress.completeStep('ssh-alias')
1415
+ } catch (error) {
1416
+ progress.failStep('ssh-alias')
1417
+ throw error
1418
+ }
1419
+ await startDevcontainer(context, {
1420
+ recreate: parsed.recreate,
1421
+ progress,
1422
+ logger,
1423
+ reuseRunning: true
1424
+ })
1425
+ })
1426
+
1427
+ const forwards = tunnelPorts
1428
+ .map((port) => `127.0.0.1:${port.localPort} -> localhost:${port.remotePort}`)
1429
+ .join(', ')
1430
+
1431
+ process.stdout.write(`Forwarding ${forwards}\n`)
1432
+ process.stdout.write('Press Ctrl-C to stop the tunnel.\n')
1433
+
1434
+ return openSshTunnel(alias, tunnelPorts, { logger })
1435
+ })
1436
+ }
1437
+
1438
+ if (parsed.command === 'refresh-gh-token-running') {
1439
+ return runLoggedLifecycle(context, 'refresh-gh-token-running', argv, async (logger) => {
1440
+ const containerId = await findRunningContainerId(context, { logger })
1441
+ if (containerId === undefined) {
1442
+ throw new Error(`No running devcontainer found for: ${context.workspaceFolder}`)
1443
+ }
1444
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1445
+ await withProgressSection(progress, 'Boxdown GitHub auth refresh', [
1446
+ `Workspace: ${context.workspaceFolder}`
1447
+ ], async () => {
1448
+ progress.setSteps(ghAuthProgressSteps(false))
1449
+ progress.startStep('devcontainer-running')
1450
+ progress.completeStep('devcontainer-running')
1451
+ await refreshContainerGhAuth(context, { progress, logger })
1452
+ })
1453
+ return 0
1454
+ })
336
1455
  }
337
1456
 
338
1457
  if (parsed.command === 'refresh-gh-token') {
339
- await startDevcontainer(context)
340
- await refreshContainerGhAuth(context)
341
- return 0
1458
+ return runLoggedLifecycle(context, 'refresh-gh-token', argv, async (logger) => {
1459
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1460
+ await withProgressSection(progress, 'Boxdown GitHub auth refresh', [
1461
+ `Workspace: ${context.workspaceFolder}`
1462
+ ], async () => {
1463
+ progress.setSteps(ghAuthProgressSteps(true))
1464
+ await startDevcontainer(context, { progress, logger })
1465
+ await refreshContainerGhAuth(context, { progress, logger })
1466
+ })
1467
+ return 0
1468
+ })
342
1469
  }
343
1470
 
344
1471
  if (parsed.command === 'coding-agent') {
@@ -347,14 +1474,38 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
347
1474
  throw new Error('Missing coding-agent command')
348
1475
  }
349
1476
 
350
- await startDevcontainer(context, { recreate: parsed.recreate })
351
- await refreshContainerCodingAgentClis(context, false, [agent])
352
- return openCodingAgentCli(context, agent, parsed.agentArgs ?? [])
1477
+ return runLoggedLifecycle(context, agent, argv, async (logger) => {
1478
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1479
+ await withProgressSection(progress, `Boxdown ${agent}`, [
1480
+ `Workspace: ${context.workspaceFolder}`
1481
+ ], async () => {
1482
+ progress.setSteps(codingAgentProgressSteps(agent))
1483
+ await startDevcontainer(context, {
1484
+ recreate: parsed.recreate,
1485
+ progress,
1486
+ logger
1487
+ })
1488
+ await ensureContainerCodingAgentCli(context, agent, { progress, logger })
1489
+ })
1490
+ return openCodingAgentCli(context, agent, parsed.agentArgs ?? [], { logger })
1491
+ })
353
1492
  }
354
1493
 
355
- const containerId = await startDevcontainer(context, { recreate: parsed.recreate })
356
- await printPortHint(context, containerId)
357
- return openShell(context)
1494
+ return runLoggedLifecycle(context, 'start', argv, async (logger) => {
1495
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1496
+ const containerId = await withProgressSection(progress, 'Boxdown start', [
1497
+ `Workspace: ${context.workspaceFolder}`
1498
+ ], async () => {
1499
+ progress.setSteps(startProgressSteps())
1500
+ return await startDevcontainer(context, {
1501
+ recreate: parsed.recreate,
1502
+ progress,
1503
+ logger
1504
+ })
1505
+ })
1506
+ await printPortHint(context, containerId, { logger })
1507
+ return openShell(context, { logger })
1508
+ })
358
1509
  } catch (error) {
359
1510
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
360
1511
  return 1