boxdown 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +49 -17
  3. package/assets/devcontainer/devcontainer.json +24 -7
  4. package/assets/devcontainer/hooks/initialize.sh +32 -0
  5. package/assets/devcontainer/hooks/post-create.sh +59 -12
  6. package/assets/devcontainer/hooks/post-start.sh +21 -4
  7. package/assets/devcontainer/ssh-config-install.sh +12 -3
  8. package/assets/devcontainer/start.sh +721 -44
  9. package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
  10. package/assets/devcontainer/utils/deps-install.sh +68 -0
  11. package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
  12. package/assets/devcontainer/utils/git-signing-bootstrap.sh +56 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  15. package/dist/bin/cli.cjs +1 -1
  16. package/dist/bin/cli.mjs +1 -1
  17. package/dist/main-Df4E8ARj.cjs +5498 -0
  18. package/dist/main-XMBsKjIK.mjs +5458 -0
  19. package/dist/main-XMBsKjIK.mjs.map +1 -0
  20. package/dist/main.cjs +5 -2
  21. package/dist/main.d.cts +495 -4
  22. package/dist/main.d.cts.map +1 -1
  23. package/dist/main.d.mts +495 -4
  24. package/dist/main.d.mts.map +1 -1
  25. package/dist/main.mjs +2 -2
  26. package/docs/README.md +1 -0
  27. package/docs/architecture.md +32 -0
  28. package/docs/development.md +13 -6
  29. package/docs/features/README.md +2 -0
  30. package/docs/features/commit-signing.md +23 -0
  31. package/docs/features/generated-config-and-state.md +57 -4
  32. package/docs/features/github-auth-refresh.md +12 -0
  33. package/docs/features/lifecycle.md +97 -11
  34. package/docs/features/setup.md +66 -0
  35. package/docs/features/ssh-config-and-proxy.md +228 -7
  36. package/docs/features/start-and-shell.md +40 -5
  37. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  38. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  39. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +396 -0
  40. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  41. package/docs/testing.md +35 -2
  42. package/docs/todo.md +2 -2
  43. package/package.json +1 -1
  44. package/src/claude-app-config.ts +304 -0
  45. package/src/cli-style.ts +43 -0
  46. package/src/codex-app-config.ts +656 -0
  47. package/src/config.ts +68 -10
  48. package/src/constants.ts +5 -0
  49. package/src/devcontainer.ts +500 -64
  50. package/src/doctor.ts +195 -30
  51. package/src/git-signing.ts +87 -0
  52. package/src/interactive-prompts.ts +692 -0
  53. package/src/list.ts +16 -2
  54. package/src/logging.ts +154 -0
  55. package/src/main.ts +1213 -63
  56. package/src/metadata.ts +52 -3
  57. package/src/package-info.ts +18 -0
  58. package/src/paths.ts +50 -10
  59. package/src/process.ts +80 -2
  60. package/src/progress.ts +593 -0
  61. package/src/purge.ts +208 -0
  62. package/src/shell.ts +19 -0
  63. package/src/ssh-config.ts +134 -16
  64. package/src/ssh-install-targets.ts +111 -0
  65. package/src/ssh-key.ts +53 -13
  66. package/src/status.ts +5 -0
  67. package/dist/main-BuEptwlL.cjs +0 -1707
  68. package/dist/main-ZFTrSVgt.mjs +0 -1685
  69. package/dist/main-ZFTrSVgt.mjs.map +0 -1
package/src/metadata.ts CHANGED
@@ -13,6 +13,14 @@ export interface WorkspaceMetadata {
13
13
  sshAlias: string
14
14
  firstSeenAt: string
15
15
  lastSeenAt: string
16
+ dockerImageId?: string
17
+ dockerImageName?: string
18
+ dockerImageLastSeenAt?: string
19
+ }
20
+
21
+ export interface WorkspaceDockerImageMetadata {
22
+ id: string
23
+ name?: string
16
24
  }
17
25
 
