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/doctor.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
2
3
|
|
|
3
4
|
import { resolveDevcontainerCli } from './devcontainer-cli.ts'
|
|
5
|
+
import { buildGeneratedDevcontainerConfig, type DevcontainerConfig } from './config.ts'
|
|
6
|
+
import { BOXDOWN_SECRET_ENV_NAMES } from './constants.ts'
|
|
7
|
+
import { resolveConfiguredSshSigningKey, selectGitSigningKey, type GitSigningReason } from './git-signing.ts'
|
|
4
8
|
import type { WorkspaceContext } from './paths.ts'
|
|
5
9
|
import { runBuffered } from './process.ts'
|
|
6
10
|
|
|
@@ -12,6 +16,20 @@ export interface DoctorCheck {
|
|
|
12
16
|
message: string
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
export interface DoctorCommandResult {
|
|
20
|
+
code: number
|
|
21
|
+
stdout: string
|
|
22
|
+
stderr: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DoctorCommandRunner = (command: string, args: string[]) => Promise<DoctorCommandResult>
|
|
26
|
+
|
|
27
|
+
export interface RunDoctorChecksOptions {
|
|
28
|
+
includeOptional?: boolean
|
|
29
|
+
includeDockerMountProbe?: boolean
|
|
30
|
+
runCommand?: DoctorCommandRunner
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
function nodeVersionPasses (version: string): boolean {
|
|
16
34
|
const major = Number.parseInt(version.split('.')[0] ?? '', 10)
|
|
17
35
|
return Number.isInteger(major) && major >= 24
|
|
@@ -25,26 +43,56 @@ function check (name: string, pass: boolean, okMessage: string, failMessage: str
|
|
|
25
43
|
}
|
|
26
44
|
}
|
|
27
45
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
mirrorStdout: false,
|
|
31
|
-
mirrorStderr: false
|
|
32
|
-
})
|
|
46
|
+
function secretEnvironmentConfigCheck (context: WorkspaceContext): DoctorCheck {
|
|
47
|
+
let config: DevcontainerConfig
|
|
33
48
|
|
|
34
|
-
|
|
49
|
+
try {
|
|
50
|
+
config = existsSync(context.generatedConfigPath)
|
|
51
|
+
? JSON.parse(readFileSync(context.generatedConfigPath, 'utf8')) as DevcontainerConfig
|
|
52
|
+
: buildGeneratedDevcontainerConfig(context)
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
name: 'secret-environment-config',
|
|
56
|
+
level: 'warn',
|
|
57
|
+
message: 'Generated config could not be checked for secret-safe environment handling'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const runArgs = Array.isArray(config.runArgs) ? config.runArgs : []
|
|
62
|
+
const containerEnv = config.containerEnv ?? {}
|
|
63
|
+
const unsafe = runArgs.includes('--env-file') ||
|
|
64
|
+
runArgs.some((arg) => arg.includes('.env.development')) ||
|
|
65
|
+
BOXDOWN_SECRET_ENV_NAMES.some((name) => Object.hasOwn(containerEnv, name))
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name: 'secret-environment-config',
|
|
69
|
+
level: unsafe ? 'warn' : 'ok',
|
|
70
|
+
message: unsafe
|
|
71
|
+
? 'Generated config still exposes Boxdown secrets through Docker environment settings; recreate after upgrading Boxdown'
|
|
72
|
+
: 'Generated config uses runtime-mounted secrets without Docker environment values'
|
|
73
|
+
}
|
|
35
74
|
}
|
|
36
75
|
|
|
37
|
-
async function
|
|
38
|
-
|
|
76
|
+
async function runDoctorCommand (command: string, args: string[]): Promise<DoctorCommandResult> {
|
|
77
|
+
return runBuffered(command, args, {
|
|
39
78
|
mirrorStdout: false,
|
|
40
79
|
mirrorStderr: false
|
|
41
80
|
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function commandWorks (runCommand: DoctorCommandRunner, command: string, args: string[]): Promise<boolean> {
|
|
84
|
+
const result = await runCommand(command, args)
|
|
85
|
+
return result.code === 0
|
|
86
|
+
}
|
|
42
87
|
|
|
88
|
+
async function commandExists (runCommand: DoctorCommandRunner, command: string, args: string[]): Promise<boolean> {
|
|
89
|
+
const result = await runCommand(command, args)
|
|
43
90
|
return result.code !== 127
|
|
44
91
|
}
|
|
45
92
|
|
|
46
|
-
export async function runDoctorChecks (context: WorkspaceContext): Promise<DoctorCheck[]> {
|
|
93
|
+
export async function runDoctorChecks (context: WorkspaceContext, options: RunDoctorChecksOptions = {}): Promise<DoctorCheck[]> {
|
|
47
94
|
const checks: DoctorCheck[] = []
|
|
95
|
+
const runCommand = options.runCommand ?? runDoctorCommand
|
|
48
96
|
const nodeVersion = process.versions.node
|
|
49
97
|
|
|
50
98
|
checks.push(check(
|
|
@@ -54,37 +102,117 @@ export async function runDoctorChecks (context: WorkspaceContext): Promise<Docto
|
|
|
54
102
|
`Node ${nodeVersion}; expected >=24.0.0`
|
|
55
103
|
))
|
|
56
104
|
|
|
105
|
+
const sshAgent = await runCommand('ssh-add', ['-L'])
|
|
106
|
+
const identityLines = sshAgent.code === 0
|
|
107
|
+
? sshAgent.stdout.split(/\r?\n/).filter((line) => line.trim().startsWith('ssh-'))
|
|
108
|
+
: []
|
|
109
|
+
const identities = identityLines.length
|
|
110
|
+
let configuredKey: string | undefined
|
|
111
|
+
let configuredFailure: { reason: GitSigningReason, detail?: string } | undefined
|
|
112
|
+
const format = await runCommand('git', ['config', '--global', '--get', 'gpg.format'])
|
|
113
|
+
if (format.code === 0 && format.stdout.trim() === 'ssh') {
|
|
114
|
+
const signingKey = await runCommand('git', ['config', '--global', '--get', 'user.signingkey'])
|
|
115
|
+
if (signingKey.code === 0 && signingKey.stdout.trim().length > 0) {
|
|
116
|
+
const resolved = resolveConfiguredSshSigningKey(signingKey.stdout.trim(), {
|
|
117
|
+
homeDir: dirname(context.hostGitconfigPath),
|
|
118
|
+
workspaceFolder: context.workspaceFolder
|
|
119
|
+
})
|
|
120
|
+
if (resolved.key === undefined) {
|
|
121
|
+
configuredFailure = {
|
|
122
|
+
reason: resolved.reason ?? 'configured-key-invalid',
|
|
123
|
+
detail: resolved.detail
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
configuredKey = resolved.key
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const includeOptional = options.includeOptional ?? true
|
|
132
|
+
let ghAvailable = false
|
|
133
|
+
let ghAuth = false
|
|
134
|
+
let githubLogin: string | undefined
|
|
135
|
+
let githubAuthKeys: string[] | undefined
|
|
136
|
+
if (includeOptional) {
|
|
137
|
+
ghAvailable = await commandWorks(runCommand, 'gh', ['--version'])
|
|
138
|
+
if (ghAvailable) {
|
|
139
|
+
ghAuth = await commandWorks(runCommand, 'gh', ['auth', 'status', '--hostname', 'github.com'])
|
|
140
|
+
if (ghAuth && configuredKey === undefined && configuredFailure === undefined && identities > 1) {
|
|
141
|
+
const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
|
|
142
|
+
githubLogin = user.code === 0 && user.stdout.trim().length > 0 ? user.stdout.trim() : undefined
|
|
143
|
+
if (githubLogin !== undefined) {
|
|
144
|
+
const authentication = await runCommand('gh', ['api', `users/${githubLogin}/keys`, '--paginate', '--jq', '.[].key'])
|
|
145
|
+
if (authentication.code === 0) githubAuthKeys = authentication.stdout.split(/\r?\n/)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const selected: { key?: string, reason?: GitSigningReason } = configuredFailure ?? selectGitSigningKey(identityLines, configuredKey, githubAuthKeys)
|
|
152
|
+
const selectedByConfiguration = selected.key !== undefined && configuredKey !== undefined
|
|
153
|
+
const selectedByGithub = selected.key !== undefined && configuredKey === undefined && identities > 1
|
|
154
|
+
const signingMessages: Record<GitSigningReason, string> = {
|
|
155
|
+
'agent-unavailable': 'SSH agent is unavailable; Boxdown commits will remain unsigned',
|
|
156
|
+
'no-identities': 'SSH agent has no identities; Boxdown commits will remain unsigned',
|
|
157
|
+
'ambiguous-identities': 'SSH agent has multiple identities; Boxdown will not guess a signing key and commits will remain unsigned',
|
|
158
|
+
'configured-key-unreadable': 'Configured SSH signing-key file could not be read; Boxdown commits will remain unsigned',
|
|
159
|
+
'configured-key-invalid': 'Configured SSH signing key is not a valid public key; Boxdown commits will remain unsigned',
|
|
160
|
+
'configured-key-not-loaded': 'Configured SSH signing key is not loaded in the agent; Boxdown commits will remain unsigned',
|
|
161
|
+
'agent-socket-unavailable': 'Host SSH-agent socket is unavailable; Boxdown commits will remain unsigned',
|
|
162
|
+
'docker-probe-image-unavailable': 'No local Docker image is available to probe commit-signing agent forwarding',
|
|
163
|
+
'agent-mount-unavailable': 'Docker could not mount the host SSH-agent socket; Boxdown commits will remain unsigned'
|
|
164
|
+
}
|
|
165
|
+
checks.push({
|
|
166
|
+
name: 'git-signing-agent',
|
|
167
|
+
level: sshAgent.code === 0 && selected.key !== undefined ? 'ok' : 'warn',
|
|
168
|
+
message: sshAgent.code !== 0
|
|
169
|
+
? signingMessages['agent-unavailable']
|
|
170
|
+
: selected.key === undefined
|
|
171
|
+
? signingMessages[selected.reason ?? 'ambiguous-identities']
|
|
172
|
+
: selectedByConfiguration
|
|
173
|
+
? 'Configured SSH signing key is loaded in the agent'
|
|
174
|
+
: selectedByGithub
|
|
175
|
+
? 'GitHub authentication keys identify one SSH agent identity for Boxdown commit signing'
|
|
176
|
+
: 'One SSH agent identity is available for Boxdown commit signing'
|
|
177
|
+
})
|
|
178
|
+
|
|
57
179
|
checks.push(check(
|
|
58
180
|
'devcontainers-cli',
|
|
59
|
-
await packagedDevcontainerCliWorks(context),
|
|
181
|
+
await packagedDevcontainerCliWorks(context, runCommand),
|
|
60
182
|
'Packaged @devcontainers/cli is available',
|
|
61
183
|
'Packaged @devcontainers/cli is required but was not available'
|
|
62
184
|
))
|
|
63
185
|
|
|
186
|
+
const dockerCliWorks = await commandWorks(runCommand, 'docker', ['--version'])
|
|
64
187
|
checks.push(check(
|
|
65
188
|
'docker-cli',
|
|
66
|
-
|
|
189
|
+
dockerCliWorks,
|
|
67
190
|
'Docker CLI is available',
|
|
68
191
|
'Docker CLI is required but was not available'
|
|
69
192
|
))
|
|
70
193
|
|
|
194
|
+
const dockerDaemonWorks = await commandWorks(runCommand, 'docker', ['info'])
|
|
71
195
|
checks.push(check(
|
|
72
196
|
'docker-daemon',
|
|
73
|
-
|
|
197
|
+
dockerDaemonWorks,
|
|
74
198
|
'Docker daemon is reachable',
|
|
75
199
|
'Docker daemon is required but was not reachable'
|
|
76
200
|
))
|
|
77
201
|
|
|
78
202
|
checks.push(check(
|
|
79
203
|
'ssh',
|
|
80
|
-
await commandExists('ssh', ['-V']),
|
|
204
|
+
await commandExists(runCommand, 'ssh', ['-V']),
|
|
81
205
|
'ssh is available',
|
|
82
206
|
'ssh is required but was not available'
|
|
83
207
|
))
|
|
84
208
|
|
|
209
|
+
if (options.includeDockerMountProbe ?? true) {
|
|
210
|
+
checks.push(await checkDockerBindMounts(context, runCommand, dockerCliWorks && dockerDaemonWorks))
|
|
211
|
+
}
|
|
212
|
+
|
|
85
213
|
checks.push(check(
|
|
86
214
|
'ssh-keygen',
|
|
87
|
-
await commandExists('ssh-keygen', ['-?']),
|
|
215
|
+
await commandExists(runCommand, 'ssh-keygen', ['-?']),
|
|
88
216
|
'ssh-keygen is available',
|
|
89
217
|
'ssh-keygen is required but was not available'
|
|
90
218
|
))
|
|
@@ -96,28 +224,162 @@ export async function runDoctorChecks (context: WorkspaceContext): Promise<Docto
|
|
|
96
224
|
`Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`
|
|
97
225
|
))
|
|
98
226
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
227
|
+
checks.push(secretEnvironmentConfigCheck(context))
|
|
228
|
+
|
|
229
|
+
if (includeOptional) {
|
|
230
|
+
if (ghAvailable) {
|
|
231
|
+
checks.push({
|
|
232
|
+
name: 'gh-auth',
|
|
233
|
+
level: ghAuth ? 'ok' : 'warn',
|
|
234
|
+
message: ghAuth ? 'GitHub CLI auth is available' : 'GitHub CLI is available but not authenticated'
|
|
235
|
+
})
|
|
236
|
+
if (ghAuth && selected.key !== undefined) {
|
|
237
|
+
if (githubLogin === undefined) {
|
|
238
|
+
const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
|
|
239
|
+
githubLogin = user.code === 0 && user.stdout.trim().length > 0 ? user.stdout.trim() : undefined
|
|
240
|
+
}
|
|
241
|
+
const signing = githubLogin !== undefined
|
|
242
|
+
? await runCommand('gh', ['api', `users/${githubLogin}/ssh_signing_keys`, '--paginate', '--jq', '.[].key'])
|
|
243
|
+
: { code: 1, stdout: '', stderr: '' }
|
|
244
|
+
checks.push({
|
|
245
|
+
name: 'git-signing-github',
|
|
246
|
+
level: signing.code === 0 && signing.stdout.includes(selected.key) ? 'ok' : 'warn',
|
|
247
|
+
message: signing.code !== 0
|
|
248
|
+
? 'GitHub SSH signing-key registration could not be checked'
|
|
249
|
+
: signing.stdout.includes(selected.key)
|
|
250
|
+
? 'Selected SSH key is registered with GitHub for commit signing'
|
|
251
|
+
: 'Register the selected public key with GitHub as a signing key to receive Verified badges'
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
checks.push({
|
|
256
|
+
name: 'gh',
|
|
257
|
+
level: 'warn',
|
|
258
|
+
message: 'GitHub CLI is optional and was not available'
|
|
259
|
+
})
|
|
260
|
+
}
|
|
112
261
|
}
|
|
113
262
|
|
|
114
263
|
return checks
|
|
115
264
|
}
|
|
116
265
|
|
|
117
|
-
|
|
266
|
+
function dockerProbeImage (output: string): string | undefined {
|
|
267
|
+
return output
|
|
268
|
+
.split(/\r?\n/)
|
|
269
|
+
.map((line) => line.trim())
|
|
270
|
+
.find((image) => image.length > 0 && image !== '<none>:<none>')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function dockerMountError (output: string): boolean {
|
|
274
|
+
return /invalid mount config|bind source path does not exist|mount denied|file sharing|mounts denied|permission denied|operation not permitted/i.test(output)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function compactOutput (output: string): string {
|
|
278
|
+
return output.trim().replace(/\s+/g, ' ').slice(0, 300)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface DockerMountSource {
|
|
282
|
+
label: string
|
|
283
|
+
path: string
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function checkDockerBindMounts (
|
|
287
|
+
context: WorkspaceContext,
|
|
288
|
+
runCommand: DoctorCommandRunner,
|
|
289
|
+
dockerReady: boolean
|
|
290
|
+
): Promise<DoctorCheck> {
|
|
291
|
+
if (!dockerReady) {
|
|
292
|
+
return {
|
|
293
|
+
name: 'docker-bind-mounts',
|
|
294
|
+
level: 'warn',
|
|
295
|
+
message: 'Docker bind-mount readiness was not checked because Docker is unavailable'
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const imageResult = await runCommand('docker', ['image', 'ls', '--format', '{{.Repository}}:{{.Tag}}'])
|
|
300
|
+
const image = imageResult.code === 0 ? dockerProbeImage(imageResult.stdout) : undefined
|
|
301
|
+
|
|
302
|
+
if (image === undefined) {
|
|
303
|
+
return {
|
|
304
|
+
name: 'docker-bind-mounts',
|
|
305
|
+
level: 'warn',
|
|
306
|
+
message: 'Docker bind-mount readiness was not checked because no local Docker image is available'
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
mkdirSync(context.workspaceDataDir, { recursive: true })
|
|
311
|
+
const runtimeProbeDir = mkdtempSync(join(context.workspaceDataDir, 'doctor-mount-probe-'))
|
|
312
|
+
mkdirSync(context.workspaceSecretEnvDir, { recursive: true, mode: 0o700 })
|
|
313
|
+
chmodSync(context.workspaceSecretEnvDir, 0o700)
|
|
314
|
+
const sources: DockerMountSource[] = [
|
|
315
|
+
{ label: 'workspace', path: context.workspaceFolder },
|
|
316
|
+
{ label: 'Boxdown devcontainer assets', path: context.assetsDevcontainerDir },
|
|
317
|
+
{ label: 'Boxdown runtime state', path: runtimeProbeDir },
|
|
318
|
+
{ label: 'Boxdown runtime secret state', path: context.workspaceSecretEnvDir }
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
for (const source of sources) {
|
|
323
|
+
const createResult = await runCommand('docker', [
|
|
324
|
+
'create',
|
|
325
|
+
'--pull=never',
|
|
326
|
+
'--entrypoint',
|
|
327
|
+
'/bin/true',
|
|
328
|
+
'--mount',
|
|
329
|
+
`type=bind,source=${source.path},target=/boxdown-preflight,readonly`,
|
|
330
|
+
image
|
|
331
|
+
])
|
|
332
|
+
const output = `${createResult.stderr}\n${createResult.stdout}`
|
|
333
|
+
|
|
334
|
+
if (createResult.code !== 0) {
|
|
335
|
+
if (dockerMountError(output)) {
|
|
336
|
+
return {
|
|
337
|
+
name: 'docker-bind-mounts',
|
|
338
|
+
level: 'fail',
|
|
339
|
+
message: `Docker cannot bind-mount the ${source.label} path (${source.path}). Check Docker Desktop file sharing and host-folder permissions.`
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
name: 'docker-bind-mounts',
|
|
345
|
+
level: 'warn',
|
|
346
|
+
message: `Docker bind-mount readiness could not be checked for ${source.label}: ${compactOutput(output) || 'Docker create failed'}`
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const containerId = createResult.stdout.trim().split(/\r?\n/)[0]
|
|
351
|
+
if (containerId === undefined || containerId.length === 0) {
|
|
352
|
+
return {
|
|
353
|
+
name: 'docker-bind-mounts',
|
|
354
|
+
level: 'warn',
|
|
355
|
+
message: `Docker bind-mount readiness could not be checked for ${source.label}: Docker did not return a container ID`
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const removeResult = await runCommand('docker', ['rm', '-f', containerId])
|
|
360
|
+
if (removeResult.code !== 0) {
|
|
361
|
+
return {
|
|
362
|
+
name: 'docker-bind-mounts',
|
|
363
|
+
level: 'warn',
|
|
364
|
+
message: `Docker bind-mount readiness was checked, but the disposable probe container could not be removed: ${compactOutput(removeResult.stderr) || 'docker rm failed'}`
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
rmSync(runtimeProbeDir, { recursive: true, force: true })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
name: 'docker-bind-mounts',
|
|
374
|
+
level: 'ok',
|
|
375
|
+
message: 'Docker can bind-mount Boxdown workspace, assets, and runtime-state paths'
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function packagedDevcontainerCliWorks (context: WorkspaceContext, runCommand: DoctorCommandRunner): Promise<boolean> {
|
|
118
380
|
try {
|
|
119
381
|
const cli = resolveDevcontainerCli(context)
|
|
120
|
-
return await commandWorks(cli.command, [...cli.argsPrefix, '--version'])
|
|
382
|
+
return await commandWorks(runCommand, cli.command, [...cli.argsPrefix, '--version'])
|
|
121
383
|
} catch {
|
|
122
384
|
return false
|
|
123
385
|
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { WorkspaceCommandLogger } from './logging.ts'
|
|
5
|
+
import type { WorkspaceContext } from './paths.ts'
|
|
6
|
+
import { runBuffered, type CommandResult } from './process.ts'
|
|
7
|
+
|
|
8
|
+
export type GitSigningReason = 'agent-unavailable' | 'no-identities' | 'ambiguous-identities' | 'configured-key-unreadable' | 'configured-key-invalid' | 'configured-key-not-loaded' | 'agent-socket-unavailable' | 'docker-probe-image-unavailable' | 'agent-mount-unavailable'
|
|
9
|
+
|
|
10
|
+
export interface GitSigningPlan {
|
|
11
|
+
enabled: boolean
|
|
12
|
+
reason?: GitSigningReason
|
|
13
|
+
detail?: string
|
|
14
|
+
publicKey?: string
|
|
15
|
+
agentSocketSource?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const GIT_SIGNING_REASON_MESSAGES: Record<GitSigningReason, string> = {
|
|
19
|
+
'agent-unavailable': 'the host SSH agent is unavailable',
|
|
20
|
+
'no-identities': 'the host SSH agent has no loaded identities',
|
|
21
|
+
'ambiguous-identities': 'multiple SSH identities are loaded and no signing key could be selected safely',
|
|
22
|
+
'configured-key-unreadable': 'the configured SSH signing-key file could not be read',
|
|
23
|
+
'configured-key-invalid': 'the configured SSH signing key is not a valid public key',
|
|
24
|
+
'configured-key-not-loaded': 'the configured SSH signing key is not loaded in the agent',
|
|
25
|
+
'agent-socket-unavailable': 'the host SSH-agent socket is unavailable',
|
|
26
|
+
'docker-probe-image-unavailable': 'no local Docker image is available to probe the SSH-agent mount',
|
|
27
|
+
'agent-mount-unavailable': 'Docker could not mount the host SSH-agent socket'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compactDiagnosticDetail (detail: string): string {
|
|
31
|
+
return detail
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/\s+/gu, ' ')
|
|
34
|
+
.slice(0, 2000)
|
|
35
|
+
.replace(/-----BEGIN [^-]*PRIVATE KEY-----.*?(?:-----END [^-]*PRIVATE KEY-----|$)/giu, '[redacted-private-key]')
|
|
36
|
+
.replace(/\b(?:ssh-[A-Za-z0-9-]+|ecdsa-sha2-[A-Za-z0-9-]+|sk-(?:ssh-[A-Za-z0-9-]+|ecdsa-sha2-[A-Za-z0-9-]+))\s+[A-Za-z0-9+/=]{16,}/gu, '[redacted-ssh-key]')
|
|
37
|
+
.replace(/\b(?:github_pat_|gh[pousr]_|glpat-|xox[baprs]-)[A-Za-z0-9_-]{8,}/gu, '[redacted-token]')
|
|
38
|
+
.replace(/\b(?:Bearer\s+|token[=:]\s*)[A-Za-z0-9._~+/-]{8,}/giu, '[redacted-token]')
|
|
39
|
+
.slice(0, 300)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function reportGitSigningPlan (
|
|
43
|
+
plan: GitSigningPlan,
|
|
44
|
+
options: {
|
|
45
|
+
logger?: Pick<WorkspaceCommandLogger, 'boxdown'>
|
|
46
|
+
quiet?: boolean
|
|
47
|
+
writeWarning?: (message: string) => void
|
|
48
|
+
} = {}
|
|
49
|
+
): void {
|
|
50
|
+
if (plan.enabled) return
|
|
51
|
+
|
|
52
|
+
const reason = plan.reason ?? 'agent-unavailable'
|
|
53
|
+
const detail = plan.detail === undefined ? '' : ` detail=${compactDiagnosticDetail(plan.detail)}`
|
|
54
|
+
options.logger?.boxdown(`git-signing: enabled=false reason=${reason}${detail}\n`)
|
|
55
|
+
|
|
56
|
+
if (options.quiet !== true) {
|
|
57
|
+
const writeWarning = options.writeWarning ?? ((message: string) => process.stderr.write(message))
|
|
58
|
+
writeWarning(`boxdown: commit signing disabled: ${GIT_SIGNING_REASON_MESSAGES[reason]}; commits will remain unsigned.\n`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseSshPublicKey (value: string): string | undefined {
|
|
63
|
+
const [algorithm, key] = value.trim().split(/\s+/, 3)
|
|
64
|
+
if (algorithm === undefined || key === undefined || !/^ssh-[A-Za-z0-9-]+$/.test(algorithm) || key.length === 0) {
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return `${algorithm} ${key}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ConfiguredSshSigningKeyResult {
|
|
72
|
+
key?: string
|
|
73
|
+
reason?: Extract<GitSigningReason, 'configured-key-unreadable' | 'configured-key-invalid'>
|
|
74
|
+
detail?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resolveConfiguredSshSigningKey (
|
|
78
|
+
value: string,
|
|
79
|
+
options: { homeDir?: string, workspaceFolder: string }
|
|
80
|
+
): ConfiguredSshSigningKeyResult {
|
|
81
|
+
const inlineValue = value.startsWith('key::') ? value.slice('key::'.length) : value
|
|
82
|
+
const inlineKey = parseSshPublicKey(inlineValue)
|
|
83
|
+
if (inlineKey !== undefined) {
|
|
84
|
+
return { key: inlineKey }
|
|
85
|
+
}
|
|
86
|
+
if (value.startsWith('key::')) {
|
|
87
|
+
return {
|
|
88
|
+
reason: 'configured-key-invalid',
|
|
89
|
+
detail: 'configured inline value is not a valid SSH public key'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let keyPath: string
|
|
94
|
+
if (value.startsWith('~/')) {
|
|
95
|
+
if (options.homeDir === undefined) {
|
|
96
|
+
return {
|
|
97
|
+
reason: 'configured-key-unreadable',
|
|
98
|
+
detail: 'configured public-key file could not be read'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
keyPath = join(options.homeDir, value.slice(2))
|
|
102
|
+
} else {
|
|
103
|
+
keyPath = isAbsolute(value) ? value : resolve(options.workspaceFolder, value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let publicKeyText: string
|
|
107
|
+
try {
|
|
108
|
+
publicKeyText = readFileSync(keyPath, 'utf8')
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
reason: 'configured-key-unreadable',
|
|
112
|
+
detail: 'configured public-key file could not be read'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const publicKey = parseSshPublicKey(publicKeyText.split(/\r?\n/u).find((line) => line.trim().length > 0) ?? '')
|
|
117
|
+
return publicKey === undefined
|
|
118
|
+
? {
|
|
119
|
+
reason: 'configured-key-invalid',
|
|
120
|
+
detail: 'configured public-key file does not contain a valid SSH public key'
|
|
121
|
+
}
|
|
122
|
+
: { key: publicKey }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function selectGitSigningKey (identities: string[], configuredKey?: string, githubKeys?: string[]): { key?: string, reason?: GitSigningReason } {
|
|
126
|
+
const keys = identities.map(parseSshPublicKey).filter((key): key is string => key !== undefined)
|
|
127
|
+
if (keys.length === 0) return { reason: 'no-identities' }
|
|
128
|
+
|
|
129
|
+
const configured = configuredKey === undefined ? undefined : parseSshPublicKey(configuredKey)
|
|
130
|
+
if (configuredKey !== undefined && configured === undefined) {
|
|
131
|
+
return { reason: 'configured-key-invalid' }
|
|
132
|
+
}
|
|
133
|
+
if (configured !== undefined) {
|
|
134
|
+
return keys.includes(configured) ? { key: configured } : { reason: 'configured-key-not-loaded' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const github = new Set((githubKeys ?? []).map(parseSshPublicKey).filter((key): key is string => key !== undefined))
|
|
138
|
+
const matches = keys.filter((key) => github.has(key))
|
|
139
|
+
if (matches.length === 1) return { key: matches[0] }
|
|
140
|
+
if (keys.length === 1) return { key: keys[0] }
|
|
141
|
+
return { reason: 'ambiguous-identities' }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function writeGitSigningPublicKey (context: WorkspaceContext, key: string): void {
|
|
145
|
+
mkdirSync(context.gitSigningStateDir, { recursive: true, mode: 0o700 })
|
|
146
|
+
writeFileSync(join(context.gitSigningStateDir, 'signing-key.pub'), `${parseSshPublicKey(key) ?? key}\n`, { mode: 0o644 })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type GitSigningCommandRunner = (command: string, args: string[]) => Promise<CommandResult>
|
|
150
|
+
|
|
151
|
+
interface ResolveGitSigningPlanOptions {
|
|
152
|
+
env?: NodeJS.ProcessEnv
|
|
153
|
+
platform?: NodeJS.Platform
|
|
154
|
+
runCommand?: GitSigningCommandRunner
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type AgentMountProbeResult =
|
|
158
|
+
| { ok: true }
|
|
159
|
+
| { ok: false, reason: Extract<GitSigningReason, 'docker-probe-image-unavailable' | 'agent-mount-unavailable'>, detail: string }
|
|
160
|
+
|
|
161
|
+
async function runGitSigningCommand (command: string, args: string[]): Promise<CommandResult> {
|
|
162
|
+
return runBuffered(command, args, { mirrorStdout: false, mirrorStderr: false })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function failedCommandDetail (label: string, result: CommandResult): string {
|
|
166
|
+
const stderr = compactDiagnosticDetail(result.stderr)
|
|
167
|
+
return stderr.length === 0
|
|
168
|
+
? `${label} failed with exit code ${result.code}`
|
|
169
|
+
: `${label} failed with exit code ${result.code}: ${stderr}`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function probeDockerAgentMount (source: string, runCommand: GitSigningCommandRunner): Promise<AgentMountProbeResult> {
|
|
173
|
+
const images = await runCommand('docker', ['image', 'ls', '--format', '{{.Repository}}:{{.Tag}}'])
|
|
174
|
+
const image = images.stdout.split(/\r?\n/).map((value) => value.trim()).find((value) => value.length > 0 && value !== '<none>:<none>')
|
|
175
|
+
if (images.code !== 0) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: 'agent-mount-unavailable',
|
|
179
|
+
detail: failedCommandDetail('Docker image listing', images)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (image === undefined) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: 'docker-probe-image-unavailable',
|
|
186
|
+
detail: 'no tagged local Docker image was found'
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const created = await runCommand('docker', ['create', '--pull=never', '--entrypoint', '/bin/true', '--mount', `type=bind,source=${source},target=/run/boxdown/ssh-agent.sock`, image])
|
|
191
|
+
const containerId = created.stdout.trim().split(/\r?\n/)[0]
|
|
192
|
+
if (created.code !== 0 || containerId === undefined || containerId.length === 0) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: 'agent-mount-unavailable',
|
|
196
|
+
detail: failedCommandDetail('Docker SSH-agent mount probe', created)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
await runCommand('docker', ['rm', '-f', containerId])
|
|
200
|
+
return { ok: true }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function resolveGitSigningPlan (context: WorkspaceContext, options: ResolveGitSigningPlanOptions = {}): Promise<GitSigningPlan> {
|
|
204
|
+
const runCommand = options.runCommand ?? runGitSigningCommand
|
|
205
|
+
const result = await runCommand('ssh-add', ['-L'])
|
|
206
|
+
if (result.code !== 0) {
|
|
207
|
+
return {
|
|
208
|
+
enabled: false,
|
|
209
|
+
reason: 'agent-unavailable',
|
|
210
|
+
detail: failedCommandDetail('ssh-add -L', result)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const identities = result.stdout.split(/\r?\n/)
|
|
215
|
+
const format = await runCommand('git', ['config', '--global', '--get', 'gpg.format'])
|
|
216
|
+
let configuredKey: string | undefined
|
|
217
|
+
if (format.code === 0 && format.stdout.trim() === 'ssh') {
|
|
218
|
+
const signingKey = await runCommand('git', ['config', '--global', '--get', 'user.signingkey'])
|
|
219
|
+
if (signingKey.code === 0 && signingKey.stdout.trim().length > 0) {
|
|
220
|
+
const resolvedKey = resolveConfiguredSshSigningKey(signingKey.stdout.trim(), {
|
|
221
|
+
homeDir: options.env?.HOME ?? process.env.HOME,
|
|
222
|
+
workspaceFolder: context.workspaceFolder
|
|
223
|
+
})
|
|
224
|
+
if (resolvedKey.key === undefined) {
|
|
225
|
+
return {
|
|
226
|
+
enabled: false,
|
|
227
|
+
reason: resolvedKey.reason ?? 'configured-key-invalid',
|
|
228
|
+
detail: resolvedKey.detail
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
configuredKey = resolvedKey.key
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
let githubKeys: string[] | undefined
|
|
235
|
+
if (configuredKey === undefined && identities.filter((key) => parseSshPublicKey(key) !== undefined).length > 1) {
|
|
236
|
+
const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
|
|
237
|
+
const login = user.stdout.trim()
|
|
238
|
+
if (user.code === 0 && login.length > 0) {
|
|
239
|
+
const github = await runCommand('gh', ['api', `users/${login}/keys`, '--paginate', '--jq', '.[].key'])
|
|
240
|
+
if (github.code === 0) githubKeys = github.stdout.split(/\r?\n/)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const selected = selectGitSigningKey(identities, configuredKey, githubKeys)
|
|
244
|
+
if (selected.key === undefined) return { enabled: false, reason: selected.reason }
|
|
245
|
+
|
|
246
|
+
const agentSocketSource = (options.platform ?? process.platform) === 'darwin'
|
|
247
|
+
? '/run/host-services/ssh-auth.sock'
|
|
248
|
+
: options.env?.SSH_AUTH_SOCK ?? process.env.SSH_AUTH_SOCK
|
|
249
|
+
if (agentSocketSource === undefined || agentSocketSource.length === 0) {
|
|
250
|
+
return {
|
|
251
|
+
enabled: false,
|
|
252
|
+
reason: 'agent-socket-unavailable',
|
|
253
|
+
detail: 'SSH_AUTH_SOCK is not set'
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const mountProbe = await probeDockerAgentMount(agentSocketSource, runCommand)
|
|
257
|
+
if (!mountProbe.ok) {
|
|
258
|
+
return {
|
|
259
|
+
enabled: false,
|
|
260
|
+
reason: mountProbe.reason,
|
|
261
|
+
detail: mountProbe.detail
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
writeGitSigningPublicKey(context, selected.key)
|
|
266
|
+
return { enabled: true, publicKey: selected.key, agentSocketSource }
|
|
267
|
+
}
|