boxdown 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +49 -17
  3. package/assets/devcontainer/devcontainer.json +24 -7
  4. package/assets/devcontainer/hooks/initialize.sh +32 -0
  5. package/assets/devcontainer/hooks/post-create.sh +59 -12
  6. package/assets/devcontainer/hooks/post-start.sh +21 -4
  7. package/assets/devcontainer/ssh-config-install.sh +12 -3
  8. package/assets/devcontainer/start.sh +721 -44
  9. package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
  10. package/assets/devcontainer/utils/deps-install.sh +68 -0
  11. package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
  12. package/assets/devcontainer/utils/git-signing-bootstrap.sh +56 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  15. package/dist/bin/cli.cjs +1 -1
  16. package/dist/bin/cli.mjs +1 -1
  17. package/dist/main-Df4E8ARj.cjs +5498 -0
  18. package/dist/main-XMBsKjIK.mjs +5458 -0
  19. package/dist/main-XMBsKjIK.mjs.map +1 -0
  20. package/dist/main.cjs +5 -2
  21. package/dist/main.d.cts +495 -4
  22. package/dist/main.d.cts.map +1 -1
  23. package/dist/main.d.mts +495 -4
  24. package/dist/main.d.mts.map +1 -1
  25. package/dist/main.mjs +2 -2
  26. package/docs/README.md +1 -0
  27. package/docs/architecture.md +32 -0
  28. package/docs/development.md +13 -6
  29. package/docs/features/README.md +2 -0
  30. package/docs/features/commit-signing.md +23 -0
  31. package/docs/features/generated-config-and-state.md +57 -4
  32. package/docs/features/github-auth-refresh.md +12 -0
  33. package/docs/features/lifecycle.md +97 -11
  34. package/docs/features/setup.md +66 -0
  35. package/docs/features/ssh-config-and-proxy.md +228 -7
  36. package/docs/features/start-and-shell.md +40 -5
  37. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  38. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  39. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +396 -0
  40. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  41. package/docs/testing.md +35 -2
  42. package/docs/todo.md +2 -2
  43. package/package.json +1 -1
  44. package/src/claude-app-config.ts +304 -0
  45. package/src/cli-style.ts +43 -0
  46. package/src/codex-app-config.ts +656 -0
  47. package/src/config.ts +68 -10
  48. package/src/constants.ts +5 -0
  49. package/src/devcontainer.ts +500 -64
  50. package/src/doctor.ts +195 -30
  51. package/src/git-signing.ts +87 -0
  52. package/src/interactive-prompts.ts +692 -0
  53. package/src/list.ts +16 -2
  54. package/src/logging.ts +154 -0
  55. package/src/main.ts +1213 -63
  56. package/src/metadata.ts +52 -3
  57. package/src/package-info.ts +18 -0
  58. package/src/paths.ts +50 -10
  59. package/src/process.ts +80 -2
  60. package/src/progress.ts +593 -0
  61. package/src/purge.ts +208 -0
  62. package/src/shell.ts +19 -0
  63. package/src/ssh-config.ts +134 -16
  64. package/src/ssh-install-targets.ts +111 -0
  65. package/src/ssh-key.ts +53 -13
  66. package/src/status.ts +5 -0
  67. package/dist/main-BuEptwlL.cjs +0 -1707
  68. package/dist/main-ZFTrSVgt.mjs +0 -1685
  69. package/dist/main-ZFTrSVgt.mjs.map +0 -1
package/src/doctor.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
2
+ import { join } from 'node:path'
2
3
 
3
4
  import { resolveDevcontainerCli } from './devcontainer-cli.ts'
4
5
  import type { WorkspaceContext } from './paths.ts'
@@ -12,6 +13,20 @@ export interface DoctorCheck {
12
13
  message: string
13
14
  }
14
15
 
16
+ export interface DoctorCommandResult {
17
+ code: number
18
+ stdout: string
19
+ stderr: string
20
+ }
21
+
22
+ export type DoctorCommandRunner = (command: string, args: string[]) => Promise<DoctorCommandResult>
23
+
24
+ export interface RunDoctorChecksOptions {
25
+ includeOptional?: boolean
26
+ includeDockerMountProbe?: boolean
27
+ runCommand?: DoctorCommandRunner
28
+ }
29
+
15
30
  function nodeVersionPasses (version: string): boolean {
16
31
  const major = Number.parseInt(version.split('.')[0] ?? '', 10)
17
32
  return Number.isInteger(major) && major >= 24
@@ -25,26 +40,26 @@ function check (name: string, pass: boolean, okMessage: string, failMessage: str
25
40
  }
26
41
  }
