boxdown 1.0.0 → 1.2.1

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 (76) hide show
  1. package/README.md +135 -12
  2. package/assets/devcontainer/README.md +65 -21
  3. package/assets/devcontainer/devcontainer.json +27 -15
  4. package/assets/devcontainer/hooks/initialize.sh +76 -22
  5. package/assets/devcontainer/hooks/post-create.sh +70 -12
  6. package/assets/devcontainer/hooks/post-start.sh +20 -13
  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 +109 -0
  13. package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
  14. package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
  15. package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
  16. package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
  17. package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
  18. package/dist/bin/cli.cjs +1 -1
  19. package/dist/bin/cli.mjs +1 -1
  20. package/dist/main-BDgyf2t5.cjs +5758 -0
  21. package/dist/main-J4_2Up3o.mjs +5718 -0
  22. package/dist/main-J4_2Up3o.mjs.map +1 -0
  23. package/dist/main.cjs +5 -2
  24. package/dist/main.d.cts +501 -4
  25. package/dist/main.d.cts.map +1 -1
  26. package/dist/main.d.mts +501 -4
  27. package/dist/main.d.mts.map +1 -1
  28. package/dist/main.mjs +2 -2
  29. package/docs/README.md +1 -0
  30. package/docs/architecture.md +32 -0
  31. package/docs/development.md +13 -6
  32. package/docs/features/README.md +2 -0
  33. package/docs/features/commit-signing.md +94 -0
  34. package/docs/features/generated-config-and-state.md +73 -5
  35. package/docs/features/github-auth-refresh.md +15 -2
  36. package/docs/features/lifecycle.md +103 -11
  37. package/docs/features/setup.md +66 -0
  38. package/docs/features/ssh-config-and-proxy.md +228 -7
  39. package/docs/features/start-and-shell.md +45 -5
  40. package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
  41. package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
  42. package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
  43. package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
  44. package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +416 -0
  45. package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
  46. package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
  47. package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
  48. package/docs/testing.md +35 -2
  49. package/docs/todo.md +2 -2
  50. package/package.json +1 -1
  51. package/src/claude-app-config.ts +304 -0
  52. package/src/cli-style.ts +43 -0
  53. package/src/codex-app-config.ts +656 -0
  54. package/src/config.ts +80 -10
  55. package/src/constants.ts +12 -0
  56. package/src/devcontainer.ts +511 -64
  57. package/src/doctor.ts +292 -30
  58. package/src/git-signing.ts +267 -0
  59. package/src/interactive-prompts.ts +692 -0
  60. package/src/list.ts +16 -2
  61. package/src/logging.ts +164 -0
  62. package/src/main.ts +1214 -63
  63. package/src/metadata.ts +52 -3
  64. package/src/package-info.ts +18 -0
  65. package/src/paths.ts +71 -11
  66. package/src/process.ts +80 -2
  67. package/src/progress.ts +593 -0
  68. package/src/purge.ts +216 -0
  69. package/src/shell.ts +25 -0
  70. package/src/ssh-config.ts +134 -16
  71. package/src/ssh-install-targets.ts +111 -0
  72. package/src/ssh-key.ts +53 -13
  73. package/src/status.ts +5 -0
  74. package/dist/main-BuEptwlL.cjs +0 -1707
  75. package/dist/main-ZFTrSVgt.mjs +0 -1685
  76. package/dist/main-ZFTrSVgt.mjs.map +0 -1
