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