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
@@ -0,0 +1,194 @@
1
+ # Runtime Secret Environment Design
2
+
3
+ ## Goal
4
+
5
+ Keep Boxdown-provided secrets available as ordinary environment variables in
6
+ container user sessions without placing their values in Docker container
7
+ configuration, the workspace, or Boxdown's persistent state and logs.
8
+
9
+ This design covers `ANTHROPIC_API_KEY`, `SNYK_TOKEN`, and
10
+ `OP_SERVICE_ACCOUNT_TOKEN`. It also prevents the Docker inspection command
11
+ used for image metadata from logging the complete container configuration.
12
+
13
+ ## Non-goals
14
+
15
+ - Migrating, deleting, or rewriting legacy `.env.development` files.
16
+ - Adding a `boxdown doctor --fix-secrets` command.
17
+ - Preventing a user who can enter the container from reading secrets that are
18
+ deliberately available to that user's session.
19
+ - Changing GitHub CLI authentication refresh, which already passes its host
20
+ token over stdin and redacts it from the workspace log.
21
+
22
+ ## Security Model
23
+
24
+ The user has requested compatibility with the current developer experience:
25
+ the three values above remain ordinary environment variables in Bash-based
26
+ container sessions. This intentionally permits session processes to read them.
27
+
28
+ The protection boundary is against accidental exposure outside that session:
29
+
30
+ - Docker inspection must not contain secret values.
31
+ - The repository and its `.env.development` file must not be created,
32
+ modified, or removed by Boxdown secret handling.
33
+ - Boxdown's persistent workspace data and `boxdown.log` must not contain
34
+ secret values.
35
+ - Secret files are short-lived, host-private runtime state and are mounted
36
+ read-only into the container.
37
+
38
+ ## Architecture
39
+
40
+ ### Host runtime secret state
41
+
42
+ `WorkspaceContext` gains a per-workspace runtime-secret directory under a
43
+ user-private runtime root. The root uses `XDG_RUNTIME_DIR` when available and
44
+ a user-private directory under the system temporary directory otherwise. It
45
+ is distinct from `workspaceDataDir`, which is persistent and contains logs and
46
+ metadata.
47
+
48
+ The directory and each secret file are created with owner-only permissions
49
+ (`0700` directory, `0600` files). The three files use fixed names matching
50
+ their environment variables. No secret value appears in generated
51
+ configuration, command arguments, progress output, metadata, or diagnostics.
52
+
53
+ The host-side `initializeCommand` refreshes this directory before each
54
+ Dev Container startup:
55
+
56
+ 1. It creates the runtime directory safely.
57
+ 2. It writes `ANTHROPIC_API_KEY` and `SNYK_TOKEN` only when each corresponding
58
+ host environment value is non-empty.
59
+ 3. It retrieves `OP_SERVICE_ACCOUNT_TOKEN` from the existing 1Password item
60
+ only when `op` is available and can read it.
61
+ 4. It atomically replaces files for available values and removes files for
62
+ unavailable values, preventing a stale token from a previous start.
63
+
64
+ Failures to retrieve 1Password or an absent host variable are informational
65
+ and non-blocking. The affected variable is absent from the next container
66
+ session.
67
+
68
+ ### Generated Dev Container configuration
69
+
70
+ The base `runArgs` no longer includes:
71
+
72
+ ```text
73
+ --env-file ${localWorkspaceFolder}/.env.development
74
+ ```
75
+
76
+ The base `containerEnv` no longer includes secret values via `localEnv`.
77
+ `NODE_ENV` remains ordinary non-secret configuration.
78
+
79
+ Boxdown's generated configuration adds a read-only bind mount from the host
80
+ runtime-secret directory to `/run/boxdown/secrets`. It may contain non-secret
81
+ bootstrap configuration such as `BASH_ENV` and the mounted directory path,
82
+ but never a secret value.
83
+
84
+ Because mounts are create-time settings, `boxdown start --recreate` is needed
85
+ for an already-created container to receive the new mount. New containers get
86
+ it automatically through every Boxdown lifecycle path (`setup`, `start`,
87
+ `codex`, and the other coding-agent commands).
88
+
89
+ ### Container session bootstrap
90
+
91
+ A mounted Boxdown bootstrap script reads only the fixed secret files, then
92
+ exports values that are present. It never echoes values or shell source lines.
93
+ It handles missing or unreadable files by leaving the corresponding variable
94
+ unset.
95
+
96
+ The generated configuration sets the non-secret `BASH_ENV` path so
97
+ non-interactive Bash processes source the bootstrap. The container setup also
98
+ installs the bootstrap in the `node` user's Bash startup path for interactive
99
+ shells and SSH sessions. Boxdown's direct shell and coding-agent launch
100
+ scripts source it explicitly before `exec` as an additional deterministic
101
+ path.
102
+
103
+ Consequently, an ordinary Bash session can use, for example,
104
+ `$ANTHROPIC_API_KEY`, `$SNYK_TOKEN`, and `$OP_SERVICE_ACCOUNT_TOKEN` as it can
105
+ today. Docker's configured environment contains only the bootstrap path and
106
+ other non-secret values, so `docker inspect` cannot reveal secret contents.
107
+
108
+ ### Workspace file handling
109
+
110
+ The initialization hook stops creating or editing `.env.development`. The
111
+ post-start hook stops deleting it. Boxdown therefore treats that file as
112
+ project-owned in all cases.
113
+
114
+ This release deliberately does not modify legacy files. A pre-existing
115
+ `OP_SERVICE_ACCOUNT_TOKEN` value remains the user's responsibility to remove
116
+ or rotate. Documentation will state that Boxdown no longer reads the file and
117
+ that users should remove a legacy accidental entry manually.
118
+
119
+ ### Logging and Docker inspection
120
+
121
+ Boxdown currently runs `docker inspect --format '{{json .}}'` only to record
122
+ the image ID and image name. The full payload includes `.Config.Env`, and the
123
+ command logger writes all child output even when it is not mirrored to the
124
+ terminal.
125
+
126
+ The implementation replaces that command with a narrow template that returns
127
+ only the image ID and configured image name. Its parser accepts that narrow,
128
+ non-sensitive output; it never parses a complete inspect object.
129
+
130
+ The workspace logger also gains structural redaction for assignments of the
131
+ three Boxdown secret variable names. This is a defense-in-depth backstop for
132
+ legacy logs or future accidental command output, not the primary protection.
133
+ Existing value-based redaction remains for dynamically acquired GitHub tokens.
134
+
135
+ ## Lifecycle and Cleanup
136
+
137
+ - `initializeCommand` is the only writer of runtime secret files.
138
+ - `down` and `purge` remove the runtime-secret directory after stopping or
139
+ removing the associated container.
140
+ - A new start refreshes values atomically, so no previous value remains when a
141
+ host value is absent or 1Password lookup fails.
142
+ - Runtime secret state is never written below `workspaceDataDir` and is never
143
+ included in status output.
144
+ - Existing workspaces require `boxdown start --recreate` to replace the old
145
+ Docker environment configuration. Boxdown will not automatically migrate
146
+ existing containers or `.env.development` files.
147
+
148
+ ## Diagnostics and Documentation
149
+
150
+ Startup output may state whether the optional 1Password token was available,
151
+ but must not print values, file contents, or paths that encode secret data.
152
+ `boxdown doctor` verifies that the runtime-secret mount is constructible and
153
+ that the generated configuration contains neither the legacy `--env-file`
154
+ argument nor secret `containerEnv` entries. Missing optional values are
155
+ warnings, not failures.
156
+
157
+ Documentation will explain:
158
+
159
+ - which host values Boxdown forwards;
160
+ - that they are exposed to Bash sessions but not Docker configuration;
161
+ - that `.env.development` is project-owned and ignored by Boxdown;
162
+ - that `--recreate` is required for existing containers; and
163
+ - how to manually remove and rotate a legacy 1Password token if it was
164
+ previously written to a workspace or log.
165
+
166
+ ## Test Strategy
167
+
168
+ Tests will cover:
169
+
170
+ - host secret-state creation, owner-only modes, atomic refresh, and removal of
171
+ stale files without printing values;
172
+ - success, missing host values, missing `op`, and failed 1Password lookup as
173
+ non-blocking cases;
174
+ - generated configuration contains the read-only runtime mount and contains no
175
+ `--env-file`, secret `containerEnv` keys, or secret values;
176
+ - `.env.development` is neither created nor altered by initialization or
177
+ post-start hooks;
178
+ - interactive, non-interactive, SSH, and Boxdown coding-agent Bash launch
179
+ paths export available values and leave unavailable values unset;
180
+ - image inspection uses only a narrow projection and workspace logs never
181
+ receive a synthetic inspect environment value;
182
+ - logger structural redaction for all three known keys;
183
+ - doctor warnings and mount probes, with optional-secret absence non-fatal;
184
+ - focused tests first, then the full test suite, lint, build, Bash syntax
185
+ checks, and `git diff --check`.
186
+
187
+ ## Decision Record
188
+
189
+ The chosen design favors compatibility over strict command-level least
190
+ privilege. The three values are intentionally ordinary environment variables
191
+ inside Bash sessions. If this causes unacceptable in-container exposure or
192
+ developer-experience problems, a future design may replace session bootstrap
193
+ with per-command wrappers. That change would be a deliberate compatibility
194
+ break and is not part of this work.
package/docs/testing.md CHANGED
@@ -21,12 +21,18 @@ Unit tests should avoid starting Docker. Prefer pure tests for:
21
21
  - Workspace path resolution and state directory selection.