package/src/purge.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { existsSync, rmSync } from 'node:fs'
2
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
3
+
4
+ import { claudeSshConfigEntryForWorkspace, uninstallClaudeSshConfigHost } from './claude-app-config.ts'
5
+ import { codexProjectEntryForWorkspace, legacyCodexRemotePathForWorkspace, uninstallCodexAppConfigProject, uninstallCodexGlobalStateProject } from './codex-app-config.ts'
6
+ import { findWorkspaceContainer, inspectContainerImage, removeContainerById, removeDockerImage } from './devcontainer.ts'
7
+ import type { WorkspaceCommandLogger } from './logging.ts'
8
+ import { readWorkspaceMetadata, type WorkspaceMetadata } from './metadata.ts'
9
+ import type { WorkspaceContext } from './paths.ts'
10
+ import { defaultSshAlias, uninstallSshConfig } from './ssh-config.ts'
11
+ import type { ContainerSummary } from './status.ts'
12
+
13
+ export interface PurgeOptions {
14
+ alias?: string
15
+ logger?: WorkspaceCommandLogger
16
+ }
17
+
18
+ function errorMessage (error: unknown): string {
19
+ return error instanceof Error ? error.message : String(error)
20
+ }
21
+
22
+ async function runPurgeStep (label: string, action: () => Promise<void> | void): Promise<boolean> {
23
+ try {
24
+ await action()
25
+ return false
26
+ } catch (error) {
27
+ process.stderr.write(`Failed ${label}: ${errorMessage(error)}\n`)
28
+ return true
29
+ }
30
+ }
31
+
32
+ function uniqueAliases (aliases: Array<string | undefined>): string[] {
33
+ return [...new Set(aliases.filter((alias): alias is string => alias !== undefined))]
34
+ }
35
+
36
+ function pathIsInsideOrSame (parent: string, candidate: string): boolean {
37
+ const path = relative(parent, candidate)
38
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path))
39
+ }
40
+
41
+ function assertSafeWorkspaceStateDir (
42
+ context: WorkspaceContext,
43
+ path: string,
44
+ root: string
45
+ ): void {
46
+ const resolvedPath = resolve(path)
47
+ const resolvedRoot = resolve(root)
48
+ const expectedPath = resolve(join(resolvedRoot, 'workspaces', context.workspaceId))
49
+
50
+ if (resolvedPath !== expectedPath) {
51
+ throw new Error(`Refusing to purge unexpected state path: ${path}`)
52
+ }
53
+
54
+ if (resolvedRoot === parse(resolvedRoot).root) {
55
+ throw new Error(`Refusing to purge state under filesystem root: ${path}`)
56
+ }
57
+
58
+ if (basename(resolvedPath) !== context.workspaceId || basename(dirname(resolvedPath)) !== 'workspaces') {
59
+ throw new Error(`Refusing to purge non-workspace state path: ${path}`)
60
+ }
61
+
62
+ if (pathIsInsideOrSame(resolve(context.workspaceFolder), resolvedPath)) {
63
+ throw new Error(`Refusing to purge state inside the workspace repository: ${path}`)
64
+ }
65
+
66
+ if (!pathIsInsideOrSame(resolvedRoot, resolvedPath)) {
67
+ throw new Error(`Refusing to purge state outside its root: ${path}`)
68
+ }
69
+ }
70
+
71
+ function removeWorkspaceStateDir (
72
+ context: WorkspaceContext,
73
+ label: string,
74
+ path: string,
75
+ root: string
76
+ ): void {
77
+ assertSafeWorkspaceStateDir(context, path, root)
78
+
79
+ if (!existsSync(path)) {
80
+ process.stdout.write(`${label} absent: ${path}\n`)
81
+ return
82
+ }
83
+
84
+ rmSync(path, { recursive: true, force: true })
85
+ process.stdout.write(`Removed ${label}: ${path}\n`)
86
+ }
87
+
88
+ export function removeWorkspaceRuntimeState (context: WorkspaceContext): void {
89
+ removeWorkspaceStateDir(context, 'workspace runtime directory', context.workspaceRuntimeDir, context.runtimeRoot)
90
+ }
91
+
92
+ async function purgeAliasIntegrations (context: WorkspaceContext, alias: string): Promise<boolean> {
93
+ let failed = false
94
+
95
+ failed = await runPurgeStep(`SSH alias ${alias}`, () => {
96
+ const changed = uninstallSshConfig(alias, { quiet: true })
97
+ process.stdout.write(changed
98
+ ? `Removed SSH alias: ${alias}\n`
99
+ : `SSH alias absent: ${alias}\n`)
100
+ }) || failed
101
+
102
+ const entry = codexProjectEntryForWorkspace(context, alias)
103
+ const legacyRemotePath = legacyCodexRemotePathForWorkspace(context)
104
+
105
+ failed = await runPurgeStep(`Codex app config for ${alias}`, () => {
106
+ const result = uninstallCodexAppConfigProject(entry, {
107
+ additionalRemotePaths: [legacyRemotePath]
108
+ })
109
+ process.stdout.write(result.changed
110
+ ? `Removed Codex remote project: ${entry.label} (${alias})\n`
111
+ : `Codex remote project absent: ${entry.label} (${alias})\n`)
112
+ }) || failed
113
+
114
+ failed = await runPurgeStep(`Codex app state for ${alias}`, () => {
115
+ const result = uninstallCodexGlobalStateProject(entry, {
116
+ additionalRemotePaths: [legacyRemotePath]
117
+ })
118
+ process.stdout.write(result.changed
119
+ ? `Removed Codex sidebar state: ${entry.label} (${alias})\n`
120
+ : `Codex sidebar state absent: ${entry.label} (${alias})\n`)
121
+ }) || failed
122
+
123
+ const claudeEntry = claudeSshConfigEntryForWorkspace(context, alias)
124
+
125
+ failed = await runPurgeStep(`Claude SSH config for ${alias}`, () => {
126
+ const result = uninstallClaudeSshConfigHost(claudeEntry)
127
+ process.stdout.write(result.changed
128
+ ? `Removed Claude SSH remote: ${claudeEntry.name} (${alias})\n`
129
+ : `Claude SSH remote absent: ${claudeEntry.name} (${alias})\n`)
130
+ }) || failed
131
+
132
+ return failed
133
+ }
134
+
135
+ export async function purgeWorkspace (context: WorkspaceContext, options: PurgeOptions = {}): Promise<number> {
136
+ let failed = false
137
+ let metadata: WorkspaceMetadata | undefined
138
+ let container: ContainerSummary | undefined
139
+ let dockerImageId: string | undefined
140
+
141
+ process.stdout.write(`Purging Boxdown workspace: ${context.workspaceFolder}\n`)
142
+
143
+ failed = await runPurgeStep('workspace metadata snapshot', () => {
144
+ metadata = readWorkspaceMetadata(context)
145
+ dockerImageId = metadata?.dockerImageId
146
+ process.stdout.write(metadata === undefined
147
+ ? `Workspace metadata absent: ${context.workspaceDataDir}\n`
148
+ : `Snapshot workspace metadata: ${context.workspaceDataDir}\n`)
149
+ }) || failed
150
+
151
+ for (const alias of uniqueAliases([
152
+ options.alias,
153
+ metadata?.sshAlias,
154
+ defaultSshAlias(context.workspaceBasename)
155
+ ])) {
156
+ failed = await purgeAliasIntegrations(context, alias) || failed
157
+ }
158
+
159
+ failed = await runPurgeStep('workspace devcontainer lookup', async () => {
160
+ container = await findWorkspaceContainer(context, { logger: options.logger })
161
+
162
+ if (container === undefined) {
163
+ process.stdout.write(`Devcontainer absent: ${context.workspaceFolder}\n`)
164
+ } else {
165
+ process.stdout.write(`Found devcontainer: ${container.id}\n`)
166
+ }
167
+ }) || failed
168
+
169
+ const currentContainer = container
170
+
171
+ if (currentContainer !== undefined) {
172
+ failed = await runPurgeStep(`Docker image inspect for ${currentContainer.id}`, async () => {
173
+ const image = await inspectContainerImage(currentContainer.id, { logger: options.logger })
174
+
175
+ if (image === undefined) {
176
+ process.stdout.write(`Docker image not recorded by container inspect: ${currentContainer.id}\n`)
177
+ return
178
+ }
179
+
180
+ dockerImageId = image.id
181
+ process.stdout.write(image.name === undefined
182
+ ? `Resolved Docker image: ${image.id}\n`
183
+ : `Resolved Docker image: ${image.id} (${image.name})\n`)
184
+ }) || failed
185
+
186
+ failed = await runPurgeStep(`devcontainer ${currentContainer.id}`, async () => {
187
+ await removeContainerById(currentContainer.id, { volumes: true, logger: options.logger })
188
+ process.stdout.write(`Removed devcontainer with volumes: ${currentContainer.id}\n`)
189
+ }) || failed
190
+ }
191
+
192
+ if (dockerImageId === undefined) {
193
+ process.stdout.write('Docker image absent: no inspected or recorded image ID\n')
194
+ } else {
195
+ const removedImageId = dockerImageId
196
+ failed = await runPurgeStep(`Docker image ${removedImageId}`, async () => {
197
+ await removeDockerImage(removedImageId, { logger: options.logger })
198
+ }) || failed
199
+ }
200
+
201
+ failed = await runPurgeStep('workspace runtime directory', () => {
202
+ removeWorkspaceRuntimeState(context)
203
+ }) || failed
204
+
205
+ failed = await runPurgeStep('workspace cache directory', () => {
206
+ removeWorkspaceStateDir(context, 'workspace cache', context.workspaceCacheDir, context.cacheRoot)
207
+ }) || failed
208
+
209
+ failed = await runPurgeStep('workspace data directory', () => {
210
+ options.logger?.boxdown(`Removing workspace data: ${context.workspaceDataDir}\n`)
211
+ options.logger?.disable()
212
+ removeWorkspaceStateDir(context, 'workspace data', context.workspaceDataDir, context.dataRoot)
213
+ }) || failed
214
+
215
+ return failed ? 1 : 0
216
+ }
package/src/shell.ts CHANGED
@@ -45,16 +45,41 @@ function interactiveTtySetupScript (): string {
45
45
  ].join('\n')
