claude-nomad 0.23.0 → 0.25.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/CHANGELOG.md +46 -0
- package/README.md +50 -46
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.ts +309 -0
- package/src/commands.doctor.checks.ts +9 -2
- package/src/commands.doctor.gitleaks-version.ts +132 -0
- package/src/commands.doctor.mirror-actions.ts +83 -0
- package/src/commands.doctor.ts +20 -3
- package/src/commands.drop-session.ts +60 -19
- package/src/commands.push.ts +23 -11
- package/src/commands.update.ts +27 -2
- package/src/config.ts +23 -10
- package/src/extras-sync.ts +30 -1
- package/src/nomad.ts +16 -5
- package/src/push-gitleaks.ts +18 -16
- package/src/remap.ts +1 -1
- package/src/update.fork-extras.ts +101 -0
- package/src/utils.ts +22 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.0](https://github.com/funkadelic/claude-nomad/compare/v0.24.0...v0.25.0) (2026-05-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **doctor:** add gitleaks version and mirror-Actions drift checks ([#125](https://github.com/funkadelic/claude-nomad/issues/125)) ([8a16e0c](https://github.com/funkadelic/claude-nomad/commit/8a16e0c274bb7b961c6fd2df9912874c572023ee))
|
|
9
|
+
* **extras:** support a single root file as an extras entry ([#132](https://github.com/funkadelic/claude-nomad/issues/132)) ([6303b62](https://github.com/funkadelic/claude-nomad/commit/6303b62de84406da6a7fa2b7eb7af28a0473d0aa))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
* **drop-session:** cascade unstage into subagent transcript directory ([#120](https://github.com/funkadelic/claude-nomad/issues/120)) ([7f10d51](https://github.com/funkadelic/claude-nomad/commit/7f10d5135a7eaa9b6e74fb1aa863506a7b914a09))
|
|
15
|
+
* **push,update:** handle untracked extras in allow-list and fork merge ([#122](https://github.com/funkadelic/claude-nomad/issues/122)) ([e710a48](https://github.com/funkadelic/claude-nomad/commit/e710a48973a7c4e7479a9671d5891458a182dac7))
|
|
16
|
+
* **update:** skip push prompt when fork merge is a no-op ([#123](https://github.com/funkadelic/claude-nomad/issues/123)) ([77fdb20](https://github.com/funkadelic/claude-nomad/commit/77fdb20244a096e44a1fecd0a283e3401a78972d))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
* pin workflow actions to SHAs and drop persisted CI credentials ([#130](https://github.com/funkadelic/claude-nomad/issues/130)) ([b60109c](https://github.com/funkadelic/claude-nomad/commit/b60109c0cbb5dff1b13c4155cc12c3d98ce1b141))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
|
|
26
|
+
* add CONTRIBUTING, PR template, and SECURITY policy ([#131](https://github.com/funkadelic/claude-nomad/issues/131)) ([774eb58](https://github.com/funkadelic/claude-nomad/commit/774eb58a36af032f7d1cd109d59f313c4ff6affa))
|
|
27
|
+
* lead README with a benefit-first pitch ([#133](https://github.com/funkadelic/claude-nomad/issues/133)) ([289e299](https://github.com/funkadelic/claude-nomad/commit/289e2994fd7dffb10818fe02aad64b193ad800b6))
|
|
28
|
+
* **readme:** foreground secret-safety in the intro ([#126](https://github.com/funkadelic/claude-nomad/issues/126)) ([e661e9f](https://github.com/funkadelic/claude-nomad/commit/e661e9f02247fc30630fd90d5facc65aa7b4f2a0))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Dependencies
|
|
32
|
+
|
|
33
|
+
* bump SonarSource/sonarqube-scan-action from 8.0.0 to 8.1.0 ([#127](https://github.com/funkadelic/claude-nomad/issues/127)) ([603f20d](https://github.com/funkadelic/claude-nomad/commit/603f20d9a22528c8df19d328c91a02012690836c))
|
|
34
|
+
* bump the dev-dependencies group across 1 directory with 2 updates ([#128](https://github.com/funkadelic/claude-nomad/issues/128)) ([50b1b87](https://github.com/funkadelic/claude-nomad/commit/50b1b87b7288eed60280843504e6ccc2598f10b0))
|
|
35
|
+
* bump tsx from 4.22.2 to 4.22.3 in the prod-dependencies group ([#129](https://github.com/funkadelic/claude-nomad/issues/129)) ([a7ada00](https://github.com/funkadelic/claude-nomad/commit/a7ada000ec2d3dbee132c7b61dc207012e53c1c3))
|
|
36
|
+
|
|
37
|
+
## [0.24.0](https://github.com/funkadelic/claude-nomad/compare/v0.23.0...v0.24.0) (2026-05-24)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
* **doctor:** add --check-shared preflight gitleaks scan ([#117](https://github.com/funkadelic/claude-nomad/issues/117)) ([0089d09](https://github.com/funkadelic/claude-nomad/commit/0089d09ef91ff7b6778b065bcfe8be97f4c54d1b))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
### Documentation
|
|
46
|
+
|
|
47
|
+
* **readme:** document nomad doctor --check-shared preflight ([#119](https://github.com/funkadelic/claude-nomad/issues/119)) ([d08ed91](https://github.com/funkadelic/claude-nomad/commit/d08ed91bbfd4c976f56c510b086e375c4595e682))
|
|
48
|
+
|
|
3
49
|
## [0.23.0](https://github.com/funkadelic/claude-nomad/compare/v0.22.3...v0.23.0) (2026-05-23)
|
|
4
50
|
|
|
5
51
|
|
package/README.md
CHANGED
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**Your entire Claude Code setup, on every machine. History included, secrets excluded.**
|
|
10
10
|
|
|
11
|
-
claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on
|
|
11
|
+
Open Claude Code on a second machine and it is a blank slate: none of your custom agents, slash commands, tuned settings, or past conversations. claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything is there, conversations included.
|
|
12
12
|
|
|
13
|
-
**
|
|
13
|
+
- **Resume your sessions on any machine.** Start a conversation on your desktop and pick it up on your laptop. claude-nomad remaps the file paths Claude Code embeds in every transcript, so your history follows you instead of getting stranded on the box where it started.
|
|
14
|
+
- **Private by default.** Your `~/.claude/` also holds OAuth tokens, MCP credentials, and the full text of every conversation. Every push is secret-scanned before it leaves your machine, credentials and ephemeral state never sync, and `nomad init` disables CI on your private mirror by default, so transcripts can't leak through Actions logs.
|
|
15
|
+
- **One setup, every machine.** Your agents, skills, slash commands, and settings live in one place and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on top instead of clobbering your shared defaults.
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history survives different file paths and your secrets never ride along.
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
- **Per-host settings via deep merge.** Shared defaults live in one file; machine-specific overrides (model choice, MCP server URLs, env vars, hooks) live in a per-host file. They're merged on every pull instead of overwriting each other.
|
|
19
|
-
- **Per-project content rides along, opt-in.** Whitelisted directories at a project's root (declared via `path-map.json`'s `extras` field) sync alongside session transcripts, so project-attached state like `.planning/` follows you across hosts. Off by default; projects without an `extras` entry behave exactly as before.
|
|
19
|
+
For anyone running Claude Code on more than one machine: a laptop and a desktop, a Mac and a WSL box, a personal rig and a work machine. [Get started in three steps.](#quickstart)
|
|
20
20
|
|
|
21
21
|
## Table of contents
|
|
22
22
|
|
|
@@ -114,7 +114,7 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
114
114
|
│ ├── commands/
|
|
115
115
|
│ ├── rules/
|
|
116
116
|
│ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
|
|
117
|
-
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, .env
|
|
117
|
+
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, *.pem, id_rsa, id_ed25519, .env, .env.*
|
|
118
118
|
│ ├── projects/ # session transcripts under logical names
|
|
119
119
|
│ └── extras/ # opt-in per-project content (materializes when path-map.json declares extras)
|
|
120
120
|
├── hosts/
|
|
@@ -127,14 +127,14 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
127
127
|
|
|
128
128
|
## What gets synced vs. not
|
|
129
129
|
|
|
130
|
-
| Category | Items | Behavior
|
|
131
|
-
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
-
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`).
|
|
133
|
-
| **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull.
|
|
134
|
-
| **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`.
|
|
135
|
-
| **Per-project extras** | `<localRoot>/.planning/` and other directories whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts`
|
|
136
|
-
| **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state.
|
|
137
|
-
| **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Plugin payloads not synced. Claude Code re-downloads them on first use from the `enabledPlugins` list in the regenerated `settings.json`; no manual `claude plugins install ...` per host.
|
|
130
|
+
| Category | Items | Behavior |
|
|
131
|
+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
+
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
|
|
133
|
+
| **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
|
|
134
|
+
| **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
|
|
135
|
+
| **Per-project extras** | `<localRoot>/.planning/` and other directories, or a single root file like `CLAUDE.md`, whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts` | Opt-in via the `extras` field in `path-map.json`. Mirrored to/from `shared/extras/<logical>/<name>` (directory subtree or single file). Pre-pull divergence WARN flags local edits before they get overwritten. |
|
|
136
|
+
| **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
|
|
137
|
+
| **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Plugin payloads not synced. Claude Code re-downloads them on first use from the `enabledPlugins` list in the regenerated `settings.json`; no manual `claude plugins install ...` per host. |
|
|
138
138
|
|
|
139
139
|
> [!NOTE]
|
|
140
140
|
> Plugins that depend on host-specific state (external binaries, API keys in env, MCP server URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's own per-host config.
|
|
@@ -145,7 +145,7 @@ For the rationale behind these choices, see [What does NOT sync (deliberate trad
|
|
|
145
145
|
|
|
146
146
|
The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-path>/` where the encoded path is the absolute path with `/` replaced by `-`. So the same logical project ends up in different directories on each host.
|
|
147
147
|
|
|
148
|
-
`path-map.json` defines logical names and where the repo lives on each host. The optional `extras` block opts a project into syncing whitelisted directories at its root:
|
|
148
|
+
`path-map.json` defines logical names and where the repo lives on each host. The optional `extras` block opts a project into syncing whitelisted directories (or a single root file) at its root:
|
|
149
149
|
|
|
150
150
|
```json
|
|
151
151
|
{
|
|
@@ -157,7 +157,7 @@ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-pa
|
|
|
157
157
|
}
|
|
158
158
|
},
|
|
159
159
|
"extras": {
|
|
160
|
-
"ha-acwd": [".planning"]
|
|
160
|
+
"ha-acwd": [".planning", "CLAUDE.md"]
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
```
|
|
@@ -169,7 +169,7 @@ Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull`
|
|
|
169
169
|
|
|
170
170
|
On `push`, sessions in `~/.claude/projects/-Users-you-code-ha-acwd/` get copied to `shared/projects/ha-acwd/`. On `pull` on another machine, they get copied to that host's encoded path. `claude --resume` then finds them (see [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs) for the cross-OS cwd-binding gotcha).
|
|
171
171
|
|
|
172
|
-
The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory names validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in
|
|
172
|
+
The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory or root-file names (e.g. `.planning`, `CLAUDE.md`) validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is copied to `shared/extras/<logical>/<name>` and inherits the staged-tree gitleaks scan. On `pull`, the reverse copy runs after `git pull --rebase`; just before it overwrites your working tree, a divergence check compares the incoming content against your local copy and emits a per-file WARN naming the diverging files. The existing local content is backed up to `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/<rel>/` before the pull copy lands (`<encoded-localRoot>` is the `localRoot` with `/` rewritten as `-`, so two opted-in projects with the same relative extras path do not collide in one backup run).
|
|
173
173
|
|
|
174
174
|
## Per-host overrides
|
|
175
175
|
|
|
@@ -206,7 +206,7 @@ Read these before adopting so you opt in with eyes open.
|
|
|
206
206
|
- **Manual push/pull.** No file watcher. Shell hooks recommended.
|
|
207
207
|
- **OAuth doesn't sync.** You'll log in once per host. Intentional.
|
|
208
208
|
- **Only sessions in `path-map.json` are remapped.** Drive-by sessions on un-mapped paths are left alone.
|
|
209
|
-
- **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected.
|
|
209
|
+
- **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected. Names (a directory or a single root file) outside `SUPPORTED_EXTRAS` are skipped with a `skip ... not in SUPPORTED_EXTRAS` log line so an unrecognized name cannot widen the sync surface. Unsafe path-map values (path-traversal in `logical` keys, non-absolute or unnormalized `localRoot` values) FATAL before any filesystem mutation via `assertSafeLogical` / `assertSafeLocalRoot` in `src/extras-sync.ts`.
|
|
210
210
|
- **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so the picker's `cd ... && claude --resume <id>` line fails on a different host. Use `nomad doctor --resume-cmd <id>` for a host-local equivalent (see [Cross-OS resume](#cross-os-resume)). The sidecar approach preserves transcript byte-equality.
|
|
211
211
|
- **Empty directories don't survive sync.** Git doesn't track empty dirs; `nomad doctor` reports them as `missing` (benign). Drop a `.gitkeep` to force materialization.
|
|
212
212
|
|
|
@@ -287,10 +287,11 @@ nomad init --keep-actions
|
|
|
287
287
|
Edit `path-map.json` to add your logical projects (see [Path remapping](#path-remapping)), then:
|
|
288
288
|
|
|
289
289
|
```bash
|
|
290
|
-
nomad doctor
|
|
291
|
-
nomad
|
|
292
|
-
nomad
|
|
293
|
-
nomad
|
|
290
|
+
nomad doctor # read-only state check; reports host, repo state, every check as ✓ (pass) / ✗ (fail) / ⚠︎ (warn)
|
|
291
|
+
nomad doctor --check-shared # read-only gitleaks preflight over the session transcripts a push would stage
|
|
292
|
+
nomad diff # preview what nomad pull would change on this host; no lock, no network, no mutation
|
|
293
|
+
nomad push # send current state to the private remote
|
|
294
|
+
nomad pull # apply on another host (or this one after a remote update)
|
|
294
295
|
```
|
|
295
296
|
|
|
296
297
|
`nomad pull --dry-run` is the network-aware twin of `nomad diff`: it acquires the lock and runs `git pull` so you see what the next real pull would do given the latest remote, then exits without mutating.
|
|
@@ -333,9 +334,9 @@ nomad update
|
|
|
333
334
|
`nomad update` (see `cmdUpdate` in `src/commands.update.ts`) detects which layout your `~/claude-nomad/` uses and does the right thing:
|
|
334
335
|
|
|
335
336
|
- **vanilla** (`origin` points at the public repo): `git pull --ff-only origin main`.
|
|
336
|
-
- **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt.
|
|
337
|
+
- **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, then (before merging) commit any whitelisted `shared/extras/` content that is still untracked locally so an overlap with upstream becomes a normal file merge instead of an untracked-overwrite abort, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt. When the merge is a no-op (HEAD unchanged, nothing new to push) the prompt is skipped entirely and `nomad update` logs `already in sync with origin/main`.
|
|
337
338
|
|
|
338
|
-
Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead (e.g. a `-dev` build between releases), and silent on network failures.
|
|
339
|
+
Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, commits the regenerated `package-lock.json` (fork topology) if the reinstall changed it, then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead (e.g. a `-dev` build between releases), and silent on network failures.
|
|
339
340
|
|
|
340
341
|
Common cases:
|
|
341
342
|
|
|
@@ -358,24 +359,27 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
|
|
|
358
359
|
|
|
359
360
|
## Commands
|
|
360
361
|
|
|
361
|
-
| Command | Description
|
|
362
|
-
| -------------------------------- |
|
|
363
|
-
| `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)).
|
|
364
|
-
| `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`.
|
|
365
|
-
| `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror.
|
|
366
|
-
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing.
|
|
367
|
-
| `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing.
|
|
368
|
-
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state.
|
|
369
|
-
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push.
|
|
370
|
-
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push.
|
|
371
|
-
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl`
|
|
372
|
-
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool).
|
|
373
|
-
| `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check.
|
|
374
|
-
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)).
|
|
375
|
-
| `nomad --
|
|
362
|
+
| Command | Description |
|
|
363
|
+
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
364
|
+
| `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
|
|
365
|
+
| `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
|
|
366
|
+
| `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
|
|
367
|
+
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing. |
|
|
368
|
+
| `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
|
|
369
|
+
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
370
|
+
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
371
|
+
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
|
|
372
|
+
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` and the sibling `shared/projects/*/<id>/` subagent directory from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` and `<id>/` tree are preserved. See [Recovery flows](#recovery-flows). |
|
|
373
|
+
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
374
|
+
| `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
|
|
375
|
+
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
|
|
376
|
+
| `nomad doctor --check-shared` | Read-only gitleaks preflight: stages the session transcripts a `push` would publish into a temp tree and scans them, failing (`✗`, exit 1) per affected session with rotate-and-scrub guidance. Skips with a `⚠︎` when gitleaks is not on PATH. See [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl). |
|
|
377
|
+
| `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
|
|
376
378
|
|
|
377
379
|
The version-check emits ``⚠︎ version: <local> -> <latest> (run `nomad update`)`` when the local install is behind the latest upstream release, and `✓ version: <local> (latest)` when current. It silently skips on network failures.
|
|
378
380
|
|
|
381
|
+
Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so a patch-only difference stays `✓`), and is silent when gitleaks is not on PATH. The mirror-Actions line (carrying a `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false` remediation hint) fires when origin is a private GitHub mirror that is gh-authed with Actions re-enabled, complementing the auto-disable that runs on `nomad init` (see [Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub origin, `gh` unauthed, public repo, or Actions already off).
|
|
382
|
+
|
|
379
383
|
Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summary:` line. The status glyph (`✓` green / `⚠︎` yellow / `✗` red / `ℹ︎` dim) carries the severity, mirroring `nomad doctor`'s left-gutter format:
|
|
380
384
|
|
|
381
385
|
```text
|
|
@@ -390,7 +394,7 @@ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summa
|
|
|
390
394
|
|
|
391
395
|
### `nomad drop-session <id>`
|
|
392
396
|
|
|
393
|
-
Surgically unstages every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl`
|
|
397
|
+
Surgically unstages every `shared/projects/*/<id>.jsonl` plus the sibling `shared/projects/*/<id>/` subagent directory (whose nested transcripts are keyed by the same session id) from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` and the local `<id>/` tree are never touched.
|
|
394
398
|
|
|
395
399
|
```bash
|
|
396
400
|
nomad drop-session <id>
|
|
@@ -398,18 +402,18 @@ nomad drop-session <id>
|
|
|
398
402
|
|
|
399
403
|
Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash, extra arg) exits 1 with a `usage:` line.
|
|
400
404
|
|
|
401
|
-
For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies the entry as tracked-in-HEAD vs newly-staged and unstages it via `git restore --staged --worktree --` or `git rm --cached -f --` respectively. Idempotent: a second run on the same id sees no matching staged
|
|
405
|
+
For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies the entry as tracked-in-HEAD vs newly-staged and unstages it via `git restore --staged --worktree --` or `git rm --cached -f --` respectively. The `<id>/` subagent directory is expanded into its staged entries via `git ls-files -z` so every nested transcript flows through the same per-entry classification; a session that has only a subagent directory (no flat `<id>.jsonl`) is still droppable. Idempotent: a second run on the same id sees no matching staged entries and exits 0.
|
|
402
406
|
|
|
403
407
|
Exit codes:
|
|
404
408
|
|
|
405
409
|
- `0` on any drop, including an idempotent re-run.
|
|
406
|
-
- `1` with `✗ no staged session matches <id>` on stderr when
|
|
410
|
+
- `1` with `✗ no staged session matches <id>` on stderr when neither a `shared/projects/*/<id>.jsonl` nor a `shared/projects/*/<id>/` directory with staged entries matches.
|
|
407
411
|
|
|
408
|
-
What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file. The local
|
|
412
|
+
What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrub the local files separately.
|
|
409
413
|
|
|
410
414
|
### Recovery flow: gitleaks FATAL on a session JSONL
|
|
411
415
|
|
|
412
|
-
`nomad push` runs `gitleaks protect --staged` before commit. When findings live in a session transcript, the FATAL names every affected session id and the recovery command:
|
|
416
|
+
`nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you push (and without mutating anything), run the read-only preflight `nomad doctor --check-shared`, which stages and scans the exact transcripts a push would publish. When findings live in a session transcript, the push FATAL names every affected session id and the recovery command:
|
|
413
417
|
|
|
414
418
|
```text
|
|
415
419
|
✗ gitleaks detected secrets in 1 session transcript(s).
|
package/package.json
CHANGED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns the `nomad doctor --check-shared` preflight reporter.
|
|
3
|
+
*
|
|
4
|
+
* Read-only diagnostic that runs gitleaks against the LOCAL session
|
|
5
|
+
* transcripts `nomad push` would stage (each path-map entry mapped to this
|
|
6
|
+
* host), surfacing secret leaks BEFORE the push pipeline fires. Mirrors the
|
|
7
|
+
* push-time scan (`runGitleaksScan` in `./push-gitleaks.ts`) but: scans a
|
|
8
|
+
* temp COPY of the live transcripts (never the live dir), uses the
|
|
9
|
+
* purpose-built `gitleaks dir` subcommand, and emits doctor-flavored glyph
|
|
10
|
+
* rows + `process.exitCode` instead of throwing a push-flavored FATAL.
|
|
11
|
+
*
|
|
12
|
+
* Composition only: reuses `partitionFindings` / `readGitleaksReport` /
|
|
13
|
+
* `SESSION_PATH` (the gitleaks JSON parser) and `copyDirJsonlOnly` (the
|
|
14
|
+
* push-fidelity source filter) verbatim. The doctor-flavored guidance
|
|
15
|
+
* composer is new (push's `buildSessionAwareFatal` is wrong at doctor time:
|
|
16
|
+
* `nomad drop-session` operates on the staged tree, and nothing is staged
|
|
17
|
+
* during a preflight).
|
|
18
|
+
*
|
|
19
|
+
* All external calls use `execFileSync` argv-array form (no shell), the
|
|
20
|
+
* codebase PUSH-04 invariant.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { green, red, yellow, okGlyph, failGlyph, warnGlyph } from './color.ts';
|
|
30
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
31
|
+
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
32
|
+
import { type Finding, partitionFindings, readGitleaksReport } from './push-gitleaks.ts';
|
|
33
|
+
import { copyDirJsonlOnly } from './remap.ts';
|
|
34
|
+
import { encodePath, nowTimestamp, readJson } from './utils.ts';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Result of staging the scan tree. `malformed` is true when `path-map.json`
|
|
38
|
+
* exists but does not parse as JSON; the caller emits a FAIL row and stops
|
|
39
|
+
* (mirroring `reportPathMap`'s `readJsonSafe` degradation) rather than letting
|
|
40
|
+
* the `SyntaxError` propagate past `nomad.ts`'s `NomadFatal`-only handler and
|
|
41
|
+
* abort the whole doctor run with a stack trace.
|
|
42
|
+
*/
|
|
43
|
+
type ScanTree = {
|
|
44
|
+
logicalToEncoded: Map<string, string>;
|
|
45
|
+
staged: number;
|
|
46
|
+
malformed: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the temp staging tree under `tmpRoot/shared/projects/<logical>/` by
|
|
51
|
+
* copying each local encoded session dir that resolves to a path-map logical
|
|
52
|
+
* for this host. Returns the `logical -> encoded-dir` association so the
|
|
53
|
+
* scrub-path hint can name the live `~/.claude/projects/<encoded>/<sid>.jsonl`
|
|
54
|
+
* file, plus the count of session dirs staged. Skips `TBD`/unmapped entries
|
|
55
|
+
* (the D-03 scope: exactly what `remapPush` would stage). Uses the same
|
|
56
|
+
* depth-0 `*.jsonl` filter as push via `copyDirJsonlOnly`. A malformed
|
|
57
|
+
* `path-map.json` sets `malformed: true` rather than throwing.
|
|
58
|
+
*/
|
|
59
|
+
function buildScanTree(tmpRoot: string): ScanTree {
|
|
60
|
+
const logicalToEncoded = new Map<string, string>();
|
|
61
|
+
let staged = 0;
|
|
62
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
63
|
+
if (!existsSync(mapPath)) return { logicalToEncoded, staged, malformed: false };
|
|
64
|
+
let map: PathMap;
|
|
65
|
+
try {
|
|
66
|
+
map = readJson<PathMap>(mapPath);
|
|
67
|
+
} catch {
|
|
68
|
+
return { logicalToEncoded, staged, malformed: true };
|
|
69
|
+
}
|
|
70
|
+
if (typeof map.projects !== 'object' || map.projects === null) {
|
|
71
|
+
return { logicalToEncoded, staged, malformed: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const reverse = new Map<string, string>();
|
|
75
|
+
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
76
|
+
if (typeof hosts !== 'object' || hosts === null) continue;
|
|
77
|
+
const p = hosts[HOST];
|
|
78
|
+
if (!p || p === 'TBD') continue;
|
|
79
|
+
reverse.set(encodePath(p), logical);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const localProjects = join(CLAUDE_HOME, 'projects');
|
|
83
|
+
if (!existsSync(localProjects)) return { logicalToEncoded, staged, malformed: false };
|
|
84
|
+
for (const dir of readdirSync(localProjects)) {
|
|
85
|
+
const logical = reverse.get(dir);
|
|
86
|
+
if (!logical) continue;
|
|
87
|
+
copyDirJsonlOnly(join(localProjects, dir), join(tmpRoot, 'shared', 'projects', logical));
|
|
88
|
+
logicalToEncoded.set(logical, dir);
|
|
89
|
+
staged++;
|
|
90
|
+
}
|
|
91
|
+
return { logicalToEncoded, staged, malformed: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Probe for the gitleaks binary on PATH, distinguishing the not-installed case
|
|
96
|
+
* (ENOENT -> `'missing'`, a WARN skip per the read-only doctor contract) from a
|
|
97
|
+
* real probe failure (EACCES, corrupt binary -> `{ fail: message }`, a FAIL).
|
|
98
|
+
* Mirrors `reportGitleaksProbe`'s ENOENT-vs-other split rather than collapsing
|
|
99
|
+
* every failure into "not on PATH". Probes directly (not via `probeGitleaks`)
|
|
100
|
+
* so the doctor flavor stays read-only and need not unwrap a `NomadFatal`.
|
|
101
|
+
*/
|
|
102
|
+
function probeGitleaksForScan(): 'ok' | 'missing' | { fail: string } {
|
|
103
|
+
try {
|
|
104
|
+
execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
105
|
+
return 'ok';
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 'missing';
|
|
108
|
+
return { fail: (err as Error).message };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Recover the live encoded-dir for a finding by mapping its `<logical>`
|
|
114
|
+
* segment through the staging association. Returns the absolute live
|
|
115
|
+
* transcript path `~/.claude/projects/<encoded>/<sid>.jsonl`, falling back to
|
|
116
|
+
* the logical name when the association is missing (defensive; the temp-tree
|
|
117
|
+
* model guarantees a hit).
|
|
118
|
+
*/
|
|
119
|
+
function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
|
|
120
|
+
/* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
|
|
121
|
+
const encoded = logicalToEncoded.get(logical) ?? logical;
|
|
122
|
+
return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Emit one fail row per affected session plus rotate-and-scrub + allowlist
|
|
127
|
+
* guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
|
|
128
|
+
* `<logical>` segment captured from the same `SESSION_PATH` match that keyed
|
|
129
|
+
* `bySession`, so the scrub-path hint reuses the authoritative parse rather
|
|
130
|
+
* than re-deriving the logical name from the finding `File`. Every `bySession`
|
|
131
|
+
* sid is keyed in `logicalBySession` (both come from the identical sid capture),
|
|
132
|
+
* so the scrub hint always renders; the guard omits the hint rather than
|
|
133
|
+
* printing a wrong path if that invariant ever breaks, and the leak row itself
|
|
134
|
+
* is always emitted.
|
|
135
|
+
*/
|
|
136
|
+
function reportSessionFindings(
|
|
137
|
+
section: DoctorSection,
|
|
138
|
+
bySession: Map<string, Map<string, number>>,
|
|
139
|
+
logicalBySession: Map<string, string>,
|
|
140
|
+
logicalToEncoded: Map<string, string>,
|
|
141
|
+
): void {
|
|
142
|
+
for (const [sid, counts] of bySession) {
|
|
143
|
+
const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
|
|
144
|
+
addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
|
|
145
|
+
const logical = logicalBySession.get(sid);
|
|
146
|
+
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
147
|
+
if (logical !== undefined) {
|
|
148
|
+
addItem(
|
|
149
|
+
section,
|
|
150
|
+
` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
|
|
154
|
+
}
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Emit one fail row per non-session ("other"-bucket) finding and set
|
|
160
|
+
* `process.exitCode = 1`. These are findings whose `File` did not match the
|
|
161
|
+
* flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
|
|
162
|
+
* etc., which `copyDirJsonlOnly` copies recursively and `nomad push` would
|
|
163
|
+
* stage). Names the repo-relative path and RuleID only, never the matched
|
|
164
|
+
* secret. Mirrors the push-side guarantee that any finding outside `bySession`
|
|
165
|
+
* still fails the scan (`buildSessionAwareFatal`'s `LEGACY_FATAL` fallback).
|
|
166
|
+
*/
|
|
167
|
+
function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
|
|
168
|
+
for (const f of other) {
|
|
169
|
+
addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
|
|
170
|
+
}
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Captures both the `<logical>` segment and the `<sid>` from a repo-relative
|
|
176
|
+
* `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
|
|
177
|
+
* the exported `SESSION_PATH` shape; the extra `<logical>` group lets the
|
|
178
|
+
* scrub-path hint reuse this single authoritative parse.
|
|
179
|
+
*/
|
|
180
|
+
const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Emit the single canonical clean row reporting the scanned-project count
|
|
184
|
+
* (`staged` is the number of mapped project directories whose transcripts were
|
|
185
|
+
* staged, not a transcript total). Centralizing the literal (zero-staged,
|
|
186
|
+
* scanned-clean, and the findings-but-no-`other` paths all route through here)
|
|
187
|
+
* keeps the phrasing consistent and prevents one copy drifting from another,
|
|
188
|
+
* which is what let a "no session findings == clean" path slip past the
|
|
189
|
+
* `other`-bucket gate.
|
|
190
|
+
*/
|
|
191
|
+
function emitClean(section: DoctorSection, staged: number): void {
|
|
192
|
+
addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run the `--check-shared` preflight and append its rows to `section`.
|
|
197
|
+
*
|
|
198
|
+
* Flow (D-01..D-10): probe gitleaks (missing -> one WARN row, exit untouched;
|
|
199
|
+
* a non-ENOENT probe failure -> FAIL row + exit 1, mirroring
|
|
200
|
+
* `reportGitleaksProbe`); stage a temp copy of this-host mapped session dirs
|
|
201
|
+
* (a malformed `path-map.json` -> FAIL row + exit 1, no crash); scan with the
|
|
202
|
+
* positional `gitleaks dir shared/projects` invocation (NOT `--source`, which
|
|
203
|
+
* `gitleaks dir` rejects with exit 126); on a clean scan emit one ok row
|
|
204
|
+
* reporting the scanned-project count; on findings emit per-session fail rows
|
|
205
|
+
* with rotate-and-scrub guidance and set `process.exitCode = 1`; on a non-zero
|
|
206
|
+
* exit with no parseable report emit a scan-failed fail row carrying the
|
|
207
|
+
* gitleaks error message (never its stderr/stdout, which may hold secrets) +
|
|
208
|
+
* exit 1 (do not chase phantom sessions). Removes both the temp report and the
|
|
209
|
+
* temp tree in `finally` on success and failure. Never writes to stderr
|
|
210
|
+
* (read-only doctor contract).
|
|
211
|
+
*
|
|
212
|
+
* `gitleaksReady` lets the doctor orchestrator pass the result of the
|
|
213
|
+
* Repository section's gitleaks probe so the `version` subcommand is not
|
|
214
|
+
* invoked a second time on a `--check-shared` run. When omitted (the module's
|
|
215
|
+
* standalone contract) this reporter probes for itself.
|
|
216
|
+
*/
|
|
217
|
+
export function reportCheckShared(section: DoctorSection, gitleaksReady?: boolean): void {
|
|
218
|
+
if (gitleaksReady !== true) {
|
|
219
|
+
const probe = probeGitleaksForScan();
|
|
220
|
+
if (probe === 'missing') {
|
|
221
|
+
addItem(section, `${yellow(warnGlyph)} gitleaks not on PATH; shared scan skipped`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (probe !== 'ok') {
|
|
225
|
+
addItem(section, `${red(failGlyph)} gitleaks probe failed: ${probe.fail}`);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const cacheDir = join(homedir(), '.cache', 'claude-nomad');
|
|
232
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
233
|
+
// nowTimestamp() is second-resolution and --check-shared takes no lock
|
|
234
|
+
// (read-only), so two same-second, same-pid invocations would otherwise
|
|
235
|
+
// share a stamp and clobber each other's temp tree / report. The random
|
|
236
|
+
// suffix makes the stamp collision-resistant, matching the push report path.
|
|
237
|
+
const stamp = `${nowTimestamp()}-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
238
|
+
const reportPath = join(cacheDir, `check-shared-${stamp}.json`);
|
|
239
|
+
const tmpRoot = join(cacheDir, `check-shared-tree-${stamp}`);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const { logicalToEncoded, staged, malformed } = buildScanTree(tmpRoot);
|
|
243
|
+
if (malformed) {
|
|
244
|
+
addItem(section, `${red(failGlyph)} path-map.json malformed JSON; shared scan skipped`);
|
|
245
|
+
process.exitCode = 1;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (staged === 0) {
|
|
249
|
+
// No path-map entry maps to this host (or all are TBD). Nothing would be
|
|
250
|
+
// staged by push either, so report clean without invoking gitleaks (a
|
|
251
|
+
// scan of a non-existent dir would exit non-zero and misfire).
|
|
252
|
+
emitClean(section, 0);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const tomlPath = join(REPO_HOME, '.gitleaks.toml');
|
|
256
|
+
const args: string[] = [
|
|
257
|
+
'dir',
|
|
258
|
+
'shared/projects',
|
|
259
|
+
'--redact',
|
|
260
|
+
'-v',
|
|
261
|
+
'--report-format=json',
|
|
262
|
+
`--report-path=${reportPath}`,
|
|
263
|
+
];
|
|
264
|
+
if (existsSync(tomlPath)) args.push('--config', tomlPath);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
execFileSync('gitleaks', args, { cwd: tmpRoot, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
268
|
+
emitClean(section, staged);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
const findings = readGitleaksReport(reportPath);
|
|
271
|
+
if (findings === null) {
|
|
272
|
+
// Carry the gitleaks error message only (never e.stderr/e.stdout, which
|
|
273
|
+
// can echo the redacted-but-still-sensitive scan output), matching
|
|
274
|
+
// runGitleaksScan on the push side.
|
|
275
|
+
const msg = (err as Error).message;
|
|
276
|
+
addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report (${msg})`);
|
|
277
|
+
process.exitCode = 1;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const { bySession, other } = partitionFindings(findings);
|
|
281
|
+
// Both buckets must gate the clean row. A finding routed to `other`
|
|
282
|
+
// (nested transcripts that match neither the flat SESSION_PATH nor any
|
|
283
|
+
// session) is still a stageable secret push would catch, so reporting
|
|
284
|
+
// clean on `bySession.size === 0` alone would make the preflight weaker
|
|
285
|
+
// than the push scan it stands in for.
|
|
286
|
+
if (bySession.size === 0 && other.length === 0) {
|
|
287
|
+
emitClean(section, staged);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (other.length > 0) reportOtherFindings(section, other);
|
|
291
|
+
if (bySession.size > 0) {
|
|
292
|
+
// Capture <logical> alongside <sid> from the same authoritative match
|
|
293
|
+
// so the scrub hint never re-derives the logical name independently.
|
|
294
|
+
const logicalBySession = new Map<string, string>();
|
|
295
|
+
for (const f of findings) {
|
|
296
|
+
const m = SESSION_PATH_LOGICAL.exec(f.File);
|
|
297
|
+
if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
|
|
298
|
+
/* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
|
|
299
|
+
logicalBySession.set(m[2], m[1] ?? '');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
reportSessionFindings(section, bySession, logicalBySession, logicalToEncoded);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(reportPath, { force: true });
|
|
307
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
}
|