22
22
  - Generated devcontainer config shape.
23
23
  - SSH config block creation and idempotent replacement.
24
+ - SSH install target parsing and prompt behavior, including explicit target
25
+ flags, prompt selection, prompt skip/cancel, and non-TTY fallback.
26
+ - Codex app/global-state target installation, legacy path migration,
27
+ and idempotent project injection.
24
28
  - Lifecycle status and doctor output formatting.
25
29
  - Workspace metadata and list output formatting.
26
30
  - Safety invariants, such as not packaging `.ssh/` key material.
27
31
 
28
32
  Use temporary directories for workspace and state tests. Do not write to the
29
- user's real SSH config in unit tests.
33
+ user's real SSH config or Codex app config in unit tests. Use
34
+ `BOXDOWN_CODEX_APP_CONFIG` or direct helper path overrides for Codex config
35
+ fixtures.
30
36
 
31
37
  ## Build and CLI Smoke Tests
32
38
 
@@ -49,15 +55,42 @@ Confirm `assets/devcontainer/**` is included and `.ssh/` is not.
49
55
  Manual Docker acceptance is heavier and should be done intentionally:
50
56
 
51
57
  ```sh
58
+ boxdown setup --workspace /path/to/repo
59
+ boxdown setup --workspace /path/to/repo --target codex
60
+ boxdown setup --workspace /path/to/repo --target claude
52
61
  boxdown start --workspace /path/to/repo
53
62
  boxdown list
54
63
  boxdown list --json
55
64
  boxdown status --workspace /path/to/repo
56
65
  boxdown status --workspace /path/to/repo --json
57
66
  boxdown doctor --workspace /path/to/repo
58
- boxdown ssh-config install --workspace /path/to/repo
67
+ boxdown ssh install --workspace /path/to/repo
68
+ boxdown ssh install --workspace /path/to/repo --target codex
69
+ boxdown ssh install --workspace /path/to/repo --target claude
70
+ CI=1 boxdown ssh install --workspace /path/to/repo
59
71
  ssh <repo-name>-devcontainer 'whoami && pwd'
72
+ boxdown down --workspace /path/to/repo-a --workspace /path/to/repo-b
73
+ boxdown purge --workspace /path/to/disposable-repo
60
74
  ```
