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.
- package/README.md +135 -12
- package/assets/devcontainer/README.md +65 -21
- package/assets/devcontainer/devcontainer.json +27 -15
- package/assets/devcontainer/hooks/initialize.sh +76 -22
- package/assets/devcontainer/hooks/post-create.sh +70 -12
- package/assets/devcontainer/hooks/post-start.sh +20 -13
- package/assets/devcontainer/ssh-config-install.sh +12 -3
- package/assets/devcontainer/start.sh +721 -44
- package/assets/devcontainer/utils/coding-agent-cli-update.sh +267 -3
- package/assets/devcontainer/utils/deps-install.sh +68 -0
- package/assets/devcontainer/utils/git-config-bootstrap.sh +128 -0
- package/assets/devcontainer/utils/git-signing-bootstrap.sh +109 -0
- package/assets/devcontainer/utils/python-bootstrap.sh +69 -0
- package/assets/devcontainer/utils/secret-env-bootstrap.sh +22 -0
- package/assets/devcontainer/utils/ssh-agent-proxy-bootstrap.sh +27 -0
- package/assets/devcontainer/utils/ssh-agent-proxy.mjs +39 -0
- package/assets/devcontainer/utils/ssh-bootstrap.sh +9 -0
- package/dist/bin/cli.cjs +1 -1
- package/dist/bin/cli.mjs +1 -1
- package/dist/main-BDgyf2t5.cjs +5758 -0
- package/dist/main-J4_2Up3o.mjs +5718 -0
- package/dist/main-J4_2Up3o.mjs.map +1 -0
- package/dist/main.cjs +5 -2
- package/dist/main.d.cts +501 -4
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +501 -4
- package/dist/main.d.mts.map +1 -1
- package/dist/main.mjs +2 -2
- package/docs/README.md +1 -0
- package/docs/architecture.md +32 -0
- package/docs/development.md +13 -6
- package/docs/features/README.md +2 -0
- package/docs/features/commit-signing.md +94 -0
- package/docs/features/generated-config-and-state.md +73 -5
- package/docs/features/github-auth-refresh.md +15 -2
- package/docs/features/lifecycle.md +103 -11
- package/docs/features/setup.md +66 -0
- package/docs/features/ssh-config-and-proxy.md +228 -7
- package/docs/features/start-and-shell.md +45 -5
- package/docs/superpowers/plans/2026-07-11-default-commit-signing.md +110 -0
- package/docs/superpowers/plans/2026-07-11-progress-ci-regression.md +125 -0
- package/docs/superpowers/plans/2026-07-17-node-ssh-agent-proxy-signing.md +132 -0
- package/docs/superpowers/plans/2026-07-17-runtime-secret-environment.md +174 -0
- package/docs/superpowers/specs/2026-07-11-default-commit-signing-design.md +416 -0
- package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -0
- package/docs/superpowers/specs/2026-07-17-node-ssh-agent-proxy-design.md +63 -0
- package/docs/superpowers/specs/2026-07-17-runtime-secret-environment-design.md +194 -0
- package/docs/testing.md +35 -2
- package/docs/todo.md +2 -2
- package/package.json +1 -1
- package/src/claude-app-config.ts +304 -0
- package/src/cli-style.ts +43 -0
- package/src/codex-app-config.ts +656 -0
- package/src/config.ts +80 -10
- package/src/constants.ts +12 -0
- package/src/devcontainer.ts +511 -64
- package/src/doctor.ts +292 -30
- package/src/git-signing.ts +267 -0
- package/src/interactive-prompts.ts +692 -0
- package/src/list.ts +16 -2
- package/src/logging.ts +164 -0
- package/src/main.ts +1214 -63
- package/src/metadata.ts +52 -3
- package/src/package-info.ts +18 -0
- package/src/paths.ts +71 -11
- package/src/process.ts +80 -2
- package/src/progress.ts +593 -0
- package/src/purge.ts +216 -0
- package/src/shell.ts +25 -0
- package/src/ssh-config.ts +134 -16
- package/src/ssh-install-targets.ts +111 -0
- package/src/ssh-key.ts +53 -13
- package/src/status.ts +5 -0
- package/dist/main-BuEptwlL.cjs +0 -1707
- package/dist/main-ZFTrSVgt.mjs +0 -1685
- 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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
skipping = true
|
|
88
|
+
if (markers === undefined) {
|
|
89
|
+
nextLines.push(line ?? '')
|
|
58
90
|
continue
|
|
59
91
|
}
|
|
60
92
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 (
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:',
|