18
26
  function isWorkspaceMetadata (value: unknown): value is WorkspaceMetadata {
@@ -28,7 +36,10 @@ function isWorkspaceMetadata (value: unknown): value is WorkspaceMetadata {
28
36
  typeof candidate.workspaceBasename === 'string' &&
29
37
  typeof candidate.sshAlias === 'string' &&
30
38
  typeof candidate.firstSeenAt === 'string' &&
31
- typeof candidate.lastSeenAt === 'string'
39
+ typeof candidate.lastSeenAt === 'string' &&
40
+ (candidate.dockerImageId === undefined || typeof candidate.dockerImageId === 'string') &&
41
+ (candidate.dockerImageName === undefined || typeof candidate.dockerImageName === 'string') &&
42
+ (candidate.dockerImageLastSeenAt === undefined || typeof candidate.dockerImageLastSeenAt === 'string')
32
43
  }
33
44
 
34
45
  export function workspaceMetadataPath (context: WorkspaceContext): string {
@@ -45,13 +56,25 @@ export function readWorkspaceMetadataFile (path: string): WorkspaceMetadata {
45
56
  return parsed
46
57
  }
47
58
 
59
+ export function readWorkspaceMetadata (context: WorkspaceContext): WorkspaceMetadata | undefined {
60
+ const metadataPath = workspaceMetadataPath(context)
61
+
62
+ if (!existsSync(metadataPath)) {
63
+ return undefined
64
+ }
65
+
66
+ return readWorkspaceMetadataFile(metadataPath)
67
+ }
68
+
48
69
  export function writeWorkspaceMetadata (context: WorkspaceContext, sshAlias: string, now = new Date()): WorkspaceMetadata {
49
70
  const metadataPath = workspaceMetadataPath(context)
50
71
  const timestamp = now.toISOString()
51
72
  let firstSeenAt = timestamp
73
+ let existingMetadata: WorkspaceMetadata | undefined
52
74
 
53
75
  if (existsSync(metadataPath)) {
54
- firstSeenAt = readWorkspaceMetadataFile(metadataPath).firstSeenAt
76
+ existingMetadata = readWorkspaceMetadataFile(metadataPath)
77
+ firstSeenAt = existingMetadata.firstSeenAt
55
78
  }
56
79
 
57
80
  const metadata: WorkspaceMetadata = {
@@ -61,7 +84,10 @@ export function writeWorkspaceMetadata (context: WorkspaceContext, sshAlias: str
61
84
  workspaceBasename: context.workspaceBasename,
62
85
  sshAlias,
63
86
  firstSeenAt,
64
- lastSeenAt: timestamp
87
+ lastSeenAt: timestamp,
88
+ ...(existingMetadata?.dockerImageId === undefined ? {} : { dockerImageId: existingMetadata.dockerImageId }),
89
+ ...(existingMetadata?.dockerImageName === undefined ? {} : { dockerImageName: existingMetadata.dockerImageName }),
90
+ ...(existingMetadata?.dockerImageLastSeenAt === undefined ? {} : { dockerImageLastSeenAt: existingMetadata.dockerImageLastSeenAt })
65
91
  }
66
92
 
67
93
  mkdirSync(context.workspaceDataDir, { recursive: true })
@@ -69,6 +95,29 @@ export function writeWorkspaceMetadata (context: WorkspaceContext, sshAlias: str
69
95
  return metadata
70
96
  }
71
97
 
98
+ export function recordWorkspaceDockerImage (
99
+ context: WorkspaceContext,
100
+ image: WorkspaceDockerImageMetadata,
101
+ now = new Date()
102
+ ): WorkspaceMetadata | undefined {
103
+ const metadata = readWorkspaceMetadata(context)
104
+
105
+ if (metadata === undefined) {
106
+ return undefined
107
+ }
108
+
109
+ const nextMetadata: WorkspaceMetadata = {
110
+ ...metadata,
111
+ dockerImageId: image.id,
112
+ ...(image.name === undefined ? {} : { dockerImageName: image.name }),
113
+ dockerImageLastSeenAt: now.toISOString()
114
+ }
115
+
116
+ mkdirSync(context.workspaceDataDir, { recursive: true })
117
+ writeFileSync(workspaceMetadataPath(context), `${JSON.stringify(nextMetadata, null, 2)}\n`)
118
+ return nextMetadata
119
+ }
120
+
72
121
  export function listWorkspaceMetadata (dataRoot: string): WorkspaceMetadata[] {
73
122
  const workspacesDir = join(dataRoot, 'workspaces')
74
123
 
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { packageRootFromImportMeta } from './paths.ts'
5
+
6
+ interface PackageJson {
7
+ version?: unknown
8
+ }
9
+
10
+ export function readPackageVersion (packageRoot = packageRootFromImportMeta(import.meta.url)): string {
11
+ const packageJson = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as PackageJson
12
+
13
+ if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
14
+ throw new Error('Boxdown package.json is missing a valid version.')
15
+ }
16
+
17
+ return packageJson.version
18
+ }
package/src/paths.ts CHANGED
@@ -26,11 +26,24 @@ export interface WorkspaceContext {
26
26
  workspaceDataDir: string
27
27
  generatedConfigPath: string
28
28
  hostAgentsDir: string
29
+ hostCodexAuthPath: string
30
+ hostGitconfigPath: string
31
+ hostGitconfigSnapshotDir: string
32
+ hostGitconfigSnapshotPath: string
33
+ gitSigningStateDir: string
34
+ gitSigningPublicKeyPath: string
29
35
  sshKeyDir: string
30
36
  sshKeyPath: string
31
37
  sshPublicKeyPath: string
32
38
  sshPublicKeyRuntimeDir: string
33
39
  sshPublicKeyRuntimePath: string
40
+ workspaceLogPath: string
41
+ }
42
+
43
+ export interface WorkspaceContextIdentity {
44
+ workspaceFolder: string
45
+ workspaceBasename: string
46
+ workspaceId: string
34
47
  }
35
48
 
36
49
  export function packageRootFromImportMeta (importMetaUrl = import.meta.url): string {
@@ -83,23 +96,33 @@ export function defaultHostAgentsDir (env: NodeJS.ProcessEnv = process.env): str
83
96
  return join(env.HOME ?? homedir(), '.agents')
84
97
  }
85
98
 
86
- export function createWorkspaceContext (options: WorkspaceContextOptions = {}): WorkspaceContext {
99
+ export function defaultHostCodexAuthPath (env: NodeJS.ProcessEnv = process.env): string {
100
+ return join(env.HOME ?? homedir(), '.codex', 'auth.json')
101
+ }
102
+
103
+ export function defaultHostGitconfigPath (env: NodeJS.ProcessEnv = process.env): string {
104
+ return join(env.HOME ?? homedir(), '.gitconfig')
105
+ }
106
+
107
+ export function createWorkspaceContextFromIdentity (
108
+ identity: WorkspaceContextIdentity,
109
+ options: Omit<WorkspaceContextOptions, 'workspace' | 'cwd'> = {}
110
+ ): WorkspaceContext {
87
111
  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
112
  const packageRoot = options.packageRoot ?? packageRootFromImportMeta()
92
113
  const assetsDevcontainerDir = options.assetsDevcontainerDir ?? env.BOXDOWN_DEVCONTAINER_ASSETS_DIR ?? join(packageRoot, 'assets', 'devcontainer')
93
114
  const cacheRoot = defaultCacheRoot(env)
94
115
  const dataRoot = defaultDataRoot(env)
95
116
  const hostAgentsDir = defaultHostAgentsDir(env)
96
- const workspaceCacheDir = join(cacheRoot, 'workspaces', workspaceId)
97
- const workspaceDataDir = join(dataRoot, 'workspaces', workspaceId)
117
+ const hostCodexAuthPath = defaultHostCodexAuthPath(env)
118
+ const workspaceCacheDir = join(cacheRoot, 'workspaces', identity.workspaceId)
119
+ const workspaceDataDir = join(dataRoot, 'workspaces', identity.workspaceId)
120
+ const hostGitconfigSnapshotDir = join(workspaceDataDir, 'gitconfig')
98
121
 
99
122
  return {
100
- workspaceFolder,
101
- workspaceBasename,
102
- workspaceId,
123
+ workspaceFolder: identity.workspaceFolder,
124
+ workspaceBasename: identity.workspaceBasename,
125
+ workspaceId: identity.workspaceId,
103
126
  packageRoot,
104
127
  assetsDevcontainerDir,
105
128
  cacheRoot,
@@ -108,10 +131,27 @@ export function createWorkspaceContext (options: WorkspaceContextOptions = {}):
108
131
  workspaceDataDir,
109
132
  generatedConfigPath: join(workspaceCacheDir, 'devcontainer.json'),
110
133
  hostAgentsDir,
134
+ hostCodexAuthPath,
135
+ hostGitconfigPath: defaultHostGitconfigPath(env),
136
+ hostGitconfigSnapshotDir,
137
+ hostGitconfigSnapshotPath: join(hostGitconfigSnapshotDir, '.gitconfig'),
138
+ gitSigningStateDir: join(workspaceDataDir, 'git-signing'),
139
+ gitSigningPublicKeyPath: join(workspaceDataDir, 'git-signing', 'signing-key.pub'),
111
140
  sshKeyDir: join(workspaceDataDir, 'ssh'),
112
141
  sshKeyPath: join(workspaceDataDir, 'ssh', 'id_ed25519'),
113
142
  sshPublicKeyPath: join(workspaceDataDir, 'ssh', 'id_ed25519.pub'),
114
143
  sshPublicKeyRuntimeDir: join(workspaceDataDir, 'ssh-public'),
115
- sshPublicKeyRuntimePath: join(workspaceDataDir, 'ssh-public', 'id_ed25519.pub')
144
+ sshPublicKeyRuntimePath: join(workspaceDataDir, 'ssh-public', 'id_ed25519.pub'),
145
+ workspaceLogPath: join(workspaceDataDir, 'boxdown.log')
116
146
  }
117
147
  }
148
+
149
+ export function createWorkspaceContext (options: WorkspaceContextOptions = {}): WorkspaceContext {
150
+ const workspaceFolder = resolveWorkspaceFolder(options.workspace, options.cwd)
151
+
152
+ return createWorkspaceContextFromIdentity({
153
+ workspaceFolder,
154
+ workspaceBasename: basename(workspaceFolder),
155
+ workspaceId: workspaceIdFor(workspaceFolder)
156
+ }, options)
157
+ }
package/src/process.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { spawn } from 'node:child_process'
2
+ import { delimiter } from 'node:path'
3
+
4
+ import type { WorkspaceCommandLogger } from './logging.ts'
2
5
 
3
6
  export interface BufferedCommandOptions {
4
7
  cwd?: string
@@ -6,6 +9,9 @@ export interface BufferedCommandOptions {
6
9
  input?: string
7
10
  mirrorStdout?: 'stdout' | 'stderr' | false
8
11
  mirrorStderr?: 'stdout' | 'stderr' | false
12
+ logger?: WorkspaceCommandLogger
13
+ onStdout?: (chunk: Buffer) => void
14
+ onStderr?: (chunk: Buffer) => void
9
15
  }
10
16
 
11
17
  export interface CommandResult {
@@ -15,10 +21,58 @@ export interface CommandResult {
15
21
  }
16
22
 
17
23
  function mergedEnv (env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
18
- return {
24
+ const baseEnv = {
19
25
  ...process.env,
20
26
  ...(env ?? {})
21
27
  }
28
+
29
+ return {
30
+ ...baseEnv,
31
+ PATH: buildHostToolPath(baseEnv)
32
+ }
33
+ }
34
+
35
+ function uniquePathEntries (entries: readonly string[]): string[] {
36
+ const seen = new Set<string>()
37
+ const result: string[] = []
38
+
39
+ for (const entry of entries) {
40
+ if (entry.length === 0 || seen.has(entry)) {
41
+ continue
42
+ }
43
+
44
+ seen.add(entry)
45
+ result.push(entry)
46
+ }
47
+
48
+ return result
49
+ }
50
+
51
+ export function buildHostToolPath (env: NodeJS.ProcessEnv = process.env): string {
52
+ const home = env.HOME
53
+ const existingPath = env.PATH ?? ''
54
+ const configuredPrefix = env.BOXDOWN_HOST_PATH_PREFIX?.split(delimiter) ?? []
55
+ const guiFriendlyPrefix = [
56
+ ...(home === undefined ? [] : [
57
+ `${home}/.local/bin`,
58
+ `${home}/.docker/bin`
59
+ ]),
60
+ '/opt/homebrew/bin',
61
+ '/opt/homebrew/sbin',
62
+ '/usr/local/bin',
63
+ '/usr/local/sbin',
64
+ '/Applications/Docker.app/Contents/Resources/bin',
65
+ '/usr/bin',
66
+ '/bin',
67
+ '/usr/sbin',
68
+ '/sbin'
69
+ ]
70
+
71
+ return uniquePathEntries([
72
+ ...configuredPrefix,
73
+ ...existingPath.split(delimiter),
74
+ ...guiFriendlyPrefix
75
+ ]).join(delimiter)
22
76
  }
23
77
 
24
78
  function writeChunk (target: 'stdout' | 'stderr' | false, chunk: Buffer): void {
@@ -31,6 +85,7 @@ function writeChunk (target: 'stdout' | 'stderr' | false, chunk: Buffer): void {
31
85
 
32
86
  export function runBuffered (command: string, args: string[], options: BufferedCommandOptions = {}): Promise<CommandResult> {
33
87
  return new Promise((resolve) => {
88
+ const loggedCommand = options.logger?.startCommand(command, args, { cwd: options.cwd })
34
89
  const child = spawn(command, args, {
35
90
  cwd: options.cwd,
36
91
  env: mergedEnv(options.env),
@@ -42,15 +97,28 @@ export function runBuffered (command: string, args: string[], options: BufferedC
42
97
 
43
98
  child.stdout.on('data', (chunk: Buffer) => {
44
99
  stdoutChunks.push(chunk)
100
+ loggedCommand?.stream('stdout', chunk)
101
+ options.onStdout?.(chunk)
45
102
  writeChunk(options.mirrorStdout ?? 'stdout', chunk)
46
103
  })
47
104
 
48
105
  child.stderr.on('data', (chunk: Buffer) => {
49
106
  stderrChunks.push(chunk)
107
+ loggedCommand?.stream('stderr', chunk)
108
+ options.onStderr?.(chunk)
50
109
  writeChunk(options.mirrorStderr ?? 'stderr', chunk)
51
110
  })
52
111
 
112
+ let resolved = false
113
+
53
114
  child.on('error', (error) => {
115
+ if (resolved) {
116
+ return
117
+ }
118
+
119
+ resolved = true
120
+ loggedCommand?.error(error)
121
+ loggedCommand?.finish(127)
54
122
  resolve({
55
123
  code: 127,
56
124
  stdout: Buffer.concat(stdoutChunks).toString('utf8'),
@@ -59,6 +127,12 @@ export function runBuffered (command: string, args: string[], options: BufferedC
59
127
  })
60
128
 
61
129
  child.on('close', (code) => {
130
+ if (resolved) {
131
+ return
132
+ }
133
+
134
+ resolved = true
135
+ loggedCommand?.finish(code ?? 1)
62
136
  resolve({
63
137
  code: code ?? 1,
64
138
  stdout: Buffer.concat(stdoutChunks).toString('utf8'),
@@ -74,8 +148,9 @@ export function runBuffered (command: string, args: string[], options: BufferedC
74
148
  })
75
149
  }
76
150
 
77
- export function runInteractive (command: string, args: string[], options: Pick<BufferedCommandOptions, 'cwd' | 'env'> = {}): Promise<number> {
151
+ export function runInteractive (command: string, args: string[], options: Pick<BufferedCommandOptions, 'cwd' | 'env' | 'logger'> = {}): Promise<number> {
78
152
  return new Promise((resolve) => {
153
+ const loggedCommand = options.logger?.startCommand(command, args, { cwd: options.cwd })
79
154
  const child = spawn(command, args, {
80
155
  cwd: options.cwd,
81
156
  env: mergedEnv(options.env),
@@ -83,11 +158,14 @@ export function runInteractive (command: string, args: string[], options: Pick<B
83
158
  })
84
159
 
85
160
  child.on('error', (error) => {
161
+ loggedCommand?.error(error)
162
+ loggedCommand?.finish(127)
86
163
  process.stderr.write(`${error.message}\n`)
87
164
  resolve(127)
88
165
  })
89
166
 
90
167
  child.on('close', (code) => {
168
+ loggedCommand?.finish(code ?? 1)
91
169
  resolve(code ?? 1)
92
170
  })
93
171
  })