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.
- package/LICENSE +191 -0
- package/README.md +115 -0
- package/assets/devcontainer/README.md +177 -0
- package/assets/devcontainer/devcontainer.json +83 -0
- package/assets/devcontainer/hooks/initialize.sh +55 -0
- package/assets/devcontainer/hooks/post-create.sh +67 -0
- package/assets/devcontainer/hooks/post-start.sh +34 -0
- package/assets/devcontainer/ssh-config-install.sh +172 -0
- package/assets/devcontainer/start.sh +485 -0
- package/assets/devcontainer/utils/codex-cli-update.sh +9 -0
- package/assets/devcontainer/utils/coding-agent-cli-update.sh +360 -0
- package/assets/devcontainer/utils/deps-install.sh +87 -0
- package/assets/devcontainer/utils/ssh-bootstrap.sh +200 -0
- package/dist/bin/cli.cjs +12 -0
- package/dist/bin/cli.d.cts +1 -0
- package/dist/bin/cli.d.mts +1 -0
- package/dist/bin/cli.mjs +14 -0
- package/dist/bin/cli.mjs.map +1 -0
- package/dist/main-BuEptwlL.cjs +1707 -0
- package/dist/main-ZFTrSVgt.mjs +1685 -0
- package/dist/main-ZFTrSVgt.mjs.map +1 -0
- package/dist/main.cjs +7 -0
- package/dist/main.d.cts +24 -0
- package/dist/main.d.cts.map +1 -0
- package/dist/main.d.mts +24 -0
- package/dist/main.d.mts.map +1 -0
- package/dist/main.mjs +3 -0
- package/docs/README.md +34 -0
- package/docs/architecture.md +75 -0
- package/docs/conventions.md +29 -0
- package/docs/development.md +59 -0
- package/docs/features/README.md +14 -0
- package/docs/features/generated-config-and-state.md +64 -0
- package/docs/features/github-auth-refresh.md +64 -0
- package/docs/features/lifecycle.md +64 -0
- package/docs/features/ssh-config-and-proxy.md +60 -0
- package/docs/features/start-and-shell.md +83 -0
- package/docs/testing.md +63 -0
- package/docs/todo.md +170 -0
- package/package.json +128 -0
- package/src/bin/cli.ts +9 -0
- package/src/coding-agents.ts +22 -0
- package/src/config.ts +81 -0
- package/src/constants.ts +9 -0
- package/src/devcontainer-cli.ts +57 -0
- package/src/devcontainer.ts +394 -0
- package/src/doctor.ts +139 -0
- package/src/github-git-auth.ts +143 -0
- package/src/jsonc.ts +123 -0
- package/src/list.ts +102 -0
- package/src/main.ts +362 -0
- package/src/metadata.ts +84 -0
- package/src/paths.ts +117 -0
- package/src/process.ts +94 -0
- package/src/shell.ts +60 -0
- package/src/ssh-config.ts +113 -0
- package/src/ssh-key.ts +52 -0
- 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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|