claude-nomad 0.26.2 → 0.28.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 +20 -0
- package/README.md +132 -31
- package/package.json +1 -1
- package/src/commands.doctor.check-schema.ts +72 -0
- package/src/commands.doctor.format.ts +2 -81
- package/src/commands.doctor.ts +20 -2
- package/src/commands.drop-session.scrub-hint.ts +72 -0
- package/src/commands.drop-session.ts +5 -1
- package/src/commands.pull.ts +69 -13
- package/src/commands.push.sections.ts +171 -0
- package/src/commands.push.ts +136 -71
- package/src/config.ts +13 -42
- package/src/extras-sync.core.ts +96 -0
- package/src/extras-sync.remap.ts +138 -0
- package/src/extras-sync.ts +22 -168
- package/src/links.ts +14 -3
- package/src/nomad.help.ts +2 -0
- package/src/nomad.ts +9 -4
- package/src/output-tree.ts +91 -0
- package/src/push-leak-verdict.ts +154 -0
- package/src/push-preview.ts +160 -0
- package/src/remap.ts +46 -27
- package/src/settings-keys.ts +124 -0
- package/src/summary.ts +75 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.28.0](https://github.com/funkadelic/claude-nomad/compare/v0.27.0...v0.28.0) (2026-05-28)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **doctor:** settings schema drift tooling (auto-sync PR + --check-schema) ([#168](https://github.com/funkadelic/claude-nomad/issues/168)) ([ac4ac21](https://github.com/funkadelic/claude-nomad/commit/ac4ac21f90148d7261b0b907bfdad43b3758f9fd))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
* **doctor:** resync KNOWN_SETTINGS_KEYS with official settings schema ([#166](https://github.com/funkadelic/claude-nomad/issues/166)) ([2b453e1](https://github.com/funkadelic/claude-nomad/commit/2b453e18c18520dd0a4df035ace3825709097bc1))
|
|
14
|
+
* drop-session scrub hint and README rendering/layout fixes ([#165](https://github.com/funkadelic/claude-nomad/issues/165)) ([0840ab4](https://github.com/funkadelic/claude-nomad/commit/0840ab408b72174b23532a0ea32c27df522cfe39))
|
|
15
|
+
|
|
16
|
+
## [0.27.0](https://github.com/funkadelic/claude-nomad/compare/v0.26.2...v0.27.0) (2026-05-28)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
* **output:** grouped tree output for push/pull and dry-run leak preview ([#163](https://github.com/funkadelic/claude-nomad/issues/163)) ([fff6f1e](https://github.com/funkadelic/claude-nomad/commit/fff6f1e28116b072ee4eceda36d87c13f4c1bc1c))
|
|
22
|
+
|
|
3
23
|
## [0.26.2](https://github.com/funkadelic/claude-nomad/compare/v0.26.1...v0.26.2) (2026-05-28)
|
|
4
24
|
|
|
5
25
|
|
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
|
|
|
67
67
|
## Quickstart
|
|
68
68
|
|
|
69
69
|
If you already have a private **claude-nomad** mirror (see [Setup](#setup) for the one-time
|
|
70
|
-
bootstrap), adding a new host is
|
|
70
|
+
bootstrap), adding a new host is two one-time steps, then the everyday loop:
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
73
|
$ npm i -g claude-nomad
|
|
@@ -157,16 +157,28 @@ so a clobbered dotfile variable does not break the CLI.
|
|
|
157
157
|
|
|
158
158
|
## What gets synced vs. not
|
|
159
159
|
|
|
160
|
-
| Category | Items
|
|
161
|
-
| ---------------------- |
|
|
162
|
-
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs`
|
|
163
|
-
| **Generated** | `settings.json`
|
|
164
|
-
| **Remapped** | `projects/` session transcripts
|
|
165
|
-
| **Per-project extras** |
|
|
166
|
-
| **Never synced** |
|
|
167
|
-
| **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...`
|
|
168
|
-
|
|
169
|
-
|
|
160
|
+
| Category | Items | Behavior |
|
|
161
|
+
| ---------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
162
|
+
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/`. |
|
|
163
|
+
| **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`; rewritten every pull. |
|
|
164
|
+
| **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
|
|
165
|
+
| **Per-project extras** | Whitelisted dirs like `.planning/`, or a root file like `CLAUDE.md` | Opt-in via the `extras` field in `path-map.json`; mirrored to/from `shared/extras/<logical>/`. |
|
|
166
|
+
| **Never synced** | OAuth and MCP state, shell history, per-host overrides, caches, scratch dirs | Per-host ephemeral state; left untouched in both directions. |
|
|
167
|
+
| **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Re-downloaded by Claude Code from the `enabledPlugins` list; no per-host install. |
|
|
168
|
+
|
|
169
|
+
Pointers and specifics:
|
|
170
|
+
|
|
171
|
+
- **Synced** link names live in `SHARED_LINKS`, **whitelisted extras** names in `SUPPORTED_EXTRAS`,
|
|
172
|
+
and the full **never-synced** set in `NEVER_SYNC` (all in `src/config.ts`).
|
|
173
|
+
- **Never synced**, in full: `~/.claude.json` (OAuth, MCP state), `history.jsonl`,
|
|
174
|
+
`settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`,
|
|
175
|
+
`debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/`.
|
|
176
|
+
- **Per-project extras** run a pre-pull divergence WARN that flags local edits before they get
|
|
177
|
+
overwritten.
|
|
178
|
+
|
|
179
|
+
<!-- prettier-ignore -->
|
|
180
|
+
> [!NOTE]
|
|
181
|
+
> Plugins that depend on host-specific state (external binaries, API keys in env, MCP server
|
|
170
182
|
> URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's
|
|
171
183
|
> own per-host config.
|
|
172
184
|
|
|
@@ -197,7 +209,9 @@ block opts a project into syncing whitelisted directories (or a single root file
|
|
|
197
209
|
}
|
|
198
210
|
```
|
|
199
211
|
|
|
200
|
-
|
|
212
|
+
<!-- prettier-ignore -->
|
|
213
|
+
> [!IMPORTANT]
|
|
214
|
+
> The host-label keys must match whatever you set `NOMAD_HOST=` to on each host (see
|
|
201
215
|
> [Setup](#setup)). Mismatched labels silently skip remap, so sessions land in the wrong host's
|
|
202
216
|
> encoded dir.
|
|
203
217
|
|
|
@@ -249,17 +263,25 @@ host-only model overrides).
|
|
|
249
263
|
|
|
250
264
|
```json
|
|
251
265
|
{
|
|
252
|
-
"model": "claude-opus-4-
|
|
266
|
+
"model": "claude-opus-4-8",
|
|
253
267
|
"env": { "OLLAMA_HOST": "http://localhost:11434" }
|
|
254
268
|
}
|
|
255
269
|
```
|
|
256
270
|
|
|
257
|
-
Results on `your-other-host`: opus 4.
|
|
271
|
+
Results on `your-other-host`: opus 4.8, the local Ollama env var, plus the shared permissions array.
|
|
258
272
|
|
|
259
|
-
|
|
273
|
+
<!-- prettier-ignore -->
|
|
274
|
+
> [!CAUTION]
|
|
275
|
+
> Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every
|
|
260
276
|
> `nomad pull` from base + host, so your edits will be clobbered. Edit the base or host file in the
|
|
261
277
|
> repo instead.
|
|
262
278
|
|
|
279
|
+
`nomad doctor` warns when `settings.json` carries a top-level key it does not recognize (a cue that
|
|
280
|
+
Claude Code added a setting). The recognized set is kept current against Claude Code's published
|
|
281
|
+
settings schema by a weekly automated PR in the public repo, so a periodic `nomad update` is what
|
|
282
|
+
keeps that warning quiet on your hosts. To check your own `settings.json` against the live schema on
|
|
283
|
+
demand, run `nomad doctor --check-schema`.
|
|
284
|
+
|
|
263
285
|
## What does NOT sync (deliberate trade-offs)
|
|
264
286
|
|
|
265
287
|
Read these before adopting so you opt in with eyes open.
|
|
@@ -300,9 +322,9 @@ Read these before adopting so you opt in with eyes open.
|
|
|
300
322
|
- `gh` ([GitHub CLI](https://cli.github.com/)), used only by `nomad init` to auto-disable Actions on
|
|
301
323
|
the private repo; if it is missing or unauthenticated, init prints a manual fallback tip and
|
|
302
324
|
continues
|
|
303
|
-
- [curl](https://curl.se/), used
|
|
304
|
-
|
|
305
|
-
the rest of the CLI works without it
|
|
325
|
+
- [curl](https://curl.se/), used by the version/update check (the `nomad doctor` latest-release line
|
|
326
|
+
and the post-`nomad update` check) and by `nomad doctor --check-schema`; it degrades silently when
|
|
327
|
+
curl is absent or offline, so the rest of the CLI works without it
|
|
306
328
|
|
|
307
329
|
## Setup
|
|
308
330
|
|
|
@@ -330,7 +352,9 @@ automatically:
|
|
|
330
352
|
Pass `--keep-actions` to either form of init to skip step 2 (for example, when your org already
|
|
331
353
|
enforces an Actions policy upstream).
|
|
332
354
|
|
|
333
|
-
|
|
355
|
+
<!-- prettier-ignore -->
|
|
356
|
+
> [!WARNING]
|
|
357
|
+
> If you ever flip the mirror to public, both protections evaporate: CI starts firing on
|
|
334
358
|
> every `nomad push` against `main`, and your session transcripts (which include conversation
|
|
335
359
|
> content) become world-readable. **Keep it private.**
|
|
336
360
|
|
|
@@ -525,12 +549,13 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
|
|
|
525
549
|
| `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. |
|
|
526
550
|
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
527
551
|
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
528
|
-
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage,
|
|
552
|
+
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
|
|
529
553
|
| `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). |
|
|
530
554
|
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
531
555
|
| `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. |
|
|
532
556
|
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
|
|
533
557
|
| `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). |
|
|
558
|
+
| `nomad doctor --check-schema` | Read-only: fetches the live Claude Code settings schema and lists any `~/.claude/settings.json` key absent from it (candidates for the hand-maintained `APP_ONLY_KEYS` list). Non-fatal and offline-tolerant: skips with a `⚠︎` when curl is missing or the schema is unreachable. |
|
|
534
559
|
| `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. |
|
|
535
560
|
|
|
536
561
|
The version-check emits ``⚠︎ claude-nomad: <local> -> <latest> (run `nomad update`)`` when the local
|
|
@@ -547,20 +572,83 @@ re-enabled, complementing the auto-disable that runs on `nomad init` (see
|
|
|
547
572
|
[Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub
|
|
548
573
|
origin, `gh` unauthed, public repo, or Actions already off).
|
|
549
574
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
`nomad
|
|
575
|
+
### Reading push and pull output
|
|
576
|
+
|
|
577
|
+
`nomad push` and `nomad pull` print a grouped tree, the same left-gutter layout you already see from
|
|
578
|
+
`nomad doctor`. There is a header line naming the command and host, then a few named sections
|
|
579
|
+
(`Sessions`, `Extras`, and so on), each with its items hanging off `├`/`└` connectors. A status
|
|
580
|
+
glyph leads every line: `✓` green for something that synced, `ℹ︎` dim for an informational count, `⚠︎`
|
|
581
|
+
yellow for a warning, and `✗` red for a failure. What this means for you: instead of one long flat
|
|
582
|
+
list with a line per project, related work is grouped and the noise is collapsed.
|
|
583
|
+
|
|
584
|
+
A clean `nomad push` looks like this (one `✓` row per project whose sessions were copied up, the
|
|
585
|
+
projects this host does not track folded into a single count, then the secret-scan result and a
|
|
586
|
+
one-line summary):
|
|
587
|
+
|
|
588
|
+
```text
|
|
589
|
+
push on host=workstation
|
|
590
|
+
Sessions
|
|
591
|
+
├ ✓ claude-nomad
|
|
592
|
+
├ ✓ my-side-project
|
|
593
|
+
└ ℹ︎ 4 not in path-map (run nomad doctor to list)
|
|
594
|
+
Extras
|
|
595
|
+
└ ✓ claude-nomad/.planning
|
|
596
|
+
Leak scan
|
|
597
|
+
└ ✓ no leaks
|
|
598
|
+
Summary
|
|
599
|
+
└ ✓ summary: clean
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
The `ℹ︎ 4 not in path-map` row is the collapse: rather than printing one line per project that this
|
|
603
|
+
host does not sync, push and pull now show a single count and point you at `nomad doctor`, which
|
|
604
|
+
lists those projects by name if you want the detail. The `Leak scan` section is the secret check
|
|
605
|
+
that runs before anything is published: `✓ no leaks` when the staged transcripts are clean. If a
|
|
606
|
+
secret IS found, that row turns into `✗ gitleaks detected secrets in N session transcript(s)` and
|
|
607
|
+
the full recovery block (which sessions, how to scrub them) still prints below the tree, exactly as
|
|
608
|
+
before (see
|
|
609
|
+
[Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)).
|
|
610
|
+
The same `Leak scan` row shows up under `nomad push --dry-run`, which runs that secret scan as a
|
|
611
|
+
read-only preview (nothing is written to the sync repo) and exits non-zero if the preview finds
|
|
612
|
+
anything.
|
|
613
|
+
|
|
614
|
+
A `nomad pull` is the mirror image, leading with the settings file it regenerated and then the
|
|
615
|
+
sessions and extras it copied down for this host:
|
|
616
|
+
|
|
617
|
+
```text
|
|
618
|
+
pull on host=workstation (backup=2026-05-27T14-02-09Z)
|
|
619
|
+
Settings
|
|
620
|
+
└ ✓ settings.json (base + workstation.json)
|
|
621
|
+
Sessions
|
|
622
|
+
├ ✓ claude-nomad
|
|
623
|
+
└ ℹ︎ 2 not in path-map (run nomad doctor to list)
|
|
624
|
+
Extras
|
|
625
|
+
└ ✓ claude-nomad/.planning
|
|
626
|
+
Summary
|
|
627
|
+
└ ✓ summary: clean
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
The `Summary` row is the final verdict for the run. It reads `✓ summary: clean` when everything
|
|
631
|
+
synced, or a `⚠︎` warning naming the counts when something was skipped:
|
|
553
632
|
|
|
554
633
|
```text
|
|
555
|
-
✓ summary: clean
|
|
556
634
|
⚠︎ summary: 3 unmapped on pull (run nomad doctor to list)
|
|
557
635
|
⚠︎ summary: 2 unmapped on push, 1 collisions (run nomad doctor to list)
|
|
558
636
|
```
|
|
559
637
|
|
|
560
|
-
`✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
638
|
+
`✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr. An early, pre-tree fatal abort (for example
|
|
639
|
+
gitleaks missing when push checks for it, or a rebase conflict before anything is staged) suppresses
|
|
640
|
+
the tree entirely, so you do not see "summary: clean" stacked under an error. A later leak-scan
|
|
641
|
+
finding is different: by then the tree has already been built, so it still renders in full with a
|
|
642
|
+
`✗` Leak scan row and the recovery block below it (see
|
|
643
|
+
[Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)).
|
|
644
|
+
Projects with no entry in `path-map.json` for this host count as unmapped and fold into the
|
|
645
|
+
collapsed `ℹ︎ ... not in path-map` count; the hint points at `nomad doctor`, which lists them by
|
|
646
|
+
logical name.
|
|
647
|
+
|
|
648
|
+
`nomad pull --dry-run` keeps its own readable preview format (a unified diff of the `settings.json`
|
|
649
|
+
changes plus the transcripts a real pull would overwrite) rather than the grouped tree, so that
|
|
650
|
+
preview stays easy to scan; only a real `nomad pull` prints the tree above. `nomad diff` is
|
|
651
|
+
unchanged.
|
|
564
652
|
|
|
565
653
|
## Recovery flows
|
|
566
654
|
|
|
@@ -600,12 +688,25 @@ REQUIRED for durability, not optional housekeeping: `remapPush` (in `src/remap.t
|
|
|
600
688
|
local content into the staged tree on the next push, so a drop without a local scrub re-stages the
|
|
601
689
|
same secret.
|
|
602
690
|
|
|
691
|
+
A successful drop prints this reminder inline, pointing at the live transcript that still needs
|
|
692
|
+
scrubbing (the exact path when `path-map.json` maps the project to the current host, a generic
|
|
693
|
+
`~/.claude/projects/<encoded>/<id>.jsonl` template otherwise). This is why a
|
|
694
|
+
`nomad doctor --check-shared` run still reports the session after a drop: that scan reads the live
|
|
695
|
+
`~/.claude/projects/` source, not the staged tree, so it keeps flagging the secret until the local
|
|
696
|
+
transcript is scrubbed.
|
|
697
|
+
|
|
603
698
|
### Recovery flow: gitleaks FATAL on a session JSONL
|
|
604
699
|
|
|
605
700
|
`nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you
|
|
606
|
-
push (and without mutating anything),
|
|
607
|
-
|
|
608
|
-
|
|
701
|
+
push (and without mutating anything), two read-only options are available:
|
|
702
|
+
`nomad doctor --check-shared` scans the session transcripts a push would publish;
|
|
703
|
+
`nomad push --dry-run` runs the same scan AND also covers opted-in extras (`.planning`,
|
|
704
|
+
`CLAUDE.md`), which `--check-shared` does not. Both stage content into a throwaway temp copy and
|
|
705
|
+
never write to the sync repo. A leak-scan finding is the contrast to an early, pre-tree fatal:
|
|
706
|
+
because the scan runs after the tree is built, the push aborts but the grouped tree still renders in
|
|
707
|
+
full, with a `✗ gitleaks detected secrets in N session transcript(s)` row in its `Leak scan`
|
|
708
|
+
section, and then the full recovery block prints below it, naming every affected session id and the
|
|
709
|
+
recovery command:
|
|
609
710
|
|
|
610
711
|
```text
|
|
611
712
|
✗ gitleaks detected secrets in 1 session transcript(s).
|
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { dim, green, infoGlyph, okGlyph, warnGlyph, yellow } from './color.ts';
|
|
6
|
+
import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
|
|
7
|
+
import { CLAUDE_HOME, SETTINGS_SCHEMA_URL } from './config.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Opt-in `nomad doctor --check-schema` reporter. Fetches the live Claude Code
|
|
11
|
+
* settings JSON schema and lists any top-level key in this host's
|
|
12
|
+
* `~/.claude/settings.json` that the published schema does not define, i.e.
|
|
13
|
+
* candidates for the hand-maintained `APP_ONLY_KEYS` list. Offline-tolerant by
|
|
14
|
+
* design (mirrors the release version check): curl missing, a network failure,
|
|
15
|
+
* or a malformed schema all degrade to a single `⚠︎` skip line. Never sets
|
|
16
|
+
* `process.exitCode`; this is informational discovery, not a gate.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch the live settings schema via curl and return its top-level property
|
|
21
|
+
* names. curl is optional (matches the version check): a missing binary,
|
|
22
|
+
* non-2xx response, or malformed payload all surface as `null` so the caller
|
|
23
|
+
* skips cleanly. 3s timeout, fail-fast (`-f`), silent (`-s`), follow redirects.
|
|
24
|
+
*/
|
|
25
|
+
function fetchSchemaKeys(): string[] | null {
|
|
26
|
+
try {
|
|
27
|
+
const raw = execFileSync('curl', ['-fsSL', '-m', '3', SETTINGS_SCHEMA_URL], {
|
|
28
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
29
|
+
}).toString();
|
|
30
|
+
const parsed = JSON.parse(raw) as { properties?: Record<string, unknown> };
|
|
31
|
+
if (typeof parsed.properties !== 'object' || parsed.properties === null) return null;
|
|
32
|
+
return Object.keys(parsed.properties);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Append the `--check-schema` result to the supplied section: an info line when
|
|
40
|
+
* there is no local settings.json, a `⚠︎` skip when the schema cannot be
|
|
41
|
+
* fetched, an OK line when every key is in the schema, or a `⚠︎` line naming the
|
|
42
|
+
* keys absent from it (APP_ONLY_KEYS candidates).
|
|
43
|
+
*/
|
|
44
|
+
export function reportCheckSchema(section: DoctorSection): void {
|
|
45
|
+
const settingsPath = join(CLAUDE_HOME, 'settings.json');
|
|
46
|
+
if (!existsSync(settingsPath)) {
|
|
47
|
+
addItem(section, `${dim(infoGlyph)} no ~/.claude/settings.json to check`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const settings = readJsonSafe<Record<string, unknown>>(settingsPath, settingsPath, section);
|
|
51
|
+
if (settings === null) return;
|
|
52
|
+
|
|
53
|
+
const liveKeys = fetchSchemaKeys();
|
|
54
|
+
if (liveKeys === null) {
|
|
55
|
+
addItem(
|
|
56
|
+
section,
|
|
57
|
+
`${yellow(warnGlyph)} schema check skipped (offline, curl missing, or schema unreachable)`,
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const liveSet = new Set(liveKeys);
|
|
63
|
+
const candidates = Object.keys(settings).filter((k) => !liveSet.has(k));
|
|
64
|
+
if (candidates.length === 0) {
|
|
65
|
+
addItem(section, `${green(okGlyph)} settings.json keys all present in the published schema`);
|
|
66
|
+
} else {
|
|
67
|
+
addItem(
|
|
68
|
+
section,
|
|
69
|
+
`${yellow(warnGlyph)} settings.json keys absent from published schema (APP_ONLY_KEYS candidates): ${candidates.join(', ')}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -1,43 +1,8 @@
|
|
|
1
1
|
import { failGlyph, red } from './color.ts';
|
|
2
|
+
import { addItem, type DoctorSection } from './output-tree.ts';
|
|
2
3
|
import { readJson } from './utils.json.ts';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
* Bare `failGlyph` codepoint (`✗`, U+2717) without any WSL padding the
|
|
6
|
-
* `failGlyph` constant may carry. Header rendering composes its own
|
|
7
|
-
* spacing (`${red(failGlyph)} ${header}`), so the section-header path
|
|
8
|
-
* must use the unpadded codepoint to avoid a double space on WSL.
|
|
9
|
-
*/
|
|
10
|
-
const FAIL_GLYPH_BARE = '✗';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Tree-style output builder for `cmdDoctor`. Doctor builds an ordered list of
|
|
14
|
-
* `DoctorSection`s, each reporter pushes plain-text items into the relevant
|
|
15
|
-
* section, then the orchestrator calls `renderDoctor` to emit a Claude Code
|
|
16
|
-
* `/doctor`-style tree (`Header` / ` ├ item` / ` └ last`) on stdout.
|
|
17
|
-
*
|
|
18
|
-
* Color and status glyphs (okGlyph/warnGlyph/failGlyph/infoGlyph) already
|
|
19
|
-
* live inside the item text; this module never re-colors or re-tokenizes.
|
|
20
|
-
* Sections with zero items are skipped at render time (no empty headers).
|
|
21
|
-
*
|
|
22
|
-
* Output goes directly through `console.log` rather than `utils.log` so the
|
|
23
|
-
* dim `ℹ︎` info glyph used by `pull` / `push` / `init` does NOT appear in
|
|
24
|
-
* doctor output (doctor has its own glyphs per row). Test assertions continue
|
|
25
|
-
* to spy on `console.log`.
|
|
26
|
-
*/
|
|
27
|
-
export type DoctorSection = {
|
|
28
|
-
header: string;
|
|
29
|
-
items: string[];
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** Construct an empty section with the given header. */
|
|
33
|
-
export function section(header: string): DoctorSection {
|
|
34
|
-
return { header, items: [] };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Append one rendered line to a section. */
|
|
38
|
-
export function addItem(s: DoctorSection, text: string): void {
|
|
39
|
-
s.items.push(text);
|
|
40
|
-
}
|
|
5
|
+
export { section, addItem, renderTree, renderDoctor, type DoctorSection } from './output-tree.ts';
|
|
41
6
|
|
|
42
7
|
/**
|
|
43
8
|
* Tolerant JSON reader for `cmdDoctor`. Doctor reads three JSON files
|
|
@@ -55,47 +20,3 @@ export function readJsonSafe<T>(path: string, label: string, section: DoctorSect
|
|
|
55
20
|
return null;
|
|
56
21
|
}
|
|
57
22
|
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* True when any item in the section contains the FAIL glyph.
|
|
61
|
-
* Color-wrapped failGlyph (`[31m✗[39m`) still contains the
|
|
62
|
-
* glyph as a substring, so this works for both color-on and color-off output.
|
|
63
|
-
*/
|
|
64
|
-
function sectionFailed(s: DoctorSection): boolean {
|
|
65
|
-
return s.items.some((line) => line.includes(failGlyph));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Emit the full doctor report. Skips empty sections, prefixes failed-section
|
|
70
|
-
* headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
|
|
71
|
-
* `grep -F '✗'` catches both row and header failures), and writes one blank
|
|
72
|
-
* line between rendered sections (no leading or trailing blank).
|
|
73
|
-
*
|
|
74
|
-
* An empty-string item renders as a true blank line (no tree connector), which
|
|
75
|
-
* lets a reporter set off a footer block (e.g. the `--check-shared` description
|
|
76
|
-
* legend) with vertical whitespace. The `└` connector attaches to the last
|
|
77
|
-
* non-empty item rather than the last array slot so a trailing blank does not
|
|
78
|
-
* strand the elbow on an empty line.
|
|
79
|
-
*/
|
|
80
|
-
/**
|
|
81
|
-
* Render one section: a (possibly fail-glyph-prefixed) header followed by its
|
|
82
|
-
* items as a tree. Empty-string items print as true blank lines; the `└` elbow
|
|
83
|
-
* attaches to the last non-empty item so a trailing blank cannot strand it.
|
|
84
|
-
*/
|
|
85
|
-
function renderSection(s: DoctorSection): void {
|
|
86
|
-
const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
|
|
87
|
-
console.log(header);
|
|
88
|
-
const lastContent = s.items.reduce((acc, item, j) => (item !== '' ? j : acc), -1);
|
|
89
|
-
for (let j = 0; j < s.items.length; j++) {
|
|
90
|
-
if (s.items[j] === '') console.log('');
|
|
91
|
-
else console.log(`${j === lastContent ? ' └ ' : ' ├ '}${s.items[j]}`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function renderDoctor(sections: DoctorSection[]): void {
|
|
96
|
-
const visible = sections.filter((s) => s.items.length > 0);
|
|
97
|
-
for (let i = 0; i < visible.length; i++) {
|
|
98
|
-
if (i > 0) console.log('');
|
|
99
|
-
renderSection(visible[i]);
|
|
100
|
-
}
|
|
101
|
-
}
|
package/src/commands.doctor.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
reportRebaseClean,
|
|
16
16
|
reportRemote,
|
|
17
17
|
} from './commands.doctor.checks.repository.ts';
|
|
18
|
+
import { reportCheckSchema } from './commands.doctor.check-schema.ts';
|
|
18
19
|
import { reportCheckShared } from './commands.doctor.check-shared.ts';
|
|
19
20
|
import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
|
|
20
21
|
import { renderDoctor, section } from './commands.doctor.format.ts';
|
|
@@ -35,8 +36,12 @@ import { reportVersionCheck } from './commands.doctor.version.ts';
|
|
|
35
36
|
* section that runs the gitleaks preflight over the session transcripts a
|
|
36
37
|
* `nomad push` would stage. It is OFF by default so plain `nomad doctor`
|
|
37
38
|
* stays the fast read-only smoke test (no scan, no temp tree).
|
|
39
|
+
*
|
|
40
|
+
* `opts.checkSchema` (the `--check-schema` sub-flag) appends a "Schema scan"
|
|
41
|
+
* section that fetches the live settings schema and flags local settings.json
|
|
42
|
+
* keys absent from it. Also OFF by default (it needs the network).
|
|
38
43
|
*/
|
|
39
|
-
export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
|
|
44
|
+
export function cmdDoctor(opts: { checkShared?: boolean; checkSchema?: boolean } = {}): void {
|
|
40
45
|
const host = section('Host');
|
|
41
46
|
reportHostAndPaths(host);
|
|
42
47
|
reportRepoState(host);
|
|
@@ -74,5 +79,18 @@ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
|
|
|
74
79
|
// drift check above spawns `gitleaks version` separately, by design.)
|
|
75
80
|
if (opts.checkShared === true) reportCheckShared(sharedScan, gitleaksReady);
|
|
76
81
|
|
|
77
|
-
|
|
82
|
+
const schemaScan = section('Schema scan');
|
|
83
|
+
if (opts.checkSchema === true) reportCheckSchema(schemaScan);
|
|
84
|
+
|
|
85
|
+
renderDoctor([
|
|
86
|
+
version,
|
|
87
|
+
host,
|
|
88
|
+
links,
|
|
89
|
+
settings,
|
|
90
|
+
pathMap,
|
|
91
|
+
neverSync,
|
|
92
|
+
repository,
|
|
93
|
+
sharedScan,
|
|
94
|
+
schemaScan,
|
|
95
|
+
]);
|
|
78
96
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
|
+
import { log } from './utils.ts';
|
|
6
|
+
import { encodePath, readJson } from './utils.json.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Repo-relative session-match shape `shared/projects/<logical>/...`; the single
|
|
10
|
+
* capture group is the `<logical>` segment fed to the path-map reverse lookup.
|
|
11
|
+
*/
|
|
12
|
+
const SHARED_PROJECT_LOGICAL = /^shared\/projects\/([^/]+)\//;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* After a successful drop, remind the operator that the unstage is per-push
|
|
16
|
+
* only: the leaked secret still lives in the local transcript, so the next
|
|
17
|
+
* `nomad push` re-copies it (via `remapPush`) and `nomad doctor --check-shared`
|
|
18
|
+
* keeps reporting it (it scans the live `~/.claude/projects/` source, not the
|
|
19
|
+
* repo index). Points at the exact live transcript when it resolves for this
|
|
20
|
+
* host, or a generic `~/.claude/projects/<encoded>/<id>.jsonl` template
|
|
21
|
+
* otherwise. Advisory output only; never mutates state.
|
|
22
|
+
*
|
|
23
|
+
* @param id Already-validated session id.
|
|
24
|
+
* @param matches Repo-relative paths collected by `collectMatches`.
|
|
25
|
+
*/
|
|
26
|
+
export function reportScrubHint(id: string, matches: string[]): void {
|
|
27
|
+
const live = resolveLiveTranscript(id, matches);
|
|
28
|
+
const target = live ?? `~/.claude/projects/<encoded>/${id}.jsonl`;
|
|
29
|
+
log(
|
|
30
|
+
'note: this only un-stages the session from the next push. The leaked secret\n' +
|
|
31
|
+
' is still in your local transcript, so nomad push re-stages it and nomad\n' +
|
|
32
|
+
' doctor --check-shared keeps reporting it. To remediate, rotate the\n' +
|
|
33
|
+
` credential, then scrub ${target}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reverse-map a dropped session to its live transcript
|
|
39
|
+
* `~/.claude/projects/<encoded>/<id>.jsonl` on THIS host via `path-map.json`
|
|
40
|
+
* (`<logical>` -> `hosts[HOST]` -> `encodePath`). Best-effort: returns the path
|
|
41
|
+
* only when it resolves AND exists on disk; null when `path-map.json` is
|
|
42
|
+
* absent or malformed, no match maps to this host, or the live file is already
|
|
43
|
+
* gone. A `'TBD'` host placeholder also yields null (its bogus path never
|
|
44
|
+
* exists). The whole body is wrapped so a malformed map (parse error, `null`
|
|
45
|
+
* projects) degrades to the generic hint instead of crashing the drop.
|
|
46
|
+
*
|
|
47
|
+
* @param id Already-validated session id.
|
|
48
|
+
* @param matches Repo-relative paths collected by `collectMatches`.
|
|
49
|
+
* @returns Absolute live transcript path, or null when unresolvable.
|
|
50
|
+
*/
|
|
51
|
+
function resolveLiveTranscript(id: string, matches: string[]): string | null {
|
|
52
|
+
try {
|
|
53
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
54
|
+
if (!existsSync(mapPath)) return null;
|
|
55
|
+
const projects = readJson<PathMap>(mapPath).projects;
|
|
56
|
+
for (const rel of matches) {
|
|
57
|
+
const logical = SHARED_PROJECT_LOGICAL.exec(rel)?.[1];
|
|
58
|
+
/* c8 ignore next -- defensive: every collectMatches path is rooted at shared/projects/<logical>/ */
|
|
59
|
+
if (logical === undefined) continue;
|
|
60
|
+
const abs = projects[logical]?.[HOST];
|
|
61
|
+
// A 'TBD' host placeholder needs no special case: encodePath('TBD') yields
|
|
62
|
+
// a directory that cannot exist among the absolute-path-encoded dirs, so
|
|
63
|
+
// the existsSync guard below rejects it and falls through to the generic.
|
|
64
|
+
if (abs === undefined) continue;
|
|
65
|
+
const live = join(CLAUDE_HOME, 'projects', encodePath(abs), `${id}.jsonl`);
|
|
66
|
+
if (existsSync(live)) return live;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -4,6 +4,7 @@ import { join, relative } from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { REPO_HOME } from './config.ts';
|
|
6
6
|
import { expandStagedDir, isInIndex, isTrackedInHead } from './commands.drop-session.git.ts';
|
|
7
|
+
import { reportScrubHint } from './commands.drop-session.scrub-hint.ts';
|
|
7
8
|
import { die, fail, log, NomadFatal } from './utils.ts';
|
|
8
9
|
import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
9
10
|
|
|
@@ -13,7 +14,9 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
|
13
14
|
* lock, collects every staged path matching the flat `<id>.jsonl` and the
|
|
14
15
|
* sibling subagent directory `<id>/` (via `collectMatches`), then unstages
|
|
15
16
|
* each (via `unstageOne`). This closes the leak where a "dropped" session
|
|
16
|
-
* still shipped its subagent transcripts.
|
|
17
|
+
* still shipped its subagent transcripts. A successful drop ends with
|
|
18
|
+
* `reportScrubHint`, which reminds the operator that the unstage is per-push
|
|
19
|
+
* only and points at the live transcript that still needs scrubbing.
|
|
17
20
|
*
|
|
18
21
|
* Idempotent: entries not in the index are skipped silently. Exits 0 on
|
|
19
22
|
* any drop (including an idempotent re-run); exits 1 with `✗ no staged
|
|
@@ -53,6 +56,7 @@ export function cmdDropSession(id: string): void {
|
|
|
53
56
|
throw new NomadFatal(`no staged session matches ${id}`);
|
|
54
57
|
}
|
|
55
58
|
for (const rel of matches) unstageOne(rel);
|
|
59
|
+
reportScrubHint(id, matches);
|
|
56
60
|
} catch (err) {
|
|
57
61
|
// Defensive escape hatch: only fires if a non-NomadFatal error escapes
|
|
58
62
|
// the try block. All execFileSync mutation failures are wrapped in
|