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.
- package/README.md +135 -12
- package/assets/devcontainer/README.md +49 -17
- package/assets/devcontainer/devcontainer.json +24 -7
- package/assets/devcontainer/hooks/initialize.sh +32 -0
- package/assets/devcontainer/hooks/post-create.sh +59 -12
- package/assets/devcontainer/hooks/post-start.sh +21 -4
- 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 +56 -0
- package/assets/devcontainer/utils/python-bootstrap.sh +69 -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-Df4E8ARj.cjs +5498 -0
- package/dist/main-XMBsKjIK.mjs +5458 -0
- package/dist/main-XMBsKjIK.mjs.map +1 -0
- package/dist/main.cjs +5 -2
- package/dist/main.d.cts +495 -4
- package/dist/main.d.cts.map +1 -1
- package/dist/main.d.mts +495 -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 +23 -0
- package/docs/features/generated-config-and-state.md +57 -4
- package/docs/features/github-auth-refresh.md +12 -0
- package/docs/features/lifecycle.md +97 -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 +40 -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/specs/2026-07-11-default-commit-signing-design.md +396 -0
- package/docs/superpowers/specs/2026-07-11-progress-ci-regression-design.md +43 -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 +68 -10
- package/src/constants.ts +5 -0
- package/src/devcontainer.ts +500 -64
- package/src/doctor.ts +195 -30
- package/src/git-signing.ts +87 -0
- package/src/interactive-prompts.ts +692 -0
- package/src/list.ts +16 -2
- package/src/logging.ts +154 -0
- package/src/main.ts +1213 -63
- package/src/metadata.ts +52 -3
- package/src/package-info.ts +18 -0
- package/src/paths.ts +50 -10
- package/src/process.ts +80 -2
- package/src/progress.ts +593 -0
- package/src/purge.ts +208 -0
- package/src/shell.ts +19 -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
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import type { WorkspaceContext } from './paths.ts'
|
|
7
|
+
|
|
8
|
+
export const CLAUDE_APP_SUPPORT_DIRNAME = 'Claude'
|
|
9
|
+
export const CLAUDE_SSH_CONFIGS_FILENAME = 'ssh_configs.json'
|
|
10
|
+
|
|
11
|
+
export interface ClaudeSshConfigEntry {
|
|
12
|
+
name: string
|
|
13
|
+
sshHost: string
|
|
14
|
+
id: string
|
|
15
|
+
source?: string
|
|
16
|
+
[key: string]: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ClaudeSshConfigs {
|
|
20
|
+
configs: ClaudeSshConfigEntry[]
|
|
21
|
+
trustedHosts: string[]
|
|
22
|
+
[key: string]: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ClaudeSshConfigTargetEntry {
|
|
26
|
+
name: string
|
|
27
|
+
sshHost: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface InstallClaudeSshConfigResult {
|
|
31
|
+
configPath: string
|
|
32
|
+
backupPath?: string
|
|
33
|
+
changed: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UninstallClaudeSshConfigResult {
|
|
37
|
+
configPath: string
|
|
38
|
+
backupPath?: string
|
|
39
|
+
changed: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord (value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseRequiredString (value: unknown, key: string): string {
|
|
47
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
48
|
+
throw new Error(`Invalid Claude SSH config: ${key} must be a nonempty string`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return value.trim()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseOptionalString (value: unknown, key: string): string | undefined {
|
|
55
|
+
if (value === undefined) {
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof value !== 'string') {
|
|
60
|
+
throw new Error(`Invalid Claude SSH config: ${key} must be a string`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseStringArray (value: unknown, key: string): string[] {
|
|
67
|
+
if (value === undefined) {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!Array.isArray(value)) {
|
|
72
|
+
throw new Error(`Invalid Claude SSH config: ${key} must be an array`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return value.map((item) => {
|
|
76
|
+
if (typeof item !== 'string') {
|
|
77
|
+
throw new Error(`Invalid Claude SSH config: ${key} entries must be strings`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return item
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseClaudeSshConfigEntry (value: unknown): ClaudeSshConfigEntry {
|
|
85
|
+
if (!isRecord(value)) {
|
|
86
|
+
throw new Error('Invalid Claude SSH config: configs entries must be objects')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const source = parseOptionalString(value.source, 'source')
|
|
90
|
+
const parsed: ClaudeSshConfigEntry = {
|
|
91
|
+
...value,
|
|
92
|
+
name: parseRequiredString(value.name, 'name'),
|
|
93
|
+
sshHost: parseRequiredString(value.sshHost, 'sshHost'),
|
|
94
|
+
id: parseRequiredString(value.id, 'id')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (source !== undefined) {
|
|
98
|
+
parsed.source = source
|
|
99
|
+
} else {
|
|
100
|
+
delete parsed.source
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return parsed
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseClaudeSshConfigs (value: unknown): ClaudeSshConfigs {
|
|
107
|
+
if (!isRecord(value)) {
|
|
108
|
+
throw new Error('Invalid Claude SSH config: top-level value must be an object')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const configs = value.configs
|
|
112
|
+
if (configs !== undefined && !Array.isArray(configs)) {
|
|
113
|
+
throw new Error('Invalid Claude SSH config: configs must be an array')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
...value,
|
|
118
|
+
configs: (configs ?? []).map((config) => parseClaudeSshConfigEntry(config)),
|
|
119
|
+
trustedHosts: parseStringArray(value.trustedHosts, 'trustedHosts')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function defaultClaudeSshConfigsPath (
|
|
124
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
125
|
+
platform: NodeJS.Platform = process.platform
|
|
126
|
+
): string {
|
|
127
|
+
if (env.BOXDOWN_CLAUDE_SSH_CONFIGS !== undefined) {
|
|
128
|
+
return env.BOXDOWN_CLAUDE_SSH_CONFIGS
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const home = env.HOME ?? homedir()
|
|
132
|
+
|
|
133
|
+
if (platform === 'darwin') {
|
|
134
|
+
return join(home, 'Library', 'Application Support', CLAUDE_APP_SUPPORT_DIRNAME, CLAUDE_SSH_CONFIGS_FILENAME)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (platform === 'win32') {
|
|
138
|
+
return join(env.APPDATA ?? join(home, 'AppData', 'Roaming'), CLAUDE_APP_SUPPORT_DIRNAME, CLAUDE_SSH_CONFIGS_FILENAME)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return join(env.XDG_CONFIG_HOME ?? join(home, '.config'), CLAUDE_APP_SUPPORT_DIRNAME, CLAUDE_SSH_CONFIGS_FILENAME)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function claudeSshConfigEntryForWorkspace (context: WorkspaceContext, sshHost: string): ClaudeSshConfigTargetEntry {
|
|
145
|
+
return {
|
|
146
|
+
name: context.workspaceBasename,
|
|
147
|
+
sshHost
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readClaudeSshConfigsFile (configPath: string): ClaudeSshConfigs {
|
|
152
|
+
if (!existsSync(configPath)) {
|
|
153
|
+
return {
|
|
154
|
+
configs: [],
|
|
155
|
+
trustedHosts: []
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let parsed: unknown
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(readFileSync(configPath, 'utf8')) as unknown
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof SyntaxError) {
|
|
165
|
+
throw new Error(`Invalid Claude SSH config JSON: ${configPath}`, { cause: error })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw error
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return parseClaudeSshConfigs(parsed)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function backupPathFor (configPath: string, now: Date): string {
|
|
175
|
+
return `${configPath}.${now.toISOString().replace(/[:.]/gu, '-')}.bak`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function writeJsonAtomic (path: string, json: string): void {
|
|
179
|
+
const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
180
|
+
writeFileSync(tmpPath, json)
|
|
181
|
+
renameSync(tmpPath, path)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function withTrustedHost (trustedHosts: readonly string[], sshHost: string): string[] {
|
|
185
|
+
return [...new Set([...trustedHosts, sshHost])]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function mergeClaudeSshConfigHost (
|
|
189
|
+
config: ClaudeSshConfigs,
|
|
190
|
+
entry: ClaudeSshConfigTargetEntry,
|
|
191
|
+
createId: () => string = randomUUID
|
|
192
|
+
): ClaudeSshConfigs {
|
|
193
|
+
let matched = false
|
|
194
|
+
const configs = config.configs.map((configEntry) => {
|
|
195
|
+
if (configEntry.sshHost !== entry.sshHost) {
|
|
196
|
+
return configEntry
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
matched = true
|
|
200
|
+
return {
|
|
201
|
+
...configEntry,
|
|
202
|
+
name: entry.name,
|
|
203
|
+
sshHost: entry.sshHost
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
if (!matched) {
|
|
208
|
+
configs.push({
|
|
209
|
+
name: entry.name,
|
|
210
|
+
sshHost: entry.sshHost,
|
|
211
|
+
id: createId(),
|
|
212
|
+
source: 'desktop'
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
...config,
|
|
218
|
+
configs,
|
|
219
|
+
trustedHosts: withTrustedHost(config.trustedHosts, entry.sshHost)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function removeClaudeSshConfigHost (
|
|
224
|
+
config: ClaudeSshConfigs,
|
|
225
|
+
entry: ClaudeSshConfigTargetEntry
|
|
226
|
+
): ClaudeSshConfigs {
|
|
227
|
+
return {
|
|
228
|
+
...config,
|
|
229
|
+
configs: config.configs.filter((configEntry) => configEntry.sshHost !== entry.sshHost),
|
|
230
|
+
trustedHosts: config.trustedHosts.filter((trustedHost) => trustedHost !== entry.sshHost)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function installClaudeSshConfigHost (
|
|
235
|
+
entry: ClaudeSshConfigTargetEntry,
|
|
236
|
+
options: { configPath?: string, now?: Date, createId?: () => string } = {}
|
|
237
|
+
): InstallClaudeSshConfigResult {
|
|
238
|
+
const configPath = options.configPath ?? defaultClaudeSshConfigsPath()
|
|
239
|
+
const configDir = dirname(configPath)
|
|
240
|
+
const existingConfigExists = existsSync(configPath)
|
|
241
|
+
const existingConfig = readClaudeSshConfigsFile(configPath)
|
|
242
|
+
const nextConfig = mergeClaudeSshConfigHost(existingConfig, entry, options.createId)
|
|
243
|
+
const nextJson = `${JSON.stringify(nextConfig, null, 2)}\n`
|
|
244
|
+
const existingJson = existingConfigExists ? readFileSync(configPath, 'utf8') : undefined
|
|
245
|
+
|
|
246
|
+
if (existingJson === nextJson) {
|
|
247
|
+
return {
|
|
248
|
+
configPath,
|
|
249
|
+
changed: false
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
mkdirSync(configDir, { recursive: true })
|
|
254
|
+
|
|
255
|
+
let backupPath: string | undefined
|
|
256
|
+
if (existingConfigExists) {
|
|
257
|
+
backupPath = backupPathFor(configPath, options.now ?? new Date())
|
|
258
|
+
copyFileSync(configPath, backupPath)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
writeJsonAtomic(configPath, nextJson)
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
configPath,
|
|
265
|
+
...(backupPath === undefined ? {} : { backupPath }),
|
|
266
|
+
changed: true
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function uninstallClaudeSshConfigHost (
|
|
271
|
+
entry: ClaudeSshConfigTargetEntry,
|
|
272
|
+
options: { configPath?: string, now?: Date } = {}
|
|
273
|
+
): UninstallClaudeSshConfigResult {
|
|
274
|
+
const configPath = options.configPath ?? defaultClaudeSshConfigsPath()
|
|
275
|
+
const existingConfigExists = existsSync(configPath)
|
|
276
|
+
|
|
277
|
+
if (!existingConfigExists) {
|
|
278
|
+
return {
|
|
279
|
+
configPath,
|
|
280
|
+
changed: false
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const existingConfig = readClaudeSshConfigsFile(configPath)
|
|
285
|
+
const nextConfig = removeClaudeSshConfigHost(existingConfig, entry)
|
|
286
|
+
|
|
287
|
+
if (JSON.stringify(existingConfig) === JSON.stringify(nextConfig)) {
|
|
288
|
+
return {
|
|
289
|
+
configPath,
|
|
290
|
+
changed: false
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const nextJson = `${JSON.stringify(nextConfig, null, 2)}\n`
|
|
295
|
+
const backupPath = backupPathFor(configPath, options.now ?? new Date())
|
|
296
|
+
copyFileSync(configPath, backupPath)
|
|
297
|
+
writeJsonAtomic(configPath, nextJson)
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
configPath,
|
|
301
|
+
backupPath,
|
|
302
|
+
changed: true
|
|
303
|
+
}
|
|
304
|
+
}
|
package/src/cli-style.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const ansi = {
|
|
2
|
+
bold: '\u001B[1m',
|
|
3
|
+
cyan: '\u001B[36m',
|
|
4
|
+
dim: '\u001B[2m',
|
|
5
|
+
green: '\u001B[32m',
|
|
6
|
+
red: '\u001B[31m',
|
|
7
|
+
yellow: '\u001B[33m',
|
|
8
|
+
reset: '\u001B[0m'
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
export type CliColor = keyof typeof ansi
|
|
12
|
+
|
|
13
|
+
export function color (value: string, colorName: CliColor): string {
|
|
14
|
+
return `${ansi[colorName]}${value}${ansi.reset}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function selectedMark (): string {
|
|
18
|
+
return color('■', 'green')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function emptyMark (isFocused: boolean): string {
|
|
22
|
+
return color('□', isFocused ? 'cyan' : 'dim')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function promptRail (): string {
|
|
26
|
+
return color('│', 'cyan')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatPromptTitle (title: string): string {
|
|
30
|
+
return `${color('◆', 'cyan')} ${color(title, 'bold')}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatPromptEnd (): string {
|
|
34
|
+
return color('└', 'cyan')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatPromptLabel (label: string, isFocused: boolean): string {
|
|
38
|
+
return color(label, isFocused ? 'bold' : 'dim')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatPromptDetailLine (detail: string): string {
|
|
42
|
+
return `${promptRail()} ${color(detail, 'dim')}`
|
|
43
|
+
}
|