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/main.ts ADDED
@@ -0,0 +1,362 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ import { codingAgentFromCommand, type CodingAgentCli } from './coding-agents.ts'
4
+ import { doctorHasFailures, formatDoctorText, runDoctorChecks } from './doctor.ts'
5
+ import { startDevcontainer, printPortHint, openShell, openCodingAgentCli, ensureContainerSshRuntime, runSshdProxy, refreshContainerGhAuth, refreshContainerCodingAgentClis, findRunningContainerId, findWorkspaceContainer, stopWorkspaceContainer, removeWorkspaceContainer, listWorkspaceContainers } from './devcontainer.ts'
6
+ import { createWorkspaceListEntries, formatWorkspaceListText } from './list.ts'
7
+ import { listWorkspaceMetadata, writeWorkspaceMetadata } from './metadata.ts'
8
+ import { createWorkspaceContext, defaultDataRoot } from './paths.ts'
9
+ import { defaultSshAlias, installSshConfig } from './ssh-config.ts'
10
+ import { createStatusInfo, formatStatusText, statusIsHealthy } from './status.ts'
11
+
12
+ export type BoxdownCommand =
13
+ | 'help'
14
+ | 'start'
15
+ | 'list'
16
+ | 'status'
17
+ | 'stop'
18
+ | 'down'
19
+ | 'doctor'
20
+ | 'ssh-config-install'
21
+ | 'ssh-proxy'
22
+ | 'refresh-gh-token'
23
+ | 'refresh-gh-token-running'
24
+ | 'coding-agent'
25
+
26
+ export interface ParsedCli {
27
+ command: BoxdownCommand
28
+ agent?: CodingAgentCli
29
+ agentArgs?: string[]
30
+ workspace?: string
31
+ alias?: string
32
+ recreate: boolean
33
+ json: boolean
34
+ }
35
+
36
+ export const USAGE = `Usage:
37
+ boxdown start [--workspace <path>] [--recreate]
38
+ boxdown codex [--workspace <path>] [--recreate] [-- <codex args...>]
39
+ boxdown claude [--workspace <path>] [--recreate] [-- <claude args...>]
40
+ boxdown cc [--workspace <path>] [--recreate] [-- <claude args...>]
41
+ boxdown opencode [--workspace <path>] [--recreate] [-- <opencode args...>]
42
+ boxdown antigravity [--workspace <path>] [--recreate] [-- <agy args...>]
43
+ boxdown list [--json]
44
+ boxdown status [--workspace <path>] [--alias <name>] [--json]
45
+ boxdown stop [--workspace <path>]
46
+ boxdown down [--workspace <path>]
47
+ boxdown doctor [--workspace <path>]
48
+ boxdown ssh-config install [--workspace <path>] [--alias <name>]
49
+ boxdown ssh-proxy [--workspace <path>] [--alias <name>]
50
+ boxdown refresh-gh-token [--workspace <path>]
51
+ boxdown refresh-gh-token-running [--workspace <path>]
52
+
53
+ Commands:
54
+ start Start or reuse the workspace devcontainer, then open
55
+ an interactive shell inside it. Alias: shell.
56
+ codex Start or reuse the devcontainer, then launch Codex.
57
+ claude Start or reuse the devcontainer, then launch Claude
58
+ Code. Alias: cc.
59
+ opencode Start or reuse the devcontainer, then launch
60
+ OpenCode.
61
+ antigravity Start or reuse the devcontainer, then launch
62
+ Antigravity CLI (agy).
63
+ list List Boxdown-known devcontainer workspaces from any
64
+ directory.
65
+ status Show workspace state, generated paths, SSH key paths,
66
+ and the matching devcontainer state.
67
+ stop Stop the workspace devcontainer if it is running.
68
+ down Remove the workspace devcontainer. Keeps Boxdown
69
+ cache, generated config, data, and SSH keys.
70
+ doctor Check required host tools and Boxdown assets.
71
+ ssh-config install Install or update an SSH host alias for the workspace
72
+ devcontainer.
73
+ ssh-proxy Internal command used by the generated SSH
74
+ ProxyCommand. Starts or reuses the devcontainer and
75
+ bridges SSH over docker exec.
76
+ refresh-gh-token Start or reuse the devcontainer, then copy host
77
+ GitHub CLI auth into the container when available.
78
+ refresh-gh-token-running Refresh GitHub CLI auth only if the workspace
79
+ devcontainer is already running.
80
+
81
+ Options:
82
+ --workspace <path> Target project directory. Defaults to the current directory.
83
+ --alias <name> SSH host alias. Defaults to <repo-name>-devcontainer.
84
+ --recreate Remove the existing devcontainer before starting.
85
+ --json Print JSON output. Supported by status and list.
86
+ --help, -h Show help.
87
+ `
88
+
89
+ export function commandWritesWorkspaceMetadata (command: BoxdownCommand): boolean {
90
+ return [
91
+ 'start',
92
+ 'ssh-config-install',
93
+ 'ssh-proxy',
94
+ 'refresh-gh-token',
95
+ 'refresh-gh-token-running',
96
+ 'coding-agent'
97
+ ].includes(command)
98
+ }
99
+
100
+ export function parseCliArgs (argv: string[]): ParsedCli {
101
+ const args = [...argv]
102
+ let workspace: string | undefined
103
+ let alias: string | undefined
104
+ let recreate = false
105
+ let json = false
106
+ let passthroughArgs: string[] | undefined
107
+ const positional: string[] = []
108
+
109
+ function parsed (command: BoxdownCommand): ParsedCli {
110
+ if (json && command !== 'status' && command !== 'list') {
111
+ throw new Error('--json is only supported with status and list')
112
+ }
113
+
114
+ if (passthroughArgs !== undefined) {
115
+ throw new Error('-- passthrough is only supported with coding-agent commands')
116
+ }
117
+
118
+ return { command, workspace, alias, recreate, json }
119
+ }
120
+
121
+ function parsedCodingAgent (agent: CodingAgentCli): ParsedCli {
122
+ if (json) {
123
+ throw new Error('--json is only supported with status and list')
124
+ }
125
+
126
+ return {
127
+ command: 'coding-agent',
128
+ agent,
129
+ agentArgs: passthroughArgs ?? [],
130
+ workspace,
131
+ alias,
132
+ recreate,
133
+ json
134
+ }
135
+ }
136
+
137
+ while (args.length > 0) {
138
+ const arg = args.shift()
139
+
140
+ if (arg === undefined) {
141
+ break
142
+ }
143
+
144
+ if (arg === '--') {
145
+ passthroughArgs = args.splice(0)
146
+ break
147
+ }
148
+
149
+ if (arg === '--help' || arg === '-h') {
150
+ return parsed('help')
151
+ }
152
+
153
+ if (arg === '--workspace') {
154
+ const value = args.shift()
155
+ if (value === undefined) {
156
+ throw new Error('--workspace requires a value')
157
+ }
158
+ workspace = value
159
+ continue
160
+ }
161
+
162
+ if (arg === '--alias') {
163
+ const value = args.shift()
164
+ if (value === undefined) {
165
+ throw new Error('--alias requires a value')
166
+ }
167
+ alias = value
168
+ continue
169
+ }
170
+
171
+ if (arg === '--recreate') {
172
+ recreate = true
173
+ continue
174
+ }
175
+
176
+ if (arg === '--json') {
177
+ json = true
178
+ continue
179
+ }
180
+
181
+ if (arg.startsWith('-')) {
182
+ throw new Error(`Unknown option: ${arg}`)
183
+ }
184
+
185
+ positional.push(arg)
186
+ }
187
+
188
+ if (positional.length === 0) {
189
+ return parsed('help')
190
+ }
191
+
192
+ if (positional[0] === 'start' || positional[0] === 'shell') {
193
+ return parsed('start')
194
+ }
195
+
196
+ const codingAgent = codingAgentFromCommand(positional[0] ?? '')
197
+ if (codingAgent !== undefined) {
198
+ if (positional.length > 1) {
199
+ throw new Error('Coding-agent CLI arguments must come after --')
200
+ }
201
+
202
+ return parsedCodingAgent(codingAgent)
203
+ }
204
+
205
+ if (positional[0] === 'list' && positional.length === 1) {
206
+ return parsed('list')
207
+ }
208
+
209
+ if (positional[0] === 'status' && positional.length === 1) {
210
+ return parsed('status')
211
+ }
212
+
213
+ if (positional[0] === 'stop' && positional.length === 1) {
214
+ return parsed('stop')
215
+ }
216
+
217
+ if (positional[0] === 'down' && positional.length === 1) {
218
+ return parsed('down')
219
+ }
220
+
221
+ if (positional[0] === 'doctor' && positional.length === 1) {
222
+ return parsed('doctor')
223
+ }
224
+
225
+ if (positional[0] === 'ssh-config') {
226
+ if (positional.length === 1 || (positional[1] === 'install' && positional.length === 2)) {
227
+ return parsed('ssh-config-install')
228
+ }
229
+
230
+ throw new Error(`Unknown ssh-config command: ${positional.slice(1).join(' ')}. Usage: boxdown ssh-config [install] [--workspace <path>] [--alias <name>]`)
231
+ }
232
+
233
+ if (positional[0] === 'ssh-proxy' && positional.length === 1) {
234
+ return parsed('ssh-proxy')
235
+ }
236
+
237
+ if (positional[0] === 'refresh-gh-token' && positional.length === 1) {
238
+ return parsed('refresh-gh-token')
239
+ }
240
+
241
+ if (positional[0] === 'refresh-gh-token-running' && positional.length === 1) {
242
+ return parsed('refresh-gh-token-running')
243
+ }
244
+
245
+ throw new Error(`Unknown command: ${positional.join(' ')}`)
246
+ }
247
+
248
+ export async function runCli (argv: string[] = process.argv.slice(2)): Promise<number> {
249
+ try {
250
+ const parsed = parseCliArgs(argv)
251
+
252
+ if (parsed.command === 'help') {
253
+ process.stdout.write(USAGE)
254
+ return 0
255
+ }
256
+
257
+ if (parsed.command === 'list') {
258
+ const metadata = listWorkspaceMetadata(defaultDataRoot())
259
+ const containers = await listWorkspaceContainers()
260
+ const entries = createWorkspaceListEntries(metadata, containers, existsSync)
261
+
262
+ if (parsed.json) {
263
+ process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`)
264
+ } else {
265
+ process.stdout.write(formatWorkspaceListText(entries))
266
+ }
267
+
268
+ return 0
269
+ }
270
+
271
+ const context = createWorkspaceContext({ workspace: parsed.workspace })
272
+ const alias = parsed.alias ?? defaultSshAlias(context.workspaceBasename)
273
+ const aliasSource = parsed.alias === undefined ? 'default' : 'provided'
274
+
275
+ if (commandWritesWorkspaceMetadata(parsed.command)) {
276
+ writeWorkspaceMetadata(context, alias)
277
+ }
278
+
279
+ if (parsed.command === 'ssh-config-install') {
280
+ await installSshConfig(context, alias)
281
+ return 0
282
+ }
283
+
284
+ if (parsed.command === 'status') {
285
+ const container = await findWorkspaceContainer(context)
286
+ const status = createStatusInfo(context, alias, container, existsSync, { aliasSource })
287
+
288
+ if (parsed.json) {
289
+ process.stdout.write(`${JSON.stringify(status, null, 2)}\n`)
290
+ } else {
291
+ process.stdout.write(formatStatusText(status, { color: true }))
292
+ }
293
+
294
+ return statusIsHealthy(status) ? 0 : 1
295
+ }
296
+
297
+ if (parsed.command === 'stop') {
298
+ await stopWorkspaceContainer(context)
299
+ return 0
300
+ }
301
+
302
+ if (parsed.command === 'down') {
303
+ await removeWorkspaceContainer(context)
304
+ return 0
305
+ }
306
+
307
+ if (parsed.command === 'doctor') {
308
+ const checks = await runDoctorChecks(context)
309
+ process.stdout.write(formatDoctorText(checks))
310
+ return doctorHasFailures(checks) ? 1 : 0
311
+ }
312
+
313
+ if (!existsSync(context.assetsDevcontainerDir)) {
314
+ throw new Error(`Missing Boxdown devcontainer assets: ${context.assetsDevcontainerDir}`)
315
+ }
316
+
317
+ if (parsed.command === 'ssh-proxy') {
318
+ await installSshConfig(context, alias, { quiet: true })
319
+ const containerId = await startDevcontainer(context, {
320
+ recreate: parsed.recreate,
321
+ proxyMode: true,
322
+ reuseRunning: true
323
+ })
324
+ await refreshContainerCodingAgentClis(context, true)
325
+ await ensureContainerSshRuntime(context)
326
+ return runSshdProxy(containerId)
327
+ }
328
+
329
+ if (parsed.command === 'refresh-gh-token-running') {
330
+ const containerId = await findRunningContainerId(context)
331
+ if (containerId === undefined) {
332
+ throw new Error(`No running devcontainer found for: ${context.workspaceFolder}`)
333
+ }
334
+ await refreshContainerGhAuth(context)
335
+ return 0
336
+ }
337
+
338
+ if (parsed.command === 'refresh-gh-token') {
339
+ await startDevcontainer(context)
340
+ await refreshContainerGhAuth(context)
341
+ return 0
342
+ }
343
+
344
+ if (parsed.command === 'coding-agent') {
345
+ const agent = parsed.agent
346
+ if (agent === undefined) {
347
+ throw new Error('Missing coding-agent command')
348
+ }
349
+
350
+ await startDevcontainer(context, { recreate: parsed.recreate })
351
+ await refreshContainerCodingAgentClis(context, false, [agent])
352
+ return openCodingAgentCli(context, agent, parsed.agentArgs ?? [])
353
+ }
354
+
355
+ const containerId = await startDevcontainer(context, { recreate: parsed.recreate })
356
+ await printPortHint(context, containerId)
357
+ return openShell(context)
358
+ } catch (error) {
359
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
360
+ return 1
361
+ }
362
+ }
@@ -0,0 +1,84 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { WorkspaceContext } from './paths.ts'
5
+
6
+ export const WORKSPACE_METADATA_VERSION = 1
7
+
8
+ export interface WorkspaceMetadata {
9
+ version: 1
10
+ workspaceId: string
11
+ workspaceFolder: string
12
+ workspaceBasename: string
13
+ sshAlias: string
14
+ firstSeenAt: string
15
+ lastSeenAt: string
16
+ }
17
+
18
+ function isWorkspaceMetadata (value: unknown): value is WorkspaceMetadata {
19
+ if (value === null || typeof value !== 'object') {
20
+ return false
21
+ }
22
+
23
+ const candidate = value as Record<string, unknown>
24
+
25
+ return candidate.version === WORKSPACE_METADATA_VERSION &&
26
+ typeof candidate.workspaceId === 'string' &&
27
+ typeof candidate.workspaceFolder === 'string' &&
28
+ typeof candidate.workspaceBasename === 'string' &&
29
+ typeof candidate.sshAlias === 'string' &&
30
+ typeof candidate.firstSeenAt === 'string' &&
31
+ typeof candidate.lastSeenAt === 'string'
32
+ }
33
+
34
+ export function workspaceMetadataPath (context: WorkspaceContext): string {
35
+ return join(context.workspaceDataDir, 'metadata.json')
36
+ }
37
+
38
+ export function readWorkspaceMetadataFile (path: string): WorkspaceMetadata {
39
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
40
+
41
+ if (!isWorkspaceMetadata(parsed)) {
42
+ throw new Error(`Invalid Boxdown workspace metadata: ${path}`)
43
+ }
44
+
45
+ return parsed
46
+ }
47
+
48
+ export function writeWorkspaceMetadata (context: WorkspaceContext, sshAlias: string, now = new Date()): WorkspaceMetadata {
49
+ const metadataPath = workspaceMetadataPath(context)
50
+ const timestamp = now.toISOString()
51
+ let firstSeenAt = timestamp
52
+
53
+ if (existsSync(metadataPath)) {
54
+ firstSeenAt = readWorkspaceMetadataFile(metadataPath).firstSeenAt
55
+ }
56
+
57
+ const metadata: WorkspaceMetadata = {
58
+ version: WORKSPACE_METADATA_VERSION,
59
+ workspaceId: context.workspaceId,
60
+ workspaceFolder: context.workspaceFolder,
61
+ workspaceBasename: context.workspaceBasename,
62
+ sshAlias,
63
+ firstSeenAt,
64
+ lastSeenAt: timestamp
65
+ }
66
+
67
+ mkdirSync(context.workspaceDataDir, { recursive: true })
68
+ writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`)
69
+ return metadata
70
+ }
71
+
72
+ export function listWorkspaceMetadata (dataRoot: string): WorkspaceMetadata[] {
73
+ const workspacesDir = join(dataRoot, 'workspaces')
74
+
75
+ if (!existsSync(workspacesDir)) {
76
+ return []
77
+ }
78
+
79
+ return readdirSync(workspacesDir, { withFileTypes: true })
80
+ .filter((entry) => entry.isDirectory())
81
+ .map((entry) => join(workspacesDir, entry.name, 'metadata.json'))
82
+ .filter((metadataPath) => existsSync(metadataPath))
83
+ .map((metadataPath) => readWorkspaceMetadataFile(metadataPath))
84
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, realpathSync, statSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { basename, dirname, join, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { PACKAGE_NAME } from './constants.ts'
8
+
9
+ export interface WorkspaceContextOptions {
10
+ workspace?: string
11
+ cwd?: string
12
+ env?: NodeJS.ProcessEnv
13
+ packageRoot?: string
14
+ assetsDevcontainerDir?: string
15
+ }
16
+
17
+ export interface WorkspaceContext {
18
+ workspaceFolder: string
19
+ workspaceBasename: string
20
+ workspaceId: string
21
+ packageRoot: string
22
+ assetsDevcontainerDir: string
23
+ cacheRoot: string
24
+ dataRoot: string
25
+ workspaceCacheDir: string
26
+ workspaceDataDir: string
27
+ generatedConfigPath: string
28
+ hostAgentsDir: string
29
+ sshKeyDir: string
30
+ sshKeyPath: string
31
+ sshPublicKeyPath: string
32
+ sshPublicKeyRuntimeDir: string
33
+ sshPublicKeyRuntimePath: string
34
+ }
35
+
36
+ export function packageRootFromImportMeta (importMetaUrl = import.meta.url): string {
37
+ return dirname(dirname(fileURLToPath(importMetaUrl)))
38
+ }
39
+
40
+ export function workspaceIdFor (workspaceFolder: string): string {
41
+ return createHash('sha256').update(workspaceFolder).digest('hex').slice(0, 16)
42
+ }
43
+
44
+ export function resolveWorkspaceFolder (workspace: string | undefined, cwd = process.cwd()): string {
45
+ const candidate = resolve(cwd, workspace ?? '.')
46
+
47
+ if (!existsSync(candidate)) {
48
+ throw new Error(`Workspace does not exist: ${candidate}`)
49
+ }
50
+
51
+ if (!statSync(candidate).isDirectory()) {
52
+ throw new Error(`Workspace is not a directory: ${candidate}`)
53
+ }
54
+
55
+ return realpathSync(candidate)
56
+ }
57
+
58
+ export function defaultCacheRoot (env: NodeJS.ProcessEnv = process.env): string {
59
+ if (env.BOXDOWN_CACHE_HOME) {
60
+ return env.BOXDOWN_CACHE_HOME
61
+ }
62
+
63
+ if (env.XDG_CACHE_HOME) {
64
+ return join(env.XDG_CACHE_HOME, PACKAGE_NAME)
65
+ }
66
+
67
+ return join(homedir(), '.cache', PACKAGE_NAME)
68
+ }
69
+
70
+ export function defaultDataRoot (env: NodeJS.ProcessEnv = process.env): string {
71
+ if (env.BOXDOWN_DATA_HOME) {
72
+ return env.BOXDOWN_DATA_HOME
73
+ }
74
+
75
+ if (env.XDG_DATA_HOME) {
76
+ return join(env.XDG_DATA_HOME, PACKAGE_NAME)
77
+ }
78
+
79
+ return join(homedir(), '.local', 'share', PACKAGE_NAME)
80
+ }
81
+
82
+ export function defaultHostAgentsDir (env: NodeJS.ProcessEnv = process.env): string {
83
+ return join(env.HOME ?? homedir(), '.agents')
84
+ }
85
+
86
+ export function createWorkspaceContext (options: WorkspaceContextOptions = {}): WorkspaceContext {
87
+ const env = options.env ?? process.env
88
+ const workspaceFolder = resolveWorkspaceFolder(options.workspace, options.cwd)
89
+ const workspaceBasename = basename(workspaceFolder)
90
+ const workspaceId = workspaceIdFor(workspaceFolder)
91
+ const packageRoot = options.packageRoot ?? packageRootFromImportMeta()
92
+ const assetsDevcontainerDir = options.assetsDevcontainerDir ?? env.BOXDOWN_DEVCONTAINER_ASSETS_DIR ?? join(packageRoot, 'assets', 'devcontainer')
93
+ const cacheRoot = defaultCacheRoot(env)
94
+ const dataRoot = defaultDataRoot(env)
95
+ const hostAgentsDir = defaultHostAgentsDir(env)
96
+ const workspaceCacheDir = join(cacheRoot, 'workspaces', workspaceId)
97
+ const workspaceDataDir = join(dataRoot, 'workspaces', workspaceId)
98
+
99
+ return {
100
+ workspaceFolder,
101
+ workspaceBasename,
102
+ workspaceId,
103
+ packageRoot,
104
+ assetsDevcontainerDir,
105
+ cacheRoot,
106
+ dataRoot,
107
+ workspaceCacheDir,
108
+ workspaceDataDir,
109
+ generatedConfigPath: join(workspaceCacheDir, 'devcontainer.json'),
110
+ hostAgentsDir,
111
+ sshKeyDir: join(workspaceDataDir, 'ssh'),
112
+ sshKeyPath: join(workspaceDataDir, 'ssh', 'id_ed25519'),
113
+ sshPublicKeyPath: join(workspaceDataDir, 'ssh', 'id_ed25519.pub'),
114
+ sshPublicKeyRuntimeDir: join(workspaceDataDir, 'ssh-public'),
115
+ sshPublicKeyRuntimePath: join(workspaceDataDir, 'ssh-public', 'id_ed25519.pub')
116
+ }
117
+ }
package/src/process.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ export interface BufferedCommandOptions {
4
+ cwd?: string
5
+ env?: NodeJS.ProcessEnv
6
+ input?: string
7
+ mirrorStdout?: 'stdout' | 'stderr' | false
8
+ mirrorStderr?: 'stdout' | 'stderr' | false
9
+ }
10
+
11
+ export interface CommandResult {
12
+ code: number
13
+ stdout: string
14
+ stderr: string
15
+ }
16
+
17
+ function mergedEnv (env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
18
+ return {
19
+ ...process.env,
20
+ ...(env ?? {})
21
+ }
22
+ }
23
+
24
+ function writeChunk (target: 'stdout' | 'stderr' | false, chunk: Buffer): void {
25
+ if (target === 'stdout') {
26
+ process.stdout.write(chunk)
27
+ } else if (target === 'stderr') {
28
+ process.stderr.write(chunk)
29
+ }
30
+ }
31
+
32
+ export function runBuffered (command: string, args: string[], options: BufferedCommandOptions = {}): Promise<CommandResult> {
33
+ return new Promise((resolve) => {
34
+ const child = spawn(command, args, {
35
+ cwd: options.cwd,
36
+ env: mergedEnv(options.env),
37
+ stdio: ['pipe', 'pipe', 'pipe']
38
+ })
39
+
40
+ const stdoutChunks: Buffer[] = []
41
+ const stderrChunks: Buffer[] = []
42
+
43
+ child.stdout.on('data', (chunk: Buffer) => {
44
+ stdoutChunks.push(chunk)
45
+ writeChunk(options.mirrorStdout ?? 'stdout', chunk)
46
+ })
47
+
48
+ child.stderr.on('data', (chunk: Buffer) => {
49
+ stderrChunks.push(chunk)
50
+ writeChunk(options.mirrorStderr ?? 'stderr', chunk)
51
+ })
52
+
53
+ child.on('error', (error) => {
54
+ resolve({
55
+ code: 127,
56
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
57
+ stderr: `${Buffer.concat(stderrChunks).toString('utf8')}${error.message}\n`
58
+ })
59
+ })
60
+
61
+ child.on('close', (code) => {
62
+ resolve({
63
+ code: code ?? 1,
64
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
65
+ stderr: Buffer.concat(stderrChunks).toString('utf8')
66
+ })
67
+ })
68
+
69
+ if (options.input !== undefined) {
70
+ child.stdin.end(options.input)
71
+ } else {
72
+ child.stdin.end()
73
+ }
74
+ })
75
+ }
76
+
77
+ export function runInteractive (command: string, args: string[], options: Pick<BufferedCommandOptions, 'cwd' | 'env'> = {}): Promise<number> {
78
+ return new Promise((resolve) => {
79
+ const child = spawn(command, args, {
80
+ cwd: options.cwd,
81
+ env: mergedEnv(options.env),
82
+ stdio: 'inherit'
83
+ })
84
+
85
+ child.on('error', (error) => {
86
+ process.stderr.write(`${error.message}\n`)
87
+ resolve(127)
88
+ })
89
+
90
+ child.on('close', (code) => {
91
+ resolve(code ?? 1)
92
+ })
93
+ })
94
+ }