61
75
 
76
+ The plain `ssh install` command should show the optional target selector when
77
+ run in an interactive terminal. The explicit `--target codex` and
78
+ `--target claude` commands verify scriptable target installation, and the `CI=1`
79
+ command verifies the non-interactive skip path without blocking.
80
+
81
+ The plain `tunnel` command should prompt for ports in an interactive terminal.
82
+ Use `boxdown tunnel --workspace /path/to/repo --port 3030` when testing the
83
+ non-interactive or fully explicit path.
84
+
85
+ When checking browser access, start a dev server inside the container and keep a
86
+ foreground tunnel open from the host:
87
+
88
+ ```sh
89
+ boxdown tunnel --workspace /path/to/repo
90
+ boxdown tunnel --workspace /path/to/repo --port 3030
91
+ ```
92
+
93
+ Confirm `http://localhost:3030/` works, then stop the tunnel with Ctrl-C.
94
+
62
95
  Run this from at least two repositories when changing workspace isolation,
63
96
  container lookup, SSH config generation, or generated config behavior.
package/docs/todo.md CHANGED
@@ -35,11 +35,11 @@ Acceptance commands:
35
35
 
36
36
  ```sh
37
37
  boxdown start --workspace ~/projects/repos/gh-cp
38
- boxdown ssh-config install --workspace ~/projects/repos/gh-cp
38
+ CI=1 boxdown ssh install --workspace ~/projects/repos/gh-cp
39
39
  ssh gh-cp-devcontainer 'whoami && pwd'
40
40
 
41
41
  boxdown start --workspace ~/projects/repos/lirantaldotcom
42
- boxdown ssh-config install --workspace ~/projects/repos/lirantaldotcom
42
+ CI=1 boxdown ssh install --workspace ~/projects/repos/lirantaldotcom
43
43
  ssh lirantaldotcom-devcontainer 'whoami && pwd'
44
44
  ```
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boxdown",
3
- "version": "1.0.0",
3
+ "version": "1.2.1",
4
4
  "description": "Run a reusable devcontainer sandbox for any local project",
5
5
  "types": "dist/main.d.mts",
6
6
  "type": "module",
@@ -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
+ }