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.
- package/README.md +135 -12
- package/assets/devcontainer/README.md +65 -21
- package/assets/devcontainer/devcontainer.json +27 -15
- package/assets/devcontainer/hooks/initialize.sh +76 -22
- package/assets/devcontainer/hooks/post-create.sh +70 -12
- package/assets/devcontainer/hooks/post-start.sh +20 -13
- package/assets/devcontainer/ssh-config-install.sh +12 -3
- package/assets/devcontainer/start.sh +721 -44
- package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
- package/assets/devcontainer/utils/deps-install.sh +68 -0
- package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
- package/assets/devcontainer/utils/git-signing-bootstrap.sh +109 -0
- package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
- package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
- package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
- package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
- package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
- package/dist/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/main-BDgyf2t5.cjs +5758 -0
- package/dist/main-J4_2Up3o.mjs +5718 -0
- package/dist/main-J4_2Up3o.mjs.map +1 -0
- package/dist/main.cjs +5 -2
- package/dist/main.d.cts +501 -4
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +501 -4
- package/dist/main.d.mts.map +1 -1
- package/dist/main.mjs +2 -2
- package/docs/README.md +1 -0
- package/docs/architecture.md +32 -0
- package/docs/development.md +13 -6
- package/docs/features/README.md +2 -0
- package/docs/features/commit-signing.md +94 -0
- package/docs/features/generated-config-and-state.md +73 -5
- package/docs/features/github-auth-refresh.md +15 -2
- package/docs/features/lifecycle.md +103 -11
- package/docs/features/setup.md +66 -0
- package/docs/features/ssh-config-and-proxy.md +228 -7
- package/docs/features/start-and-shell.md +45 -5
- package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
- package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
- package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
- package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
- package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +416 -0
- package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
- package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
- package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
- package/docs/testing.md +35 -2
- package/docs/todo.md +2 -2
- package/package.json +1 -1
- package/src/claude-app-config.ts +304 -0
- package/src/cli-style.ts +43 -0
- package/src/codex-app-config.ts +656 -0
- package/src/config.ts +80 -10
- package/src/constants.ts +12 -0
- package/src/devcontainer.ts +511 -64
- package/src/doctor.ts +292 -30
- package/src/git-signing.ts +267 -0
- package/src/interactive-prompts.ts +692 -0
- package/src/list.ts +16 -2
- package/src/logging.ts +164 -0
- package/src/main.ts +1214 -63
- package/src/metadata.ts +52 -3
- package/src/package-info.ts +18 -0
- package/src/paths.ts +71 -11
- package/src/process.ts +80 -2
- package/src/progress.ts +593 -0
- package/src/purge.ts +216 -0
- package/src/shell.ts +25 -0
- package/src/ssh-config.ts +134 -16
- package/src/ssh-install-targets.ts +111 -0
- package/src/ssh-key.ts +53 -13
- package/src/status.ts +5 -0
- package/dist/main-BuEptwlL.cjs +0 -1707
- package/dist/main-ZFTrSVgt.mjs +0 -1685
- 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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
58
|
-
Code.
|
|
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
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
411
|
+
if (positional[0] === 'ssh') {
|
|
226
412
|
if (positional.length === 1 || (positional[1] === 'install' && positional.length === 2)) {
|
|
227
|
-
return parsed('ssh-
|
|
413
|
+
return parsed('ssh-install')
|
|
228
414
|
}
|
|
229
415
|
|
|
230
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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 === '
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|