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
@@ -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
+ }
@@ -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
+ }