27
42
 
28
- async function commandWorks (command: string, args: string[]): Promise<boolean> {
29
- const result = await runBuffered(command, args, {
43
+ async function runDoctorCommand (command: string, args: string[]): Promise<DoctorCommandResult> {
44
+ return runBuffered(command, args, {
30
45
  mirrorStdout: false,
31
46
  mirrorStderr: false
32
47
  })
48
+ }
33
49
 
50
+ async function commandWorks (runCommand: DoctorCommandRunner, command: string, args: string[]): Promise<boolean> {
51
+ const result = await runCommand(command, args)
34
52
  return result.code === 0
35
53
  }
36
54
 
37
- async function commandExists (command: string, args: string[]): Promise<boolean> {
38
- const result = await runBuffered(command, args, {
39
- mirrorStdout: false,
40
- mirrorStderr: false
41
- })
42
-
55
+ async function commandExists (runCommand: DoctorCommandRunner, command: string, args: string[]): Promise<boolean> {
56
+ const result = await runCommand(command, args)
43
57
  return result.code !== 127
44
58
  }
45
59
 
46
- export async function runDoctorChecks (context: WorkspaceContext): Promise<DoctorCheck[]> {
60
+ export async function runDoctorChecks (context: WorkspaceContext, options: RunDoctorChecksOptions = {}): Promise<DoctorCheck[]> {
47
61
  const checks: DoctorCheck[] = []
62
+ const runCommand = options.runCommand ?? runDoctorCommand
48
63
  const nodeVersion = process.versions.node
49
64
 
50
65
  checks.push(check(
@@ -54,37 +69,59 @@ export async function runDoctorChecks (context: WorkspaceContext): Promise<Docto
54
69
  `Node ${nodeVersion}; expected >=24.0.0`
55
70
  ))
56
71
 
72
+ const sshAgent = await runCommand('ssh-add', ['-L'])
73
+ const identities = sshAgent.code === 0
74
+ ? sshAgent.stdout.split(/\r?\n/).filter((line) => line.trim().startsWith('ssh-')).length
75
+ : 0
76
+ checks.push({
77
+ name: 'git-signing-agent',
78
+ level: sshAgent.code === 0 && identities === 1 ? 'ok' : 'warn',
79
+ message: sshAgent.code !== 0
80
+ ? 'SSH agent is unavailable; Boxdown commits will remain unsigned'
81
+ : identities === 0
82
+ ? 'SSH agent has no identities; Boxdown commits will remain unsigned'
83
+ : identities > 1
84
+ ? 'SSH agent has multiple identities; Boxdown will not guess a signing key and commits will remain unsigned'
85
+ : 'One SSH agent identity is available for Boxdown commit signing'
86
+ })
87
+
57
88
  checks.push(check(
58
89
  'devcontainers-cli',
59
- await packagedDevcontainerCliWorks(context),
90
+ await packagedDevcontainerCliWorks(context, runCommand),
60
91
  'Packaged @devcontainers/cli is available',
61
92
  'Packaged @devcontainers/cli is required but was not available'
62
93
  ))
63
94
 
95
+ const dockerCliWorks = await commandWorks(runCommand, 'docker', ['--version'])
64
96
  checks.push(check(
65
97
  'docker-cli',
66
- await commandWorks('docker', ['--version']),
98
+ dockerCliWorks,
67
99
  'Docker CLI is available',
68
100
  'Docker CLI is required but was not available'
69
101
  ))
70
102
 
103
+ const dockerDaemonWorks = await commandWorks(runCommand, 'docker', ['info'])
71
104
  checks.push(check(
72
105
  'docker-daemon',
73
- await commandWorks('docker', ['info']),
106
+ dockerDaemonWorks,
74
107
  'Docker daemon is reachable',
75
108
  'Docker daemon is required but was not reachable'
76
109
  ))
77
110
 
78
111
  checks.push(check(
79
112
  'ssh',
80
- await commandExists('ssh', ['-V']),
113
+ await commandExists(runCommand, 'ssh', ['-V']),
81
114
  'ssh is available',
82
115
  'ssh is required but was not available'
83
116
  ))
84
117
 
118
+ if (options.includeDockerMountProbe ?? true) {
119
+ checks.push(await checkDockerBindMounts(context, runCommand, dockerCliWorks && dockerDaemonWorks))
120
+ }
121
+
85
122
  checks.push(check(
86
123
  'ssh-keygen',
87
- await commandExists('ssh-keygen', ['-?']),
124
+ await commandExists(runCommand, 'ssh-keygen', ['-?']),
88
125
  'ssh-keygen is available',
89
126
  'ssh-keygen is required but was not available'
90
127
  ))
@@ -96,28 +133,156 @@ export async function runDoctorChecks (context: WorkspaceContext): Promise<Docto
96
133
  `Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`
97
134
  ))
98
135
 
99
- if (await commandWorks('gh', ['--version'])) {
100
- const ghAuth = await commandWorks('gh', ['auth', 'status', '--hostname', 'github.com'])
101
- checks.push({
102
- name: 'gh-auth',
103
- level: ghAuth ? 'ok' : 'warn',
104
- message: ghAuth ? 'GitHub CLI auth is available' : 'GitHub CLI is available but not authenticated'
105
- })
106
- } else {
107
- checks.push({
108
- name: 'gh',
109
- level: 'warn',
110
- message: 'GitHub CLI is optional and was not available'
111
- })
136
+ if (options.includeOptional ?? true) {
137
+ if (await commandWorks(runCommand, 'gh', ['--version'])) {
138
+ const ghAuth = await commandWorks(runCommand, 'gh', ['auth', 'status', '--hostname', 'github.com'])
139
+ checks.push({
140
+ name: 'gh-auth',
141
+ level: ghAuth ? 'ok' : 'warn',
142
+ message: ghAuth ? 'GitHub CLI auth is available' : 'GitHub CLI is available but not authenticated'
143
+ })
144
+ if (ghAuth && identities === 1) {
145
+ const user = await runCommand('gh', ['api', 'user', '--jq', '.login'])
146
+ const signing = user.code === 0 && user.stdout.trim().length > 0
147
+ ? await runCommand('gh', ['api', `users/${user.stdout.trim()}/ssh_signing_keys`, '--paginate', '--jq', '.[].key'])
148
+ : { code: 1, stdout: '', stderr: '' }
149
+ const identity = sshAgent.stdout.split(/\r?\n/).find((line) => line.trim().startsWith('ssh-'))?.trim()
150
+ checks.push({
151
+ name: 'git-signing-github',
152
+ level: signing.code === 0 && identity !== undefined && signing.stdout.includes(identity.split(/\s+/, 3).slice(0, 2).join(' ')) ? 'ok' : 'warn',
153
+ message: signing.code !== 0
154
+ ? 'GitHub SSH signing-key registration could not be checked'
155
+ : signing.stdout.includes(identity?.split(/\s+/, 3).slice(0, 2).join(' ') ?? '')
156
+ ? 'Selected SSH key is registered with GitHub for commit signing'
157
+ : 'Register the selected public key with GitHub as a signing key to receive Verified badges'
158
+ })
159
+ }
160
+ } else {
161
+ checks.push({
162
+ name: 'gh',
163
+ level: 'warn',
164
+ message: 'GitHub CLI is optional and was not available'
165
+ })
166
+ }
112
167
  }
113
168
 
114
169
  return checks
115
170
  }
116
171
 
117
- async function packagedDevcontainerCliWorks (context: WorkspaceContext): Promise<boolean> {
172
+ function dockerProbeImage (output: string): string | undefined {
173
+ return output
174
+ .split(/\r?\n/)
175
+ .map((line) => line.trim())
176
+ .find((image) => image.length > 0 && image !== '<none>:<none>')
177
+ }
178
+
179
+ function dockerMountError (output: string): boolean {
180
+ return /invalid mount config|bind source path does not exist|mount denied|file sharing|mounts denied|permission denied|operation not permitted/i.test(output)
181
+ }
182
+
183
+ function compactOutput (output: string): string {
184
+ return output.trim().replace(/\s+/g, ' ').slice(0, 300)
185
+ }
186
+
187
+ interface DockerMountSource {
188
+ label: string
189
+ path: string
190
+ }
191
+
192
+ async function checkDockerBindMounts (
193
+ context: WorkspaceContext,
194
+ runCommand: DoctorCommandRunner,
195
+ dockerReady: boolean
196
+ ): Promise<DoctorCheck> {
197
+ if (!dockerReady) {
198
+ return {
199
+ name: 'docker-bind-mounts',
200
+ level: 'warn',
201
+ message: 'Docker bind-mount readiness was not checked because Docker is unavailable'
202
+ }
203
+ }
204
+
205
+ const imageResult = await runCommand('docker', ['image', 'ls', '--format', '{{.Repository}}:{{.Tag}}'])
206
+ const image = imageResult.code === 0 ? dockerProbeImage(imageResult.stdout) : undefined
207
+
208
+ if (image === undefined) {
209
+ return {
210
+ name: 'docker-bind-mounts',
211
+ level: 'warn',
212
+ message: 'Docker bind-mount readiness was not checked because no local Docker image is available'
213
+ }
214
+ }
215
+
216
+ mkdirSync(context.workspaceDataDir, { recursive: true })
217
+ const runtimeProbeDir = mkdtempSync(join(context.workspaceDataDir, 'doctor-mount-probe-'))
218
+ const sources: DockerMountSource[] = [
219
+ { label: 'workspace', path: context.workspaceFolder },
220
+ { label: 'Boxdown devcontainer assets', path: context.assetsDevcontainerDir },
221
+ { label: 'Boxdown runtime state', path: runtimeProbeDir }
222
+ ]
223
+
224
+ try {
225
+ for (const source of sources) {
226
+ const createResult = await runCommand('docker', [
227
+ 'create',
228
+ '--pull=never',
229
+ '--entrypoint',
230
+ '/bin/true',
231
+ '--mount',
232
+ `type=bind,source=${source.path},target=/boxdown-preflight,readonly`,
233
+ image
234
+ ])
235
+ const output = `${createResult.stderr}\n${createResult.stdout}`
236
+
237
+ if (createResult.code !== 0) {
238
+ if (dockerMountError(output)) {
239
+ return {
240
+ name: 'docker-bind-mounts',
241
+ level: 'fail',
242
+ message: `Docker cannot bind-mount the ${source.label} path (${source.path}). Check Docker Desktop file sharing and host-folder permissions.`
243
+ }
244
+ }
245
+
246
+ return {
247
+ name: 'docker-bind-mounts',
248
+ level: 'warn',
249
+ message: `Docker bind-mount readiness could not be checked for ${source.label}: ${compactOutput(output) || 'Docker create failed'}`
250
+ }
251
+ }
252
+
253
+ const containerId = createResult.stdout.trim().split(/\r?\n/)[0]
254
+ if (containerId === undefined || containerId.length === 0) {
255
+ return {
256
+ name: 'docker-bind-mounts',
257
+ level: 'warn',
258
+ message: `Docker bind-mount readiness could not be checked for ${source.label}: Docker did not return a container ID`
259
+ }
260
+ }
261
+
262
+ const removeResult = await runCommand('docker', ['rm', '-f', containerId])
263
+ if (removeResult.code !== 0) {
264
+ return {
265
+ name: 'docker-bind-mounts',
266
+ level: 'warn',
267
+ message: `Docker bind-mount readiness was checked, but the disposable probe container could not be removed: ${compactOutput(removeResult.stderr) || 'docker rm failed'}`
268
+ }
269
+ }
270
+ }
271
+ } finally {
272
+ rmSync(runtimeProbeDir, { recursive: true, force: true })
273
+ }
274
+
275
+ return {
276
+ name: 'docker-bind-mounts',
277
+ level: 'ok',
278
+ message: 'Docker can bind-mount Boxdown workspace, assets, and runtime-state paths'
279
+ }
280
+ }
281
+
282
+ async function packagedDevcontainerCliWorks (context: WorkspaceContext, runCommand: DoctorCommandRunner): Promise<boolean> {
118
283
  try {
119
284
  const cli = resolveDevcontainerCli(context)
120
- return await commandWorks(cli.command, [...cli.argsPrefix, '--version'])
285
+ return await commandWorks(runCommand, cli.command, [...cli.argsPrefix, '--version'])
121
286
  } catch {
122
287
  return false
123
288
  }
@@ -0,0 +1,87 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { WorkspaceContext } from './paths.ts'
5
+ import { runBuffered } from './process.ts'
6
+
7
+ export type GitSigningReason = 'agent-unavailable' | 'no-identities' | 'ambiguous-identities' | 'configured-key-not-loaded' | 'agent-mount-unavailable'
8
+
9
+ export interface GitSigningPlan {
10
+ enabled: boolean
11
+ reason?: GitSigningReason
12
+ publicKey?: string
13
+ agentSocketSource?: string
14
+ }
15
+
16
+ export function parseSshPublicKey (value: string): string | undefined {
17
+ const [algorithm, key] = value.trim().split(/\s+/, 3)
18
+ if (algorithm === undefined || key === undefined || !/^ssh-[A-Za-z0-9-]+$/.test(algorithm) || key.length === 0) {
19
+ return undefined
20
+ }
21
+
22
+ return `${algorithm} ${key}`
23
+ }
24
+
25
+ export function selectGitSigningKey (identities: string[], configuredKey?: string, githubKeys?: string[]): { key?: string, reason?: GitSigningReason } {
26
+ const keys = identities.map(parseSshPublicKey).filter((key): key is string => key !== undefined)
27
+ if (keys.length === 0) return { reason: 'no-identities' }
28
+
29
+ const configured = configuredKey === undefined ? undefined : parseSshPublicKey(configuredKey)
30
+ if (configured !== undefined) {
31
+ return keys.includes(configured) ? { key: configured } : { reason: 'configured-key-not-loaded' }
32
+ }
33
+
34
+ const github = new Set((githubKeys ?? []).map(parseSshPublicKey).filter((key): key is string => key !== undefined))
35
+ const matches = keys.filter((key) => github.has(key))
36
+ if (matches.length === 1) return { key: matches[0] }
37
+ if (keys.length === 1) return { key: keys[0] }
38
+ return { reason: 'ambiguous-identities' }
39
+ }
40
+
41
+ export function writeGitSigningPublicKey (context: WorkspaceContext, key: string): void {
42
+ mkdirSync(context.gitSigningStateDir, { recursive: true, mode: 0o700 })
43
+ writeFileSync(join(context.gitSigningStateDir, 'signing-key.pub'), `${parseSshPublicKey(key) ?? key}\n`, { mode: 0o644 })
44
+ }
45
+
46
+ async function dockerCanMountAgent (source: string): Promise<boolean> {
47
+ const images = await runBuffered('docker', ['image', 'ls', '--format', '{{.Repository}}:{{.Tag}}'], { mirrorStdout: false, mirrorStderr: false })
48
+ const image = images.stdout.split(/\r?\n/).map((value) => value.trim()).find((value) => value.length > 0 && value !== '<none>:<none>')
49
+ if (images.code !== 0 || image === undefined) return false
50
+
51
+ const created = await runBuffered('docker', ['create', '--pull=never', '--entrypoint', '/bin/true', '--mount', `type=bind,source=${source},target=/run/boxdown/ssh-agent.sock`, image], { mirrorStdout: false, mirrorStderr: false })
52
+ const containerId = created.stdout.trim().split(/\r?\n/)[0]
53
+ if (created.code !== 0 || containerId === undefined || containerId.length === 0) return false
54
+ await runBuffered('docker', ['rm', '-f', containerId], { mirrorStdout: false, mirrorStderr: false })
55
+ return true
56
+ }
57
+
58
+ export async function resolveGitSigningPlan (context: WorkspaceContext): Promise<GitSigningPlan> {
59
+ const result = await runBuffered('ssh-add', ['-L'], { mirrorStdout: false, mirrorStderr: false })
60
+ if (result.code !== 0) return { enabled: false, reason: 'agent-unavailable' }
61
+
62
+ const identities = result.stdout.split(/\r?\n/)
63
+ const format = await runBuffered('git', ['config', '--global', '--get', 'gpg.format'], { mirrorStdout: false, mirrorStderr: false })
64
+ const configuredKey = format.code === 0 && format.stdout.trim() === 'ssh'
65
+ ? (await runBuffered('git', ['config', '--global', '--get', 'user.signingkey'], { mirrorStdout: false, mirrorStderr: false })).stdout.trim()
66
+ : undefined
67
+ let githubKeys: string[] | undefined
68
+ if (identities.filter((key) => parseSshPublicKey(key) !== undefined).length > 1) {
69
+ const user = await runBuffered('gh', ['api', 'user', '--jq', '.login'], { mirrorStdout: false, mirrorStderr: false })
70
+ const login = user.stdout.trim()
71
+ if (user.code === 0 && login.length > 0) {
72
+ const github = await runBuffered('gh', ['api', `users/${login}/keys`, '--paginate', '--jq', '.[].key'], { mirrorStdout: false, mirrorStderr: false })
73
+ if (github.code === 0) githubKeys = github.stdout.split(/\r?\n/)
74
+ }
75
+ }
76
+ const selected = selectGitSigningKey(identities, configuredKey, githubKeys)
77
+ if (selected.key === undefined) return { enabled: false, reason: selected.reason }
78
+
79
+ const agentSocketSource = process.platform === 'darwin'
80
+ ? '/run/host-services/ssh-auth.sock'
81
+ : process.env.SSH_AUTH_SOCK
82
+ if (agentSocketSource === undefined || agentSocketSource.length === 0) return { enabled: false, reason: 'agent-mount-unavailable' }
83
+ if (!await dockerCanMountAgent(agentSocketSource)) return { enabled: false, reason: 'agent-mount-unavailable' }
84
+
85
+ writeGitSigningPublicKey(context, selected.key)
86
+ return { enabled: true, publicKey: selected.key, agentSocketSource }
87
+ }