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/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
|
+
}
|
package/src/metadata.ts
ADDED
|
@@ -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
|
+
}
|