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