boxdown 1.0.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 (58) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +115 -0
  3. package/assets/devcontainer/README.md +177 -0
  4. package/assets/devcontainer/devcontainer.json +83 -0
  5. package/assets/devcontainer/hooks/initialize.sh +55 -0
  6. package/assets/devcontainer/hooks/post-create.sh +67 -0
  7. package/assets/devcontainer/hooks/post-start.sh +34 -0
  8. package/assets/devcontainer/ssh-config-install.sh +172 -0
  9. package/assets/devcontainer/start.sh +485 -0
  10. package/assets/devcontainer/utils/codex-cli-update.sh +9 -0
  11. package/assets/devcontainer/utils/coding-agent-cli-update.sh +360 -0
  12. package/assets/devcontainer/utils/deps-install.sh +87 -0
  13. package/assets/devcontainer/utils/ssh-bootstrap.sh +200 -0
  14. package/dist/bin/cli.cjs +12 -0
  15. package/dist/bin/cli.d.cts +1 -0
  16. package/dist/bin/cli.d.mts +1 -0
  17. package/dist/bin/cli.mjs +14 -0
  18. package/dist/bin/cli.mjs.map +1 -0
  19. package/dist/main-BuEptwlL.cjs +1707 -0
  20. package/dist/main-ZFTrSVgt.mjs +1685 -0
  21. package/dist/main-ZFTrSVgt.mjs.map +1 -0
  22. package/dist/main.cjs +7 -0
  23. package/dist/main.d.cts +24 -0
  24. package/dist/main.d.cts.map +1 -0
  25. package/dist/main.d.mts +24 -0
  26. package/dist/main.d.mts.map +1 -0
  27. package/dist/main.mjs +3 -0
  28. package/docs/README.md +34 -0
  29. package/docs/architecture.md +75 -0
  30. package/docs/conventions.md +29 -0
  31. package/docs/development.md +59 -0
  32. package/docs/features/README.md +14 -0
  33. package/docs/features/generated-config-and-state.md +64 -0
  34. package/docs/features/github-auth-refresh.md +64 -0
  35. package/docs/features/lifecycle.md +64 -0
  36. package/docs/features/ssh-config-and-proxy.md +60 -0
  37. package/docs/features/start-and-shell.md +83 -0
  38. package/docs/testing.md +63 -0
  39. package/docs/todo.md +170 -0
  40. package/package.json +128 -0
  41. package/src/bin/cli.ts +9 -0
  42. package/src/coding-agents.ts +22 -0
  43. package/src/config.ts +81 -0
  44. package/src/constants.ts +9 -0
  45. package/src/devcontainer-cli.ts +57 -0
  46. package/src/devcontainer.ts +394 -0
  47. package/src/doctor.ts +139 -0
  48. package/src/github-git-auth.ts +143 -0
  49. package/src/jsonc.ts +123 -0
  50. package/src/list.ts +102 -0
  51. package/src/main.ts +362 -0
  52. package/src/metadata.ts +84 -0
  53. package/src/paths.ts +117 -0
  54. package/src/process.ts +94 -0
  55. package/src/shell.ts +60 -0
  56. package/src/ssh-config.ts +113 -0
  57. package/src/ssh-key.ts +52 -0
  58. package/src/status.ts +327 -0