46
46
  }
47
47
 
48
+ function interactiveTermSetupScript (): string {
49
+ return [
50
+ 'if ! infocmp "${TERM:-xterm-256color}" >/dev/null 2>&1; then',
51
+ ' export TERM=xterm-256color',
52
+ 'fi',
53
+ 'export COLORTERM="${COLORTERM:-truecolor}"'
54
+ ].join('\n')
55
+ }
56
+
57
+ function interactiveAgentPathScript (): string {
58
+ return [
59
+ 'codex_home="${CODEX_HOME:-${HOME}/.codex}"',
60
+ 'export PATH="${HOME}/.local/bin:${HOME}/.opencode/bin:${codex_home}/packages/standalone/current/bin:${PATH}"'
61
+ ].join('\n')
62
+ }
63
+
64
+ function runtimeSecretEnvironmentScript (): string {
65
+ return 'source "${BASH_ENV:-/opt/boxdown/devcontainer/utils/secret-env-bootstrap.sh}"'
66
+ }
67
+
48
68
  export function interactiveShellScript (): string {
49
69
  return [
70
+ interactiveTermSetupScript(),
50
71
  interactiveTtySetupScript(),
72
+ runtimeSecretEnvironmentScript(),
51
73
  'exec bash -i'
52
74
  ].join('\n')
53
75
  }
