boxdown 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +49 -17
  3. package/assets/devcontainer/devcontainer.json +24 -7
  4. package/assets/devcontainer/hooks/initialize.sh +32 -0
  5. package/assets/devcontainer/hooks/post-create.sh +59 -12
  6. package/assets/devcontainer/hooks/post-start.sh +21 -4
  7. package/assets/devcontainer/ssh-config-install.sh +12 -3
  8. package/assets/devcontainer/start.sh +721 -44
  9. package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
  10. package/assets/devcontainer/utils/deps-install.sh +68 -0
  11. package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
  12. package/assets/devcontainer/utils/git-signing-bootstrap.sh +56 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  15. package/dist/bin/cli.cjs +1 -1
  16. package/dist/bin/cli.mjs +1 -1
  17. package/dist/main-Df4E8ARj.cjs +5498 -0
  18. package/dist/main-XMBsKjIK.mjs +5458 -0
  19. package/dist/main-XMBsKjIK.mjs.map +1 -0
  20. package/dist/main.cjs +5 -2
  21. package/dist/main.d.cts +495 -4
  22. package/dist/main.d.cts.map +1 -1
  23. package/dist/main.d.mts +495 -4
  24. package/dist/main.d.mts.map +1 -1
  25. package/dist/main.mjs +2 -2
  26. package/docs/README.md +1 -0
  27. package/docs/architecture.md +32 -0
  28. package/docs/development.md +13 -6
  29. package/docs/features/README.md +2 -0
  30. package/docs/features/commit-signing.md +23 -0
  31. package/docs/features/generated-config-and-state.md +57 -4
  32. package/docs/features/github-auth-refresh.md +12 -0
  33. package/docs/features/lifecycle.md +97 -11
  34. package/docs/features/setup.md +66 -0
  35. package/docs/features/ssh-config-and-proxy.md +228 -7
  36. package/docs/features/start-and-shell.md +40 -5
  37. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  38. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  39. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +396 -0
  40. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  41. package/docs/testing.md +35 -2
  42. package/docs/todo.md +2 -2
  43. package/package.json +1 -1
  44. package/src/claude-app-config.ts +304 -0
  45. package/src/cli-style.ts +43 -0
  46. package/src/codex-app-config.ts +656 -0
  47. package/src/config.ts +68 -10
  48. package/src/constants.ts +5 -0
  49. package/src/devcontainer.ts +500 -64
  50. package/src/doctor.ts +195 -30
  51. package/src/git-signing.ts +87 -0
  52. package/src/interactive-prompts.ts +692 -0
  53. package/src/list.ts +16 -2
  54. package/src/logging.ts +154 -0
  55. package/src/main.ts +1213 -63
  56. package/src/metadata.ts +52 -3
  57. package/src/package-info.ts +18 -0
  58. package/src/paths.ts +50 -10
  59. package/src/process.ts +80 -2
  60. package/src/progress.ts +593 -0
  61. package/src/purge.ts +208 -0
  62. package/src/shell.ts +19 -0
  63. package/src/ssh-config.ts +134 -16
  64. package/src/ssh-install-targets.ts +111 -0
  65. package/src/ssh-key.ts +53 -13
  66. package/src/status.ts +5 -0
  67. package/dist/main-BuEptwlL.cjs +0 -1707
  68. package/dist/main-ZFTrSVgt.mjs +0 -1685
  69. package/dist/main-ZFTrSVgt.mjs.map +0 -1
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 } 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,746 @@ 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
+ })
835
+ } catch (error) {
836
+ failed = true
837
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
838
+ }
839
+ }
840
+
841
+ return failed ? 1 : 0
842
+ }
843
+
844
+ interface ResolvedSshInstallTargets {
845
+ targets: SshConfigInstallTarget[]
846
+ cancelled: boolean
847
+ skippedNonInteractive: boolean
848
+ }
849
+
850
+ interface ResolvedTunnelPorts {
851
+ tunnelPorts: TunnelPortForward[]
852
+ cancelled: boolean
853
+ }
854
+
855
+ async function resolveTunnelPorts (
856
+ parsed: ParsedCli,
857
+ context: ReturnType<typeof createWorkspaceContext>,
858
+ options: RunCliOptions
859
+ ): Promise<ResolvedTunnelPorts> {
860
+ if (parsed.tunnelPorts !== undefined && parsed.tunnelPorts.length > 0) {
861
+ return {
862
+ tunnelPorts: parsed.tunnelPorts,
863
+ cancelled: false
864
+ }
865
+ }
866
+
867
+ const input = options.promptInput ?? process.stdin
868
+ const output = options.promptOutput ?? process.stdout
869
+ const env = options.env ?? process.env
870
+
871
+ if (!canPromptInteractively(input, output, env)) {
872
+ return {
873
+ tunnelPorts: [],
874
+ cancelled: false
875
+ }
876
+ }
877
+
878
+ const defaultPort = publishContainerPortFromConfig(buildGeneratedDevcontainerConfig(context))
879
+ const result = await promptText({
880
+ title: 'Tunnel port(s) to forward?',
881
+ details: ['Use a port like 3030, or a mapping like 8080:3031.'],
882
+ defaultValue: defaultPort,
883
+ summaryLabel: 'Tunnel ports',
884
+ validate: (value) => {
885
+ try {
886
+ parseTunnelPortList(value)
887
+ return undefined
888
+ } catch (error) {
889
+ return error instanceof Error ? error.message : String(error)
890
+ }
891
+ },
892
+ input,
893
+ output,
894
+ env
895
+ })
896
+
897
+ if (result.status === 'submitted') {
898
+ return {
899
+ tunnelPorts: parseTunnelPortList(result.value),
900
+ cancelled: false
901
+ }
902
+ }
903
+
904
+ return {
905
+ tunnelPorts: [],
906
+ cancelled: result.status === 'cancelled'
907
+ }
908
+ }
909
+
910
+ async function confirmPurgeWorkspace (
911
+ context: ReturnType<typeof createWorkspaceContext>,
912
+ parsed: ParsedCli,
913
+ options: RunCliOptions
914
+ ): Promise<boolean> {
915
+ const result = await promptConfirm({
916
+ title: 'Purge Boxdown workspace?',
917
+ details: [
918
+ `Workspace: ${context.workspaceFolder}`,
919
+ 'Removes devcontainer, recorded image, SSH/Codex entries, cache, and data.',
920
+ parsed.alias === undefined ? 'Alias: default and recorded aliases' : `Alias: ${parsed.alias}, default, and recorded aliases`
921
+ ],
922
+ confirmLabel: 'Purge',
923
+ cancelLabel: 'Cancel',
924
+ summaryLabel: 'Purge',
925
+ input: options.promptInput,
926
+ output: options.promptOutput,
927
+ env: options.env
928
+ })
929
+
930
+ return result.status === 'confirmed' || result.status === 'non-interactive'
931
+ }
932
+
933
+ async function resolveSshInstallTargets (
934
+ parsed: ParsedCli,
935
+ options: RunCliOptions
936
+ ): Promise<ResolvedSshInstallTargets> {
937
+ if (parsed.targets !== undefined) {
938
+ return {
939
+ targets: parsed.targets,
940
+ cancelled: false,
941
+ skippedNonInteractive: false
942
+ }
943
+ }
944
+
945
+ const result = await promptMultiSelect<SshConfigInstallTarget>({
946
+ title: 'Install optional SSH targets?',
947
+ choices: SSH_INSTALL_TARGETS.map((target) => ({
948
+ value: target.value,
949
+ label: target.label,
950
+ description: target.description
951
+ })),
952
+ skipLabel: 'Skip optional targets',
953
+ summaryLabel: 'Optional SSH targets',
954
+ input: options.promptInput,
955
+ output: options.promptOutput,
956
+ env: options.env
957
+ })
958
+
959
+ return {
960
+ targets: result.values,
961
+ cancelled: result.status === 'cancelled',
962
+ skippedNonInteractive: result.status === 'non-interactive'
963
+ }
964
+ }
965
+
966
+ function printSkippedSshInstallTargets (command: 'setup' | 'ssh install'): void {
967
+ process.stdout.write(`\nNo optional SSH install targets selected. Run boxdown ${command} ${sshInstallTargetFlagHintsText()} to install optional targets explicitly. Supported targets: ${supportedSshInstallTargetsText()}.\n`)
968
+ }
969
+
970
+ interface SetupWorkspaceOptions {
971
+ recreate?: boolean
972
+ targets?: SshConfigInstallTarget[]
973
+ progress?: ProgressReporter
974
+ logger?: WorkspaceCommandLogger
975
+ start?: typeof startDevcontainer
976
+ installSsh?: typeof installSshConfig
977
+ installTarget?: typeof installSshInstallTarget
978
+ }
979
+
980
+ export async function setupWorkspace (
981
+ context: WorkspaceContext,
982
+ alias: string,
983
+ options: SetupWorkspaceOptions = {}
984
+ ): Promise<void> {
985
+ await (options.start ?? startDevcontainer)(context, {
986
+ recreate: options.recreate,
987
+ ...(options.logger === undefined ? {} : { logger: options.logger }),
988
+ ...(options.progress === undefined ? {} : { progress: options.progress })
989
+ })
990
+
991
+ const hasSshAliasStep = options.progress?.hasStep('ssh-alias') === true
992
+
993
+ if (options.progress?.mode === 'interactive') {
994
+ if (hasSshAliasStep) {
995
+ options.progress.startStep('ssh-alias')
996
+ } else {
997
+ options.progress.item('Installing SSH alias')
998
+ options.progress.detail(alias)
999
+ }
1000
+
1001
+ try {
1002
+ await (options.installSsh ?? installSshConfig)(context, alias, { quiet: true })
1003
+ if (hasSshAliasStep) {
1004
+ options.progress.completeStep('ssh-alias')
1005
+ }
1006
+ } catch (error) {
1007
+ if (hasSshAliasStep) {
1008
+ options.progress.failStep('ssh-alias')
1009
+ }
1010
+ throw error
1011
+ }
1012
+ } else {
1013
+ await (options.installSsh ?? installSshConfig)(context, alias)
1014
+ }
1015
+
1016
+ const installTarget = options.installTarget ?? installSshInstallTarget
1017
+ for (const target of options.targets ?? []) {
1018
+ const stepId = `ssh-target:${target}`
1019
+ const hasTargetStep = options.progress?.hasStep(stepId) === true
1020
+
1021
+ if (hasTargetStep) {
1022
+ options.progress?.startStep(stepId)
1023
+ } else if (options.progress?.mode === 'interactive') {
1024
+ options.progress.item(`Installing ${target} SSH target`)
1025
+ }
1026
+
1027
+ try {
1028
+ await installTarget(context, alias, target, {
1029
+ quiet: options.progress?.mode === 'interactive'
1030
+ })
1031
+ if (hasTargetStep) {
1032
+ options.progress?.completeStep(stepId)
1033
+ }
1034
+ } catch (error) {
1035
+ if (hasTargetStep) {
1036
+ options.progress?.failStep(stepId)
1037
+ }
1038
+ throw error
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ function createCliProgress (
1044
+ parsed: ParsedCli,
1045
+ target: ProgressOutputTarget = 'stdout',
1046
+ options: { env?: NodeJS.ProcessEnv } = {}
1047
+ ): ProgressReporter {
1048
+ return createProgress({
1049
+ mode: resolveProgressMode({
1050
+ verbose: parsed.verbose,
1051
+ json: parsed.json,
1052
+ target,
1053
+ env: options.env
1054
+ }),
1055
+ target
1056
+ })
1057
+ }
1058
+
1059
+ function startProgressSteps (): ProgressStepDefinition[] {
1060
+ return [
1061
+ { id: 'ssh-identity', label: 'Preparing SSH identity' },
1062
+ { id: 'devcontainer-config', label: 'Writing generated devcontainer config' },
1063
+ { id: 'devcontainer-start', label: 'Starting devcontainer' }
1064
+ ]
1065
+ }
1066
+
1067
+ function sshTargetProgressLabel (target: SshConfigInstallTarget): string {
1068
+ const label = SSH_INSTALL_TARGETS.find((candidate) => candidate.value === target)?.label ?? target
1069
+ return `Installing ${label} SSH target`
1070
+ }
1071
+
1072
+ function setupProgressSteps (targets: readonly SshConfigInstallTarget[]): ProgressStepDefinition[] {
1073
+ return [
1074
+ ...startProgressSteps(),
1075
+ { id: 'ssh-alias', label: 'Installing SSH alias' },
1076
+ ...targets.map((target) => ({
1077
+ id: `ssh-target:${target}`,
1078
+ label: sshTargetProgressLabel(target)
1079
+ }))
1080
+ ]
1081
+ }
1082
+
1083
+ function setupPreflightProgressSteps (): ProgressStepDefinition[] {
1084
+ return [{ id: 'setup-preflight', label: 'Checking host readiness' }]
1085
+ }
1086
+
1087
+ function setupPreflightFailureMessage (checks: DoctorCheck[]): string {
1088
+ const failures = checks
1089
+ .filter((check) => check.level === 'fail')
1090
+ .map((check) => `- ${check.name}: ${check.message}`)
1091
+
1092
+ return `Setup preflight failed:\n${failures.join('\n')}`
1093
+ }
1094
+
1095
+ async function runSetupPreflight (
1096
+ context: WorkspaceContext,
1097
+ alias: string,
1098
+ parsed: ParsedCli,
1099
+ options: RunCliOptions
1100
+ ): Promise<void> {
1101
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1102
+ const doctor = options.runDoctorChecks ?? runDoctorChecks
1103
+
1104
+ await withProgressSection(progress, 'Boxdown setup', [
1105
+ `Workspace: ${context.workspaceFolder}`,
1106
+ `SSH alias: ${alias}`
1107
+ ], async () => {
1108
+ progress.setSteps(setupPreflightProgressSteps())
1109
+ progress.startStep('setup-preflight')
1110
+ const checks = await doctor(context, { includeOptional: false })
1111
+
1112
+ for (const check of checks.filter((item) => item.level === 'warn')) {
1113
+ progress.warn(`${check.name}: ${check.message}`)
1114
+ }
1115
+
1116
+ if (doctorHasFailures(checks)) {
1117
+ progress.failStep('setup-preflight')
1118
+ throw new Error(setupPreflightFailureMessage(checks))
1119
+ }
1120
+
1121
+ progress.completeStep('setup-preflight')
1122
+ })
1123
+ }
1124
+
1125
+ function sshAliasProgressStep (label: string): ProgressStepDefinition {
1126
+ return { id: 'ssh-alias', label }
1127
+ }
1128
+
1129
+ function tunnelProgressSteps (): ProgressStepDefinition[] {
1130
+ return [
1131
+ sshAliasProgressStep('Updating SSH alias'),
1132
+ ...startProgressSteps()
1133
+ ]
1134
+ }
1135
+
1136
+ function sshProxyProgressSteps (): ProgressStepDefinition[] {
1137
+ return [
1138
+ sshAliasProgressStep('Updating SSH alias'),
1139
+ ...startProgressSteps(),
1140
+ { id: 'coding-agent-refresh', label: 'Refreshing default coding-agent CLIs' },
1141
+ { id: 'ssh-runtime', label: 'Preparing container SSH runtime' }
1142
+ ]
1143
+ }
1144
+
1145
+ function codingAgentProgressSteps (agent: CodingAgentCli): ProgressStepDefinition[] {
1146
+ return [
1147
+ ...startProgressSteps(),
1148
+ { id: 'agent-cli', label: `Preparing ${codingAgentBinary(agent)} inside the devcontainer` }
1149
+ ]
1150
+ }
1151
+
1152
+ function ghAuthProgressSteps (includeStart: boolean): ProgressStepDefinition[] {
1153
+ return [
1154
+ ...(includeStart ? startProgressSteps() : [{ id: 'devcontainer-running', label: 'Using running devcontainer' }]),
1155
+ { id: 'gh-auth-config', label: 'Preparing generated config for GitHub auth refresh' },
1156
+ { id: 'gh-token-read', label: 'Reading host GitHub CLI token' },
1157
+ { id: 'gh-auth-refresh', label: 'Refreshing GitHub CLI auth inside the devcontainer' },
1158
+ { id: 'gh-git-auth', label: 'Configuring workspace GitHub Git auth' },
1159
+ { id: 'gh-auth-verify', label: 'Verifying GitHub CLI auth inside the devcontainer' }
1160
+ ]
1161
+ }
1162
+
1163
+ async function withProgressSection<T> (
1164
+ progress: ProgressReporter,
1165
+ title: string,
1166
+ details: readonly string[],
1167
+ run: () => Promise<T>
1168
+ ): Promise<T> {
1169
+ progress.section(title)
1170
+ for (const detail of details) {
1171
+ progress.detail(detail)
1172
+ }
1173
+
1174
+ try {
1175
+ return await run()
1176
+ } finally {
1177
+ progress.end()
1178
+ }
1179
+ }
1180
+
1181
+ export async function runCli (argv: string[] = process.argv.slice(2), options: RunCliOptions = {}): Promise<number> {
249
1182
  try {
250
1183
  const parsed = parseCliArgs(argv)
251
1184
 
@@ -254,6 +1187,11 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
254
1187
  return 0
255
1188
  }
256
1189
 
1190
+ if (parsed.command === 'version') {
1191
+ process.stdout.write(`${readPackageVersion()}\n`)
1192
+ return 0
1193
+ }
1194
+
257
1195
  if (parsed.command === 'list') {
258
1196
  const metadata = listWorkspaceMetadata(defaultDataRoot())
259
1197
  const containers = await listWorkspaceContainers()
@@ -261,6 +1199,8 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
261
1199
 
262
1200
  if (parsed.json) {
263
1201
  process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`)
1202
+ } else if (parsed.details) {
1203
+ process.stdout.write(formatWorkspaceListDetailsText(entries))
264
1204
  } else {
265
1205
  process.stdout.write(formatWorkspaceListText(entries))
266
1206
  }
@@ -268,16 +1208,87 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
268
1208
  return 0
269
1209
  }
270
1210
 
1211
+ if (parsed.command === 'down') {
1212
+ return runDownCommand(parsed.workspaces, options)
1213
+ }
1214
+
1215
+ if (parsed.command === 'purge') {
1216
+ return await runPurgeCommand(parsed, argv, options)
1217
+ }
1218
+
271
1219
  const context = createWorkspaceContext({ workspace: parsed.workspace })
272
1220
  const alias = parsed.alias ?? defaultSshAlias(context.workspaceBasename)
273
1221
  const aliasSource = parsed.alias === undefined ? 'default' : 'provided'
274
1222
 
275
- if (commandWritesWorkspaceMetadata(parsed.command)) {
1223
+ if (parsed.command !== 'ssh-install' && parsed.command !== 'setup' && parsed.command !== 'tunnel' && commandWritesWorkspaceMetadata(parsed.command)) {
276
1224
  writeWorkspaceMetadata(context, alias)
277
1225
  }
278
1226
 
279
- if (parsed.command === 'ssh-config-install') {
1227
+ if (parsed.command === 'ssh-install') {
1228
+ const resolvedTargets = await resolveSshInstallTargets(parsed, options)
1229
+
1230
+ if (resolvedTargets.cancelled) {
1231
+ process.stderr.write('Canceled SSH install.\n')
1232
+ return 1
1233
+ }
1234
+
1235
+ writeWorkspaceMetadata(context, alias)
280
1236
  await installSshConfig(context, alias)
1237
+
1238
+ if (resolvedTargets.skippedNonInteractive) {
1239
+ printSkippedSshInstallTargets('ssh install')
1240
+ }
1241
+
1242
+ for (const target of resolvedTargets.targets) {
1243
+ await installSshInstallTarget(context, alias, target)
1244
+ }
1245
+
1246
+ return 0
1247
+ }
1248
+
1249
+ if (parsed.command === 'ssh-uninstall') {
1250
+ uninstallSshConfig(alias)
1251
+ const entry = codexProjectEntryForWorkspace(context, alias)
1252
+ const legacyRemotePath = legacyCodexRemotePathForWorkspace(context)
1253
+ const result = uninstallCodexAppConfigProject(entry, {
1254
+ additionalRemotePaths: [legacyRemotePath]
1255
+ })
1256
+
1257
+ process.stdout.write(`\nCodex app config: ${result.configPath}\n`)
1258
+ process.stdout.write(result.changed
1259
+ ? `Removed Codex remote project: ${entry.label} (${entry.remotePath})\n`
1260
+ : `Codex remote project not installed: ${entry.label} (${entry.remotePath})\n`)
1261
+
1262
+ if (result.backupPath !== undefined) {
1263
+ process.stdout.write(`Codex app config backup: ${result.backupPath}\n`)
1264
+ }
1265
+
1266
+ const stateResult = uninstallCodexGlobalStateProject(entry, {
1267
+ additionalRemotePaths: [legacyRemotePath]
1268
+ })
1269
+
1270
+ process.stdout.write(`\nCodex app state: ${stateResult.statePath}\n`)
1271
+ process.stdout.write(stateResult.changed
1272
+ ? `Removed Codex sidebar state: ${entry.label} (${entry.remotePath})\n`
1273
+ : `Codex sidebar state not installed: ${entry.label} (${entry.remotePath})\n`)
1274
+
1275
+ if (stateResult.backupPath !== undefined) {
1276
+ process.stdout.write(`Codex app state backup: ${stateResult.backupPath}\n`)
1277
+ }
1278
+
1279
+ const claudeEntry = claudeSshConfigEntryForWorkspace(context, alias)
1280
+ const claudeResult = uninstallClaudeSshConfigHost(claudeEntry)
1281
+
1282
+ process.stdout.write(`\nClaude SSH config: ${claudeResult.configPath}\n`)
1283
+ process.stdout.write(claudeResult.changed
1284
+ ? `Removed Claude SSH remote: ${claudeEntry.name} (${claudeEntry.sshHost})\n`
1285
+ : `Claude SSH remote not installed: ${claudeEntry.name} (${claudeEntry.sshHost})\n`)
1286
+
1287
+ if (claudeResult.backupPath !== undefined) {
1288
+ process.stdout.write(`Claude SSH config backup: ${claudeResult.backupPath}\n`)
1289
+ }
1290
+
1291
+ process.stdout.write('Restart Codex and Claude to apply the remote project removal.\n')
281
1292
  return 0
282
1293
  }
283
1294
 
@@ -295,50 +1306,165 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
295
1306
  }
296
1307
 
297
1308
  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
1309
+ return runLoggedLifecycle(context, 'stop', argv, async (logger) => {
1310
+ await stopWorkspaceContainer(context, { logger })
1311
+ return 0
1312
+ })
305
1313
  }
306
1314
 
307
1315
  if (parsed.command === 'doctor') {
308
- const checks = await runDoctorChecks(context)
1316
+ const checks = await (options.runDoctorChecks ?? runDoctorChecks)(context)
309
1317
  process.stdout.write(formatDoctorText(checks))
310
1318
  return doctorHasFailures(checks) ? 1 : 0
311
1319
  }
312
1320
 
1321
+ if (parsed.command === 'setup') {
1322
+ await runSetupPreflight(context, alias, parsed, options)
1323
+ const resolvedTargets = await resolveSshInstallTargets(parsed, options)
1324
+
1325
+ if (resolvedTargets.cancelled) {
1326
+ process.stderr.write('Canceled setup.\n')
1327
+ return 1
1328
+ }
1329
+
1330
+ writeWorkspaceMetadata(context, alias)
1331
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1332
+ await runLoggedLifecycle(context, 'setup', argv, async (logger) => {
1333
+ await withProgressSection(progress, 'Boxdown setup', [
1334
+ `Workspace: ${context.workspaceFolder}`,
1335
+ `SSH alias: ${alias}`
1336
+ ], async () => {
1337
+ progress.setSteps(setupProgressSteps(resolvedTargets.targets))
1338
+ await (options.setupWorkspace ?? setupWorkspace)(context, alias, {
1339
+ recreate: parsed.recreate,
1340
+ targets: resolvedTargets.targets,
1341
+ progress,
1342
+ logger
1343
+ })
1344
+ })
1345
+ })
1346
+
1347
+ if (resolvedTargets.skippedNonInteractive) {
1348
+ printSkippedSshInstallTargets('setup')
1349
+ }
1350
+
1351
+ return 0
1352
+ }
1353
+
313
1354
  if (!existsSync(context.assetsDevcontainerDir)) {
314
1355
  throw new Error(`Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`)
315
1356
  }
316
1357
 
317
1358
  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
1359
+ return runLoggedLifecycle(context, 'ssh-proxy', argv, async (logger) => {
1360
+ const progress = createCliProgress(parsed, 'stderr', { env: options.env })
1361
+ const containerId = await withProgressSection(progress, 'Boxdown SSH proxy', [
1362
+ `Workspace: ${context.workspaceFolder}`,
1363
+ `SSH alias: ${alias}`
1364
+ ], async () => {
1365
+ progress.setSteps(sshProxyProgressSteps())
1366
+ progress.startStep('ssh-alias')
1367
+ try {
1368
+ await installSshConfig(context, alias, { quiet: true })
1369
+ progress.completeStep('ssh-alias')
1370
+ } catch (error) {
1371
+ progress.failStep('ssh-alias')
1372
+ throw error
1373
+ }
1374
+ const startedContainerId = await startDevcontainer(context, {
1375
+ recreate: parsed.recreate,
1376
+ proxyMode: true,
1377
+ progress,
1378
+ logger,
1379
+ reuseRunning: true
1380
+ })
1381
+ await refreshContainerCodingAgentClis(context, true, [], { progress, logger })
1382
+ await ensureContainerSshRuntime(context, { progress, logger })
1383
+ return startedContainerId
1384
+ })
1385
+ return runSshdProxy(containerId, { logger })
323
1386
  })
324
- await refreshContainerCodingAgentClis(context, true)
325
- await ensureContainerSshRuntime(context)
326
- return runSshdProxy(containerId)
327
1387
  }
328
1388
 
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}`)
1389
+ if (parsed.command === 'tunnel') {
1390
+ const resolvedTunnelPorts = await resolveTunnelPorts(parsed, context, options)
1391
+
1392
+ if (resolvedTunnelPorts.cancelled) {
1393
+ process.stderr.write('Canceled tunnel.\n')
1394
+ return 1
333
1395
  }
334
- await refreshContainerGhAuth(context)
335
- return 0
1396
+
1397
+ const tunnelPorts = resolvedTunnelPorts.tunnelPorts
1398
+ if (tunnelPorts.length === 0) {
1399
+ throw new Error('tunnel requires at least one --port value')
1400
+ }
1401
+
1402
+ writeWorkspaceMetadata(context, alias)
1403
+ return runLoggedLifecycle(context, 'tunnel', argv, async (logger) => {
1404
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1405
+ await withProgressSection(progress, 'Boxdown tunnel', [
1406
+ `Workspace: ${context.workspaceFolder}`,
1407
+ `SSH alias: ${alias}`
1408
+ ], async () => {
1409
+ progress.setSteps(tunnelProgressSteps())
1410
+ progress.startStep('ssh-alias')
1411
+ try {
1412
+ await installSshConfig(context, alias, { quiet: true })
1413
+ progress.completeStep('ssh-alias')
1414
+ } catch (error) {
1415
+ progress.failStep('ssh-alias')
1416
+ throw error
1417
+ }
1418
+ await startDevcontainer(context, {
1419
+ recreate: parsed.recreate,
1420
+ progress,
1421
+ logger,
1422
+ reuseRunning: true
1423
+ })
1424
+ })
1425
+
1426
+ const forwards = tunnelPorts
1427
+ .map((port) => `127.0.0.1:${port.localPort} -> localhost:${port.remotePort}`)
1428
+ .join(', ')
1429
+
1430
+ process.stdout.write(`Forwarding ${forwards}\n`)
1431
+ process.stdout.write('Press Ctrl-C to stop the tunnel.\n')
1432
+
1433
+ return openSshTunnel(alias, tunnelPorts, { logger })
1434
+ })
1435
+ }
1436
+
1437
+ if (parsed.command === 'refresh-gh-token-running') {
1438
+ return runLoggedLifecycle(context, 'refresh-gh-token-running', argv, async (logger) => {
1439
+ const containerId = await findRunningContainerId(context, { logger })
1440
+ if (containerId === undefined) {
1441
+ throw new Error(`No running devcontainer found for: ${context.workspaceFolder}`)
1442
+ }
1443
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1444
+ await withProgressSection(progress, 'Boxdown GitHub auth refresh', [
1445
+ `Workspace: ${context.workspaceFolder}`
1446
+ ], async () => {
1447
+ progress.setSteps(ghAuthProgressSteps(false))
1448
+ progress.startStep('devcontainer-running')
1449
+ progress.completeStep('devcontainer-running')
1450
+ await refreshContainerGhAuth(context, { progress, logger })
1451
+ })
1452
+ return 0
1453
+ })
336
1454
  }
337
1455
 
338
1456
  if (parsed.command === 'refresh-gh-token') {
339
- await startDevcontainer(context)
340
- await refreshContainerGhAuth(context)
341
- return 0
1457
+ return runLoggedLifecycle(context, 'refresh-gh-token', argv, async (logger) => {
1458
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1459
+ await withProgressSection(progress, 'Boxdown GitHub auth refresh', [
1460
+ `Workspace: ${context.workspaceFolder}`
1461
+ ], async () => {
1462
+ progress.setSteps(ghAuthProgressSteps(true))
1463
+ await startDevcontainer(context, { progress, logger })
1464
+ await refreshContainerGhAuth(context, { progress, logger })
1465
+ })
1466
+ return 0
1467
+ })
342
1468
  }
343
1469
 
344
1470
  if (parsed.command === 'coding-agent') {
@@ -347,14 +1473,38 @@ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<n
347
1473
  throw new Error('Missing coding-agent command')
348
1474
  }
349
1475
 
350
- await startDevcontainer(context, { recreate: parsed.recreate })
351
- await refreshContainerCodingAgentClis(context, false, [agent])
352
- return openCodingAgentCli(context, agent, parsed.agentArgs ?? [])
1476
+ return runLoggedLifecycle(context, agent, argv, async (logger) => {
1477
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1478
+ await withProgressSection(progress, `Boxdown ${agent}`, [
1479
+ `Workspace: ${context.workspaceFolder}`
1480
+ ], async () => {
1481
+ progress.setSteps(codingAgentProgressSteps(agent))
1482
+ await startDevcontainer(context, {
1483
+ recreate: parsed.recreate,
1484
+ progress,
1485
+ logger
1486
+ })
1487
+ await ensureContainerCodingAgentCli(context, agent, { progress, logger })
1488
+ })
1489
+ return openCodingAgentCli(context, agent, parsed.agentArgs ?? [], { logger })
1490
+ })
353
1491
  }
354
1492
 
355
- const containerId = await startDevcontainer(context, { recreate: parsed.recreate })
356
- await printPortHint(context, containerId)
357
- return openShell(context)
1493
+ return runLoggedLifecycle(context, 'start', argv, async (logger) => {
1494
+ const progress = createCliProgress(parsed, 'stdout', { env: options.env })
1495
+ const containerId = await withProgressSection(progress, 'Boxdown start', [
1496
+ `Workspace: ${context.workspaceFolder}`
1497
+ ], async () => {
1498
+ progress.setSteps(startProgressSteps())
1499
+ return await startDevcontainer(context, {
1500
+ recreate: parsed.recreate,
1501
+ progress,
1502
+ logger
1503
+ })
1504
+ })
1505
+ await printPortHint(context, containerId, { logger })
1506
+ return openShell(context, { logger })
1507
+ })
358
1508
  } catch (error) {
359
1509
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
360
1510
  return 1