package/src/config.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import {
5
+ BOXDOWN_CONTAINER_AGENTS_DIR,
6
+ BOXDOWN_CONTAINER_DEVCONTAINER_DIR,
7
+ BOXDOWN_CONTAINER_SSH_DIR,
8
+ BOXDOWN_CONTAINER_SSH_PUBLIC_KEY_PATH
9
+ } from './constants.ts'
10
+ import { parseJsonc } from './jsonc.ts'
11
+ import type { WorkspaceContext } from './paths.ts'
12
+ import { shellQuote } from './shell.ts'
13
+
14
+ export interface DevcontainerConfig {
15
+ name?: string
16
+ mounts?: string[]
17
+ containerEnv?: Record<string, string>
18
+ runArgs?: string[]
19
+ initializeCommand?: string
20
+ postCreateCommand?: string
21
+ postStartCommand?: string
22
+ [key: string]: unknown
23
+ }
24
+
25
+ export function readBaseDevcontainerConfig (assetsDevcontainerDir: string): DevcontainerConfig {
26
+ const configPath = join(assetsDevcontainerDir, 'devcontainer.json')
27
+ return parseJsonc<DevcontainerConfig>(readFileSync(configPath, 'utf8'))
28
+ }
29
+
30
+ function directoryExists (path: string): boolean {
31
+ try {
32
+ return statSync(path).isDirectory()
33
+ } catch {
34
+ return false
35
+ }
36
+ }
37
+
38
+ export function buildGeneratedDevcontainerConfig (context: WorkspaceContext): DevcontainerConfig {
39
+ const baseConfig = readBaseDevcontainerConfig(context.assetsDevcontainerDir)
40
+ const mounts = Array.isArray(baseConfig.mounts)
41
+ ? baseConfig.mounts.filter((mount): mount is string => typeof mount === 'string')
42
+ : []
43
+
44
+ const boxdownMounts = [
45
+ `type=bind,source=${context.assetsDevcontainerDir},target=${BOXDOWN_CONTAINER_DEVCONTAINER_DIR},readonly`,
46
+ `type=bind,source=${context.sshPublicKeyRuntimeDir},target=${BOXDOWN_CONTAINER_SSH_DIR},readonly`
47
+ ]
48
+
49
+ if (
50
+ directoryExists(context.hostAgentsDir) &&
51
+ !mounts.some((mount) => mount.includes(`target=${BOXDOWN_CONTAINER_AGENTS_DIR}`))
52
+ ) {
53
+ boxdownMounts.push(`type=bind,source=${context.hostAgentsDir},target=${BOXDOWN_CONTAINER_AGENTS_DIR},readonly`)
54
+ }
55
+
56
+ return {
57
+ ...baseConfig,
58
+ name: `Boxdown: ${context.workspaceBasename}`,
59
+ mounts: [...mounts, ...boxdownMounts],
60
+ initializeCommand: `BOXDOWN_WORKSPACE_FOLDER=${shellQuote(context.workspaceFolder)} bash ${shellQuote(join(context.assetsDevcontainerDir, 'hooks', 'initialize.sh'))}`,
61
+ postCreateCommand: `bash ${shellQuote(`${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/hooks/post-create.sh`)}`,
62
+ postStartCommand: `bash ${shellQuote(`${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/hooks/post-start.sh`)}`,
63
+ containerEnv: {
64
+ ...(baseConfig.containerEnv ?? {}),
65
+ BOXDOWN_CONTAINER_WORKSPACE_FOLDER: '/workspaces/${localWorkspaceFolderBasename}',
66
+ BOXDOWN_WORKSPACE_BASENAME: '${localWorkspaceFolderBasename}',
67
+ DEVCONTAINER_SSH_PUBLIC_KEY_FILE: BOXDOWN_CONTAINER_SSH_PUBLIC_KEY_PATH
68
+ }
69
+ }
70
+ }
71
+
72
+ export function writeGeneratedDevcontainerConfig (context: WorkspaceContext): DevcontainerConfig {
73
+ const config = buildGeneratedDevcontainerConfig(context)
74
+ mkdirSync(context.workspaceCacheDir, { recursive: true })
75
+ writeFileSync(context.generatedConfigPath, `${JSON.stringify(config, null, 2)}\n`)
76
+ return config
77
+ }
78
+
79
+ export function publishContainerPortFromConfig (config: DevcontainerConfig): string | undefined {
80
+ return config.runArgs?.find((arg) => /^[0-9.]+::[0-9]+$/.test(arg))?.split('::')[1]
81
+ }
@@ -0,0 +1,9 @@
1
+ export const DEVCONTAINER_CLI_VERSION = '0.84.1'
2
+
3
+ export const BOXDOWN_CONTAINER_DEVCONTAINER_DIR = '/opt/boxdown/devcontainer'
4
+ export const BOXDOWN_CONTAINER_STATE_DIR = '/opt/boxdown/state'
5
+ export const BOXDOWN_CONTAINER_SSH_DIR = `${BOXDOWN_CONTAINER_STATE_DIR}/ssh`
6
+ export const BOXDOWN_CONTAINER_SSH_PUBLIC_KEY_PATH = `${BOXDOWN_CONTAINER_SSH_DIR}/id_ed25519.pub`
7
+ export const BOXDOWN_CONTAINER_AGENTS_DIR = '/home/node/.agents'
8
+
9
+ export const PACKAGE_NAME = 'boxdown'
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join, resolve } from 'node:path'
4
+
5
+ import { DEVCONTAINER_CLI_VERSION } from './constants.ts'
6
+ import type { WorkspaceContext } from './paths.ts'
7
+
8
+ export interface DevcontainerCliCommand {
9
+ command: string
10
+ argsPrefix: string[]
11
+ path: string
12
+ version: string
13
+ }
14
+
15
+ interface PackageJson {
16
+ bin?: string | Record<string, string>
17
+ version?: string
18
+ }
19
+
20
+ function devcontainerBinFromPackageJson (packageJson: PackageJson): string | undefined {
21
+ return typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.devcontainer
22
+ }
23
+
24
+ export function resolveDevcontainerCli (context: WorkspaceContext): DevcontainerCliCommand {
25
+ const requireFromBoxdown = createRequire(join(context.packageRoot, 'package.json'))
26
+ let packageJsonPath: string
27
+
28
+ try {
29
+ packageJsonPath = requireFromBoxdown.resolve('@devcontainers/cli/package.json')
30
+ } catch (error) {
31
+ throw new Error(`Boxdown's packaged @devcontainers/cli dependency is missing. Reinstall boxdown so @devcontainers/cli@${DEVCONTAINER_CLI_VERSION} is available.`, { cause: error })
32
+ }
33
+
34
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageJson
35
+ const bin = devcontainerBinFromPackageJson(packageJson)
36
+
37
+ if (packageJson.version !== DEVCONTAINER_CLI_VERSION) {
38
+ throw new Error(`Boxdown expected @devcontainers/cli@${DEVCONTAINER_CLI_VERSION} but resolved ${packageJson.version ?? 'an unknown version'}.`)
39
+ }
40
+
41
+ if (bin === undefined) {
42
+ throw new Error('Boxdown resolved @devcontainers/cli, but the package does not expose a devcontainer binary.')
43
+ }
44
+
45
+ const cliPath = resolve(dirname(packageJsonPath), bin)
46
+
47
+ if (!existsSync(cliPath)) {
48
+ throw new Error(`Boxdown resolved @devcontainers/cli, but its devcontainer binary is missing: ${cliPath}`)
49
+ }
50
+
51
+ return {
52
+ command: process.execPath,
53
+ argsPrefix: [cliPath],
54
+ path: cliPath,
55
+ version: packageJson.version
56
+ }
57
+ }
@@ -0,0 +1,394 @@
1
+ import {
2
+ BOXDOWN_CONTAINER_DEVCONTAINER_DIR
3
+ } from './constants.ts'
4
+ import { buildGeneratedDevcontainerConfig, publishContainerPortFromConfig, writeGeneratedDevcontainerConfig } from './config.ts'
5
+ import { codingAgentBinary, type CodingAgentCli } from './coding-agents.ts'
6
+ import { resolveDevcontainerCli } from './devcontainer-cli.ts'
7
+ import { configureWorkspaceGithubGitAuth } from './github-git-auth.ts'
8
+ import type { WorkspaceContext } from './paths.ts'
9
+ import { runBuffered, runInteractive } from './process.ts'
10
+ import { interactiveCommandScript, interactiveShellEnvArgs, interactiveShellScript } from './shell.ts'
11
+ import { ensureHostSshKey } from './ssh-key.ts'
12
+ import { type ContainerSummary, parseDockerPsJsonLines } from './status.ts'
13
+
14
+ export interface StartOptions {
15
+ recreate?: boolean
16
+ proxyMode?: boolean
17
+ reuseRunning?: boolean
18
+ }
19
+
20
+ function devcontainerWorkspaceArgs (context: WorkspaceContext): string[] {
21
+ return [
22
+ '--workspace-folder',
23
+ context.workspaceFolder,
24
+ '--override-config',
25
+ context.generatedConfigPath
26
+ ]
27
+ }
28
+
29
+ function log (message: string, proxyMode = false): void {
30
+ if (proxyMode) {
31
+ process.stderr.write(`${message}\n`)
32
+ } else {
33
+ process.stdout.write(`${message}\n`)
34
+ }
35
+ }
36
+
37
+ export function parseContainerIdFromUpOutput (output: string): string | undefined {
38
+ return /"containerId"\s*:\s*"([^"]+)"/.exec(output)?.[1]
39
+ }
40
+
41
+ export async function findWorkspaceContainer (context: WorkspaceContext): Promise<ContainerSummary | undefined> {
42
+ const result = await runBuffered('docker', [
43
+ 'ps',
44
+ '-a',
45
+ '--filter',
46
+ `label=devcontainer.local_folder=${context.workspaceFolder}`,
47
+ '--format',
48
+ '{{json .}}'
49
+ ], {
50
+ mirrorStdout: false,
51
+ mirrorStderr: false
52
+ })
53
+
54
+ if (result.code !== 0) {
55
+ throw new Error(`Could not inspect devcontainer for ${context.workspaceFolder}`)
56
+ }
57
+
58
+ return parseDockerPsJsonLines(result.stdout)[0]
59
+ }
60
+
61
+ export async function listWorkspaceContainers (): Promise<ContainerSummary[] | undefined> {
62
+ const result = await runBuffered('docker', [
63
+ 'ps',
64
+ '-a',
65
+ '--filter',
66
+ 'label=devcontainer.local_folder',
67
+ '--format',
68
+ '{{json .}}'
69
+ ], {
70
+ mirrorStdout: false,
71
+ mirrorStderr: false
72
+ })
73
+
74
+ if (result.code !== 0) {
75
+ return undefined
76
+ }
77
+
78
+ return parseDockerPsJsonLines(result.stdout)
79
+ }
80
+
81
+ export async function findRunningContainerId (context: WorkspaceContext): Promise<string | undefined> {
82
+ const result = await runBuffered('docker', [
83
+ 'ps',
84
+ '--filter',
85
+ `label=devcontainer.local_folder=${context.workspaceFolder}`,
86
+ '--format',
87
+ '{{.ID}}'
88
+ ], {
89
+ mirrorStdout: false,
90
+ mirrorStderr: false
91
+ })
92
+
93
+ if (result.code !== 0) {
94
+ return undefined
95
+ }
96
+
97
+ return result.stdout.split(/\r?\n/).find((line) => line.length > 0)
98
+ }
99
+
100
+ export async function stopWorkspaceContainer (context: WorkspaceContext): Promise<void> {
101
+ const container = await findWorkspaceContainer(context)
102
+
103
+ if (container === undefined) {
104
+ process.stdout.write(`No devcontainer found for: ${context.workspaceFolder}\n`)
105
+ return
106
+ }
107
+
108
+ if (container.state?.toLowerCase() !== 'running') {
109
+ process.stdout.write(`Devcontainer is not running for: ${context.workspaceFolder}\n`)
110
+ return
111
+ }
112
+
113
+ const result = await runBuffered('docker', ['stop', container.id], {
114
+ mirrorStdout: false,
115
+ mirrorStderr: false
116
+ })
117
+
118
+ if (result.code !== 0) {
119
+ throw new Error(`Could not stop devcontainer ${container.id}`)
120
+ }
121
+
122
+ process.stdout.write(`Stopped devcontainer: ${container.id}\n`)
123
+ }
124
+
125
+ export async function removeWorkspaceContainer (context: WorkspaceContext): Promise<void> {
126
+ const container = await findWorkspaceContainer(context)
127
+
128
+ if (container === undefined) {
129
+ process.stdout.write(`No devcontainer found for: ${context.workspaceFolder}\n`)
130
+ return
131
+ }
132
+
133
+ const result = await runBuffered('docker', ['rm', '-f', container.id], {
134
+ mirrorStdout: false,
135
+ mirrorStderr: false
136
+ })
137
+
138
+ if (result.code !== 0) {
139
+ throw new Error(`Could not remove devcontainer ${container.id}`)
140
+ }
141
+
142
+ process.stdout.write(`Removed devcontainer: ${container.id}\n`)
143
+ }
144
+
145
+ export async function startDevcontainer (context: WorkspaceContext, options: StartOptions = {}): Promise<string> {
146
+ await ensureHostSshKey(context, options.proxyMode ?? false)
147
+ writeGeneratedDevcontainerConfig(context)
148
+
149
+ if (options.reuseRunning === true && options.recreate !== true) {
150
+ const runningContainerId = await findRunningContainerId(context)
151
+
152
+ if (runningContainerId !== undefined) {
153
+ log(`Using running devcontainer for: ${context.workspaceFolder}`, options.proxyMode)
154
+ return runningContainerId
155
+ }
156
+ }
157
+
158
+ const cli = resolveDevcontainerCli(context)
159
+ log(`Starting devcontainer for: ${context.workspaceFolder}`, options.proxyMode)
160
+
161
+ const args = [
162
+ 'up',
163
+ ...devcontainerWorkspaceArgs(context)
164
+ ]
165
+
166
+ if (options.recreate === true) {
167
+ args.push('--remove-existing-container')
168
+ log('Removing existing dev container so create-time settings apply.', options.proxyMode)
169
+ }
170
+
171
+ const result = await runBuffered(cli.command, [...cli.argsPrefix, ...args], {
172
+ mirrorStdout: options.proxyMode === true ? 'stderr' : 'stdout',
173
+ mirrorStderr: 'stderr'
174
+ })
175
+
176
+ if (result.code !== 0) {
177
+ throw new Error(`devcontainer up failed for ${context.workspaceFolder}`)
178
+ }
179
+
180
+ const containerId = parseContainerIdFromUpOutput(`${result.stdout}\n${result.stderr}`) ?? await findRunningContainerId(context)
181
+
182
+ if (containerId === undefined) {
183
+ throw new Error(`Could not resolve devcontainer ID for ${context.workspaceFolder}`)
184
+ }
185
+
186
+ return containerId
187
+ }
188
+
189
+ export async function printPortHint (context: WorkspaceContext, containerId: string): Promise<void> {
190
+ const config = buildGeneratedDevcontainerConfig(context)
191
+ const containerPort = publishContainerPortFromConfig(config)
192
+
193
+ if (containerPort === undefined) {
194
+ process.stderr.write('Warning: could not find a runArgs publish port.\n')
195
+ return
196
+ }
197
+
198
+ const result = await runBuffered('docker', ['port', containerId, `${containerPort}/tcp`], {
199
+ mirrorStdout: false,
200
+ mirrorStderr: false
201
+ })
202
+
203
+ const hostBinding = result.stdout.split(/\r?\n/).find((line) => line.length > 0)
204
+
205
+ if (result.code === 0 && hostBinding !== undefined) {
206
+ process.stdout.write(`\nDev server available at: http://${hostBinding}\n\n`)
207
+ } else {
208
+ process.stderr.write(`Warning: container is running but port ${containerPort}/tcp is not mapped.\n`)
209
+ }
210
+ }
211
+
212
+ export async function openShell (context: WorkspaceContext): Promise<number> {
213
+ const cli = resolveDevcontainerCli(context)
214
+ process.stdout.write('Dropping into container shell...\n')
215
+
216
+ return runInteractive(cli.command, [
217
+ ...cli.argsPrefix,
218
+ 'exec',
219
+ ...devcontainerWorkspaceArgs(context),
220
+ '--',
221
+ 'env',
222
+ ...interactiveShellEnvArgs(),
223
+ 'bash',
224
+ '-c',
225
+ interactiveShellScript()
226
+ ])
227
+ }
228
+
229
+ export function codingAgentDevcontainerExecArgs (context: WorkspaceContext, agent: CodingAgentCli, agentArgs: string[] = []): string[] {
230
+ return [
231
+ 'exec',
232
+ ...devcontainerWorkspaceArgs(context),
233
+ '--',
234
+ 'env',
235
+ ...interactiveShellEnvArgs(),
236
+ 'bash',
237
+ '-c',
238
+ interactiveCommandScript(),
239
+ 'boxdown-agent',
240
+ codingAgentBinary(agent),
241
+ ...agentArgs
242
+ ]
243
+ }
244
+
245
+ export async function openCodingAgentCli (context: WorkspaceContext, agent: CodingAgentCli, agentArgs: string[] = []): Promise<number> {
246
+ const cli = resolveDevcontainerCli(context)
247
+ process.stdout.write(`Dropping into ${codingAgentBinary(agent)} inside the devcontainer...\n`)
248
+
249
+ return runInteractive(cli.command, [
250
+ ...cli.argsPrefix,
251
+ ...codingAgentDevcontainerExecArgs(context, agent, agentArgs)
252
+ ])
253
+ }
254
+
255
+ export async function ensureContainerSshRuntime (context: WorkspaceContext): Promise<void> {
256
+ const cli = resolveDevcontainerCli(context)
257
+ const result = await runBuffered(cli.command, [
258
+ ...cli.argsPrefix,
259
+ 'exec',
260
+ ...devcontainerWorkspaceArgs(context),
261
+ '--',
262
+ 'bash',
263
+ `${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/utils/ssh-bootstrap.sh`,
264
+ 'runtime'
265
+ ], {
266
+ mirrorStdout: 'stderr',
267
+ mirrorStderr: 'stderr'
268
+ })
269
+
270
+ if (result.code !== 0) {
271
+ throw new Error('Failed to prepare devcontainer SSH runtime')
272
+ }
273
+ }
274
+
275
+ export async function refreshContainerCodingAgentClis (context: WorkspaceContext, proxyMode = false, agents: CodingAgentCli[] = []): Promise<void> {
276
+ const cli = resolveDevcontainerCli(context)
277
+ const result = await runBuffered(cli.command, [
278
+ ...cli.argsPrefix,
279
+ 'exec',
280
+ ...devcontainerWorkspaceArgs(context),
281
+ '--',
282
+ 'bash',
283
+ `${BOXDOWN_CONTAINER_DEVCONTAINER_DIR}/utils/coding-agent-cli-update.sh`,
284
+ 'maybe-update',
285
+ ...agents
286
+ ], {
287
+ mirrorStdout: proxyMode ? 'stderr' : 'stdout',
288
+ mirrorStderr: 'stderr'
289
+ })
290
+
291
+ if (result.code !== 0) {
292
+ process.stderr.write('Warning: could not refresh one or more coding-agent CLIs inside the devcontainer.\n')
293
+ }
294
+ }
295
+
296
+ export async function runSshdProxy (containerId: string): Promise<number> {
297
+ return runInteractive('docker', [
298
+ 'exec',
299
+ '-i',
300
+ containerId,
301
+ '/usr/sbin/sshd',
302
+ '-i',
303
+ '-o',
304
+ 'LogLevel=QUIET',
305
+ '-o',
306
+ 'PubkeyAuthentication=yes',
307
+ '-o',
308
+ 'PasswordAuthentication=no',
309
+ '-o',
310
+ 'KbdInteractiveAuthentication=no',
311
+ '-o',
312
+ 'AllowTcpForwarding=yes',
313
+ '-o',
314
+ 'AllowStreamLocalForwarding=yes',
315
+ '-o',
316
+ 'PermitTTY=yes'
317
+ ])
318
+ }
319
+
320
+ async function hostGhTokenOrEmpty (): Promise<string> {
321
+ const result = await runBuffered('gh', ['auth', 'token'], {
322
+ mirrorStdout: false,
323
+ mirrorStderr: false
324
+ })
325
+
326
+ if (result.code !== 0) {
327
+ return ''
328
+ }
329
+
330
+ return result.stdout.trim()
331
+ }
332
+
333
+ export async function refreshContainerGhAuth (context: WorkspaceContext): Promise<void> {
334
+ await ensureHostSshKey(context, true)
335
+ writeGeneratedDevcontainerConfig(context)
336
+
337
+ const token = await hostGhTokenOrEmpty()
338
+
339
+ if (token.length === 0) {
340
+ return
341
+ }
342
+
343
+ const cli = resolveDevcontainerCli(context)
344
+ const login = await runBuffered(cli.command, [
345
+ ...cli.argsPrefix,
346
+ 'exec',
347
+ ...devcontainerWorkspaceArgs(context),
348
+ '--',
349
+ 'gh',
350
+ 'auth',
351
+ 'login',
352
+ '--hostname',
353
+ 'github.com',
354
+ '--git-protocol',
355
+ 'https',
356
+ '--with-token',
357
+ '--insecure-storage'
358
+ ], {
359
+ input: `${token}\n`,
360
+ mirrorStdout: false,
361
+ mirrorStderr: false
362
+ })
363
+
364
+ if (login.code !== 0) {
365
+ process.stderr.write('Warning: could not refresh GitHub CLI auth inside the devcontainer.\n')
366
+ return
367
+ }
368
+
369
+ const gitAuthConfigured = await configureWorkspaceGithubGitAuth(context.workspaceFolder)
370
+ if (!gitAuthConfigured) {
371
+ process.stderr.write('Warning: GitHub CLI auth refreshed, but GitHub Git auth was not configured for this workspace.\n')
372
+ }
373
+
374
+ const verify = await runBuffered(cli.command, [
375
+ ...cli.argsPrefix,
376
+ 'exec',
377
+ ...devcontainerWorkspaceArgs(context),
378
+ '--',
379
+ 'gh',
380
+ 'auth',
381
+ 'status',
382
+ '--hostname',
383
+ 'github.com'
384
+ ], {
385
+ mirrorStdout: false,
386
+ mirrorStderr: false
387
+ })
388
+
389
+ if (verify.code === 0) {
390
+ process.stdout.write('GitHub CLI auth refreshed inside the devcontainer.\n')
391
+ } else {
392
+ process.stderr.write('Warning: GitHub CLI auth refresh completed, but verification failed.\n')
393
+ }
394
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ import { resolveDevcontainerCli } from './devcontainer-cli.ts'
4
+ import type { WorkspaceContext } from './paths.ts'
5
+ import { runBuffered } from './process.ts'
6
+
7
+ export type DoctorLevel = 'ok' | 'fail' | 'warn'
8
+
9
+ export interface DoctorCheck {
10
+ name: string
11
+ level: DoctorLevel
12
+ message: string
13
+ }
14
+
15
+ function nodeVersionPasses (version: string): boolean {
16
+ const major = Number.parseInt(version.split('.')[0] ?? '', 10)
17
+ return Number.isInteger(major) && major >= 24
18
+ }
19
+
20
+ function check (name: string, pass: boolean, okMessage: string, failMessage: string): DoctorCheck {
21
+ return {
22
+ name,
23
+ level: pass ? 'ok' : 'fail',
24
+ message: pass ? okMessage : failMessage
25
+ }
26
+ }
27
+
28
+ async function commandWorks (command: string, args: string[]): Promise<boolean> {
29
+ const result = await runBuffered(command, args, {
30
+ mirrorStdout: false,
31
+ mirrorStderr: false
32
+ })
33
+
34
+ return result.code === 0
35
+ }
36
+
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
+
43
+ return result.code !== 127
44
+ }
45
+
46
+ export async function runDoctorChecks (context: WorkspaceContext): Promise<DoctorCheck[]> {
47
+ const checks: DoctorCheck[] = []
48
+ const nodeVersion = process.versions.node
49
+
50
+ checks.push(check(
51
+ 'node',
52
+ nodeVersionPasses(nodeVersion),
53
+ `Node ${nodeVersion}`,
54
+ `Node ${nodeVersion}; expected >=24.0.0`
55
+ ))
56
+
57
+ checks.push(check(
58
+ 'devcontainers-cli',
59
+ await packagedDevcontainerCliWorks(context),
60
+ 'Packaged @devcontainers/cli is available',
61
+ 'Packaged @devcontainers/cli is required but was not available'
62
+ ))
63
+
64
+ checks.push(check(
65
+ 'docker-cli',
66
+ await commandWorks('docker', ['--version']),
67
+ 'Docker CLI is available',
68
+ 'Docker CLI is required but was not available'
69
+ ))
70
+
71
+ checks.push(check(
72
+ 'docker-daemon',
73
+ await commandWorks('docker', ['info']),
74
+ 'Docker daemon is reachable',
75
+ 'Docker daemon is required but was not reachable'
76
+ ))
77
+
78
+ checks.push(check(
79
+ 'ssh',
80
+ await commandExists('ssh', ['-V']),
81
+ 'ssh is available',
82
+ 'ssh is required but was not available'
83
+ ))
84
+
85
+ checks.push(check(
86
+ 'ssh-keygen',
87
+ await commandExists('ssh-keygen', ['-?']),
88
+ 'ssh-keygen is available',
89
+ 'ssh-keygen is required but was not available'
90
+ ))
91
+
92
+ checks.push(check(
93
+ 'assets',
94
+ existsSync(context.assetsDevcontainerDir),
95
+ `Devcontainer assets found at ${context.assetsDevcontainerDir}`,
96
+ `Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`
97
+ ))
98
+
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
+ })
112
+ }
113
+
114
+ return checks
115
+ }
116
+
117
+ async function packagedDevcontainerCliWorks (context: WorkspaceContext): Promise<boolean> {
118
+ try {
119
+ const cli = resolveDevcontainerCli(context)
120
+ return await commandWorks(cli.command, [...cli.argsPrefix, '--version'])
121
+ } catch {
122
+ return false
123
+ }
124
+ }
125
+
126
+ export function doctorHasFailures (checks: DoctorCheck[]): boolean {
127
+ return checks.some((item) => item.level === 'fail')
128
+ }
129
+
130
+ export function formatDoctorText (checks: DoctorCheck[]): string {
131
+ const lines = ['Boxdown doctor', '']
132
+
133
+ for (const item of checks) {
134
+ lines.push(`[${item.level}] ${item.name}: ${item.message}`)
135
+ }
136
+
137
+ lines.push('', doctorHasFailures(checks) ? 'Result: failed' : 'Result: ok')
138
+ return `${lines.join('\n')}\n`
139
+ }