54
76
 
55
77
  export function interactiveCommandScript (): string {
56
78
  return [
79
+ interactiveTermSetupScript(),
57
80
  interactiveTtySetupScript(),
81
+ interactiveAgentPathScript(),
82
+ runtimeSecretEnvironmentScript(),
58
83
  'exec "$@"'
59
84
  ].join('\n')
60
85
  }
package/src/ssh-config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
 
4
4
  import type { WorkspaceContext } from './paths.ts'
@@ -45,36 +45,121 @@ export function buildSshConfigBlock (context: WorkspaceContext, alias: string):
45
45
  ].join('\n')
46
46
  }
47
47
 
48
- export function replaceSshConfigBlock (existingConfig: string, alias: string, block: string): string {
49
- const begin = `# BEGIN ${alias} boxdown devcontainer ssh`
50
- const end = `# END ${alias} boxdown devcontainer ssh`
48
+ interface SshConfigMarkerSet {
49
+ begin: string
50
+ end: string
51
+ }
52
+
53
+ function managedSshConfigMarkerSets (alias: string): SshConfigMarkerSet[] {
54
+ return [
55
+ {
56
+ begin: `# BEGIN ${alias} boxdown devcontainer ssh`,
57
+ end: `# END ${alias} boxdown devcontainer ssh`
58
+ },
59
+ {
60
+ begin: `# BEGIN ${alias} devcontainer ssh`,
61
+ end: `# END ${alias} devcontainer ssh`
62
+ }
63
+ ]
64
+ }
65
+
66
+ function malformedSshConfigBlockError (alias: string, markers: SshConfigMarkerSet): Error {
67
+ return new Error(`Refusing to update SSH config for ${alias}: found "${markers.begin}" without matching "${markers.end}". Repair the config manually before running Boxdown again.`)
68
+ }
69
+
70
+ function overlappingSshConfigBlockError (alias: string, markers: SshConfigMarkerSet, marker: string): Error {
71
+ return new Error(`Refusing to update SSH config for ${alias}: found overlapping managed SSH config marker "${marker}" before matching "${markers.end}". Repair the config manually before running Boxdown again.`)
72
+ }
73
+
74
+ function isManagedSshConfigMarker (line: string): boolean {
75
+ return /^# (?:BEGIN|END) [A-Za-z0-9_.-]+ (?:boxdown )?devcontainer ssh$/u.test(line)
76
+ }
77
+
78
+ function stripManagedSshConfigBlocks (existingConfig: string, alias: string): { lines: string[], removed: boolean } {
79
+ const markerSets = managedSshConfigMarkerSets(alias)
51
80
  const lines = existingConfig.split(/\r?\n/)
52
81
  const nextLines: string[] = []
53
- let skipping = false
82
+ let removed = false
83
+
84
+ for (let index = 0; index < lines.length; index++) {
85
+ const line = lines[index]
86
+ const markers = markerSets.find((candidate) => candidate.begin === line)
54
87
 
55
- for (const line of lines) {
56
- if (line === begin) {
57
- skipping = true
88
+ if (markers === undefined) {
89
+ nextLines.push(line ?? '')
58
90
  continue
59
91
  }
60
92
 
61
- if (line === end) {
62
- skipping = false
63
- continue
93
+ let endIndex = -1
94
+ for (let candidateIndex = index + 1; candidateIndex < lines.length; candidateIndex++) {
95
+ const candidate = lines[candidateIndex] ?? ''
96
+
97
+ if (candidate === markers.end) {
98
+ endIndex = candidateIndex
99
+ break
100
+ }
101
+
102
+ if (isManagedSshConfigMarker(candidate)) {
103
+ throw overlappingSshConfigBlockError(alias, markers, candidate)
104
+ }
64
105
  }
65
106
 
66
- if (!skipping) {
67
- nextLines.push(line)
107
+ if (endIndex === -1) {
108
+ throw malformedSshConfigBlockError(alias, markers)
68
109
  }
110
+
111
+ removed = true
112
+ index = endIndex
113
+ }
114
+
115
+ return { lines: nextLines, removed }
116
+ }
117
+
118
+ function trimTrailingBlankLines (lines: string[]): void {
119
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
120
+ lines.pop()
69
121
  }
122
+ }
123
+
124
+ function trimLeadingBlankLinesBeforeManagedBlock (lines: string[]): void {
125
+ const firstContentIndex = lines.findIndex((line) => line !== '')
70
126
 
71
- while (nextLines.length > 0 && nextLines[nextLines.length - 1] === '') {
72
- nextLines.pop()
127
+ if (firstContentIndex > 0 && isManagedSshConfigMarker(lines[firstContentIndex] ?? '')) {
128
+ lines.splice(0, firstContentIndex)
73
129
  }
130
+ }
131
+
132
+ function writeFileAtomic (path: string, contents: string, mode: number): void {
133
+ const destinationPath = existsSync(path) ? realpathSync(path) : path
134
+ const tmpPath = `${destinationPath}.tmp-${process.pid}-${Date.now()}`
135
+
136
+ writeFileSync(tmpPath, contents, { mode })
137
+ chmodSync(tmpPath, mode)
138
+ renameSync(tmpPath, destinationPath)
139
+ }
140
+
141
+ export function replaceSshConfigBlock (existingConfig: string, alias: string, block: string): string {
142
+ const { lines: nextLines } = stripManagedSshConfigBlocks(existingConfig, alias)
143
+
144
+ trimTrailingBlankLines(nextLines)
145
+ trimLeadingBlankLinesBeforeManagedBlock(nextLines)
74
146
 
75
147
  return `${nextLines.join('\n')}${nextLines.length > 0 ? '\n\n' : ''}${block}`
76
148
  }
77
149
 
150
+ export function removeSshConfigBlock (existingConfig: string, alias: string): string {
151
+ const { lines: nextLines, removed } = stripManagedSshConfigBlocks(existingConfig, alias)
152
+
153
+ if (!removed) {
154
+ return existingConfig
155
+ }
156
+
157
+ trimTrailingBlankLines(nextLines)
158
+ trimLeadingBlankLinesBeforeManagedBlock(nextLines)
159
+
160
+ return nextLines.length > 0 ? `${nextLines.join('\n')}\n` : ''
161
+ }
162
+
78
163
  export async function installSshConfig (context: WorkspaceContext, alias: string, options: { quiet?: boolean, configPath?: string } = {}): Promise<void> {
79
164
  validateSshAlias(alias)
80
165
  await ensureHostSshKey(context, options.quiet ?? false)
@@ -95,7 +180,7 @@ export async function installSshConfig (context: WorkspaceContext, alias: string
95
180
  const nextConfig = replaceSshConfigBlock(existingConfig, alias, block)
96
181
 
97
182
  if (nextConfig !== existingConfig) {
98
- writeFileSync(sshConfigPath, nextConfig)
183
+ writeFileAtomic(sshConfigPath, nextConfig, 0o600)
99
184
  if (!options.quiet) {
100
185
  process.stdout.write(`Installed SSH alias: ${alias}\n`)
101
186
  }
@@ -111,3 +196,36 @@ export async function installSshConfig (context: WorkspaceContext, alias: string
111
196
  process.stdout.write(`Validate with:\n ssh ${alias} 'whoami && pwd'\n`)
112
197
  }
113
198
  }
199
+
200
+ export function uninstallSshConfig (alias: string, options: { quiet?: boolean, configPath?: string } = {}): boolean {
201
+ validateSshAlias(alias)
202
+
203
+ const sshConfigPath = options.configPath ?? defaultSshConfigPath()
204
+
205
+ if (!existsSync(sshConfigPath)) {
206
+ if (!options.quiet) {
207
+ process.stdout.write(`SSH alias not installed: ${alias}\n`)
208
+ process.stdout.write(`SSH config: ${sshConfigPath}\n`)
209
+ }
210
+
211
+ return false
212
+ }
213
+
214
+ const existingConfig = readFileSync(sshConfigPath, 'utf8')
215
+ const nextConfig = removeSshConfigBlock(existingConfig, alias)
216
+ const changed = nextConfig !== existingConfig
217
+
218
+ if (changed) {
219
+ writeFileAtomic(sshConfigPath, nextConfig, 0o600)
220
+ chmodSync(sshConfigPath, 0o600)
221
+ }
222
+
223
+ if (!options.quiet) {
224
+ process.stdout.write(changed
225
+ ? `Uninstalled SSH alias: ${alias}\n`
226
+ : `SSH alias not installed: ${alias}\n`)
227
+ process.stdout.write(`SSH config: ${sshConfigPath}\n`)
228
+ }
229
+
230
+ return changed
231
+ }
@@ -0,0 +1,111 @@
1
+ import { claudeSshConfigEntryForWorkspace, installClaudeSshConfigHost } from './claude-app-config.ts'
2
+ import { codexProjectEntryForWorkspace, installCodexAppConfigProject, installCodexGlobalStateProject, legacyCodexRemotePathForWorkspace } from './codex-app-config.ts'
3
+ import type { WorkspaceContext } from './paths.ts'
4
+
5
+ export type SshConfigInstallTarget = 'codex' | 'claude'
6
+
7
+ export interface SshInstallTargetDefinition {
8
+ value: SshConfigInstallTarget
9
+ label: string
10
+ description: string
11
+ flag: string
12
+ install: (context: WorkspaceContext, alias: string, options?: SshInstallTargetInstallOptions) => Promise<void> | void
13
+ }
14
+
15
+ export interface SshInstallTargetInstallOptions {
16
+ quiet?: boolean
17
+ }
18
+
19
+ function installCodexTarget (context: WorkspaceContext, alias: string, options: SshInstallTargetInstallOptions = {}): void {
20
+ const entry = codexProjectEntryForWorkspace(context, alias)
21
+ const legacyRemotePath = legacyCodexRemotePathForWorkspace(context)
22
+ const result = installCodexAppConfigProject(entry, { legacyRemotePaths: [legacyRemotePath] })
23
+ const stateResult = installCodexGlobalStateProject(entry, { legacyRemotePaths: [legacyRemotePath] })
24
+
25
+ if (options.quiet === true) {
26
+ return
27
+ }
28
+
29
+ process.stdout.write(`\nCodex app config: ${result.configPath}\n`)
30
+ process.stdout.write(result.changed
31
+ ? `Installed Codex remote project: ${entry.label} (${entry.remotePath})\n`
32
+ : `Codex remote project already up to date: ${entry.label} (${entry.remotePath})\n`)
33
+
34
+ if (result.backupPath !== undefined) {
35
+ process.stdout.write(`Codex app config backup: ${result.backupPath}\n`)
36
+ }
37
+
38
+ if (stateResult.backupPath !== undefined) {
39
+ process.stdout.write(`Codex app state backup: ${stateResult.backupPath}\n`)
40
+ }
41
+
42
+ process.stdout.write('Restart Codex to apply the remote project entry.\n')
43
+ }
44
+
45
+ function installClaudeTarget (context: WorkspaceContext, alias: string, options: SshInstallTargetInstallOptions = {}): void {
46
+ const entry = claudeSshConfigEntryForWorkspace(context, alias)
47
+ const result = installClaudeSshConfigHost(entry)
48
+
49
+ if (options.quiet === true) {
50
+ return
51
+ }
52
+
53
+ process.stdout.write(`\nClaude SSH config: ${result.configPath}\n`)
54
+ process.stdout.write(result.changed
55
+ ? `Installed Claude SSH remote: ${entry.name} (${entry.sshHost})\n`
56
+ : `Claude SSH remote already up to date: ${entry.name} (${entry.sshHost})\n`)
57
+
58
+ if (result.backupPath !== undefined) {
59
+ process.stdout.write(`Claude SSH config backup: ${result.backupPath}\n`)
60
+ }
61
+
62
+ process.stdout.write('Restart Claude to apply the SSH remote entry.\n')
63
+ }
64
+
65
+ export const SSH_INSTALL_TARGETS: readonly SshInstallTargetDefinition[] = [
66
+ {
67
+ value: 'codex',
68
+ label: 'Codex',
69
+ description: 'Register this SSH alias as a Codex app remote project.',
70
+ flag: '--target codex',
71
+ install: installCodexTarget
72
+ },
73
+ {
74
+ value: 'claude',
75
+ label: 'Claude',
76
+ description: 'Register this SSH alias as a Claude app SSH remote.',
77
+ flag: '--target claude',
78
+ install: installClaudeTarget
79
+ }
80
+ ]
81
+
82
+ export function supportedSshInstallTargetsText (): string {
83
+ return SSH_INSTALL_TARGETS.map((target) => target.value).join(', ')
84
+ }
85
+
86
+ export function sshInstallTargetFlagHintsText (): string {
87
+ return SSH_INSTALL_TARGETS.map((target) => target.flag).join(' ')
88
+ }
89
+
90
+ export function isSshConfigInstallTarget (value: string): value is SshConfigInstallTarget {
91
+ return SSH_INSTALL_TARGETS.some((target) => target.value === value)
92
+ }
93
+
94
+ export function dedupeSshInstallTargets (targets: readonly SshConfigInstallTarget[]): SshConfigInstallTarget[] {
95
+ return [...new Set(targets)]
96
+ }
97
+
98
+ export async function installSshInstallTarget (
99
+ context: WorkspaceContext,
100
+ alias: string,
101
+ targetValue: SshConfigInstallTarget,
102
+ options: SshInstallTargetInstallOptions = {}
103
+ ): Promise<void> {
104
+ const target = SSH_INSTALL_TARGETS.find((candidate) => candidate.value === targetValue)
105
+
106
+ if (target === undefined) {
107
+ throw new Error(`Unsupported ssh install target: ${targetValue}`)
108
+ }
109
+
110
+ await target.install(context, alias, options)
111
+ }
package/src/ssh-key.ts CHANGED
@@ -2,16 +2,28 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
2
2
 
3
3
  import type { WorkspaceContext } from './paths.ts'
4
4
  import { runBuffered } from './process.ts'
5
+ import { assertProgressCommandSucceeded, type ProgressReporter, runProgressCommand } from './progress.ts'
6
+
7
+ export interface EnsureHostSshKeyOptions {
8
+ quiet?: boolean
9
+ progress?: ProgressReporter
10
+ }
11
+
12
+ export async function ensureHostSshKey (context: WorkspaceContext, options: boolean | EnsureHostSshKeyOptions = false): Promise<void> {
13
+ const quiet = typeof options === 'boolean' ? options : options.quiet ?? false
14
+ const progress = typeof options === 'boolean' ? undefined : options.progress
15
+ const checklistActive = progress?.isChecklistActive() ?? false
5
16
 
6
- export async function ensureHostSshKey (context: WorkspaceContext, quiet = false): Promise<void> {
7
17
  mkdirSync(context.sshKeyDir, { recursive: true, mode: 0o700 })
8
18
 
9
19
  if (!existsSync(context.sshKeyPath)) {
10
- if (!quiet) {
20
+ if (progress !== undefined && !checklistActive) {
21
+ progress.detail(context.sshKeyPath)
22
+ } else if (progress === undefined && !quiet) {
11
23
  process.stderr.write(`Generating Boxdown SSH identity: ${context.sshKeyPath}\n`)
12
24
  }
13
25
 
14
- const result = await runBuffered('ssh-keygen', [
26
+ const args = [
15
27
  '-t',
16
28
  'ed25519',
17
29
  '-f',
@@ -20,26 +32,54 @@ export async function ensureHostSshKey (context: WorkspaceContext, quiet = false
20
32
  '',
21
33
  '-C',
22
34
  `${context.workspaceBasename}-devcontainer`
23
- ], {
24
- mirrorStdout: quiet ? false : 'stderr',
25
- mirrorStderr: 'stderr'
26
- })
35
+ ]
36
+ const result = progress === undefined
37
+ ? await runBuffered('ssh-keygen', args, {
38
+ mirrorStdout: quiet ? false : 'stderr',
39
+ mirrorStderr: 'stderr'
40
+ })
41
+ : await runProgressCommand('ssh-keygen create identity', 'ssh-keygen', args, {
42
+ progress,
43
+ spinnerLabel: checklistActive ? undefined : 'Generating Boxdown SSH identity',
44
+ verboseStdout: 'stderr',
45
+ verboseStderr: 'stderr'
46
+ })
27
47
 
28
- if (result.code !== 0) {
48
+ if (progress === undefined && result.code !== 0) {
29
49
  throw new Error(`ssh-keygen failed while creating ${context.sshKeyPath}`)
30
50
  }
51
+
52
+ if (progress !== undefined) {
53
+ assertProgressCommandSucceeded('ssh-keygen create identity', result, `ssh-keygen failed while creating ${context.sshKeyPath}`)
54
+ }
31
55
  }
32
56
 
33
57
  if (!existsSync(context.sshPublicKeyPath)) {
34
- const result = await runBuffered('ssh-keygen', ['-y', '-f', context.sshKeyPath], {
35
- mirrorStdout: false,
36
- mirrorStderr: 'stderr'
37
- })
58
+ if (progress !== undefined && !checklistActive) {
59
+ progress.detail(context.sshPublicKeyPath)
60
+ }
61
+
62
+ const args = ['-y', '-f', context.sshKeyPath]
63
+ const result = progress === undefined
64
+ ? await runBuffered('ssh-keygen', args, {
65
+ mirrorStdout: false,
66
+ mirrorStderr: 'stderr'
67
+ })
68
+ : await runProgressCommand('ssh-keygen derive public key', 'ssh-keygen', args, {
69
+ progress,
70
+ spinnerLabel: checklistActive ? undefined : 'Writing Boxdown SSH public key',
71
+ verboseStdout: false,
72
+ verboseStderr: 'stderr'
73
+ })
38
74
 
39
- if (result.code !== 0) {
75
+ if (progress === undefined && result.code !== 0) {
40
76
  throw new Error(`ssh-keygen failed while deriving ${context.sshPublicKeyPath}`)
41
77
  }
42
78
 
79
+ if (progress !== undefined) {
80
+ assertProgressCommandSucceeded('ssh-keygen derive public key', result, `ssh-keygen failed while deriving ${context.sshPublicKeyPath}`)
81
+ }
82
+
43
83
  writeFileSync(context.sshPublicKeyPath, result.stdout)
44
84
  }
45
85
 
package/src/status.ts CHANGED
@@ -40,6 +40,8 @@ export interface StatusInfo {
40
40
  workspaceDataDir: string
41
41
  generatedConfigPath: string
42
42
  generatedConfigExists: boolean
43
+ logPath: string
44
+ logExists: boolean
43
45
  assetsDevcontainerDir: string
44
46
  assetsDevcontainerExists: boolean
45
47
  }
@@ -216,6 +218,8 @@ export function createStatusInfo (
216
218
  workspaceDataDir: context.workspaceDataDir,
217
219
  generatedConfigPath: context.generatedConfigPath,
218
220
  generatedConfigExists: exists(context.generatedConfigPath),
221
+ logPath: context.workspaceLogPath,
222
+ logExists: exists(context.workspaceLogPath),
219
223
  assetsDevcontainerDir: context.assetsDevcontainerDir,
220
224
  assetsDevcontainerExists: exists(context.assetsDevcontainerDir)
221
225
  },
@@ -297,6 +301,7 @@ export function formatStatusText (status: StatusInfo, options: { color?: boolean
297
301
  ` Workspace cache: ${status.paths.workspaceCacheDir}`,
298
302
  ` Workspace data: ${status.paths.workspaceDataDir}`,
299
303
  ` Generated config: ${status.paths.generatedConfigPath} (${existenceText(status.paths.generatedConfigExists, colorEnabled)})`,
304
+ ` Command log: ${status.paths.logPath} (${existenceText(status.paths.logExists, colorEnabled)})`,
300
305
  ` Devcontainer assets: ${status.paths.assetsDevcontainerDir} (${existenceText(status.paths.assetsDevcontainerExists, colorEnabled)})`,
301
306
  '',
302
307
  'SSH:',