claude-nomad 0.28.0 → 0.30.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 +26 -0
- package/README.md +99 -32
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.scan.ts +5 -4
- package/src/commands.doctor.checks.deps.ts +97 -0
- package/src/commands.doctor.checks.hooks.ts +176 -0
- package/src/commands.doctor.checks.repo.ts +6 -5
- package/src/commands.doctor.checks.repository.ts +12 -6
- package/src/commands.doctor.ts +19 -2
- package/src/commands.pull.ts +11 -5
- package/src/commands.push.allowlist.ts +2 -0
- package/src/config.sharedDirs.guard.ts +55 -0
- package/src/config.ts +52 -0
- package/src/diff-lines.ts +5 -6
- package/src/diff.ts +7 -2
- package/src/extras-sync.ts +5 -9
- package/src/init.snapshot.ts +15 -14
- package/src/init.ts +5 -2
- package/src/links.ts +16 -11
- package/src/preview.ts +6 -3
- package/src/summary.ts +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.30.0](https://github.com/funkadelic/claude-nomad/compare/v0.29.1...v0.30.0) (2026-05-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **doctor:** report gh and curl presence in Version Checks ([#175](https://github.com/funkadelic/claude-nomad/issues/175)) ([5163833](https://github.com/funkadelic/claude-nomad/commit/516383301dbd9c64cfc8f1c7a654655b61c29280))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
* widen npm-publish smoke-test propagation window ([#176](https://github.com/funkadelic/claude-nomad/issues/176)) ([f8b1eef](https://github.com/funkadelic/claude-nomad/commit/f8b1eef18dcee91fb894d2036c42356f82c76f79))
|
|
14
|
+
|
|
15
|
+
## [0.29.1](https://github.com/funkadelic/claude-nomad/compare/v0.29.0...v0.29.1) (2026-05-29)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
* **doctor:** degrade gitleaks-absent probe to WARN, not FAIL ([#173](https://github.com/funkadelic/claude-nomad/issues/173)) ([320bb8a](https://github.com/funkadelic/claude-nomad/commit/320bb8a5d6f6c1f02207be8e186fe678f8e6f8bd))
|
|
21
|
+
|
|
22
|
+
## [0.29.0](https://github.com/funkadelic/claude-nomad/compare/v0.28.0...v0.29.0) (2026-05-29)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
* sync hook scripts and tool support dirs across hosts ([#171](https://github.com/funkadelic/claude-nomad/issues/171)) ([e340fd2](https://github.com/funkadelic/claude-nomad/commit/e340fd221882f9107f4e10c3a64ccd7be4061a14))
|
|
28
|
+
|
|
3
29
|
## [0.28.0](https://github.com/funkadelic/claude-nomad/compare/v0.27.0...v0.28.0) (2026-05-28)
|
|
4
30
|
|
|
5
31
|
|
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
|
|
|
45
45
|
- [Repo layout](#repo-layout-what-claude-nomad-looks-like-on-a-configured-host)
|
|
46
46
|
- [What gets synced vs. not](#what-gets-synced-vs-not)
|
|
47
47
|
- [Path remapping](#path-remapping)
|
|
48
|
+
- [Shared support dirs (sharedDirs)](#shared-support-dirs-shareddirs)
|
|
48
49
|
- [Per-host overrides](#per-host-overrides)
|
|
49
50
|
- [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs)
|
|
50
51
|
- **Getting started**
|
|
@@ -113,6 +114,7 @@ public funkadelic/claude-nomad your private <your-username>/claude-noma
|
|
|
113
114
|
│ ├── skills/
|
|
114
115
|
│ ├── commands/
|
|
115
116
|
│ ├── rules/
|
|
117
|
+
│ ├── hooks/
|
|
116
118
|
│ ├── settings.base.json
|
|
117
119
|
│ └── projects/
|
|
118
120
|
├── hosts/<hostname>.json
|
|
@@ -143,6 +145,7 @@ so a clobbered dotfile variable does not break the CLI.
|
|
|
143
145
|
│ ├── skills/
|
|
144
146
|
│ ├── commands/
|
|
145
147
|
│ ├── rules/
|
|
148
|
+
│ ├── hooks/ # hook scripts, symlinked into ~/.claude/hooks/
|
|
146
149
|
│ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
|
|
147
150
|
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, *.pem, id_rsa, id_ed25519, .env, .env.*
|
|
148
151
|
│ ├── projects/ # session transcripts under logical names
|
|
@@ -157,22 +160,29 @@ so a clobbered dotfile variable does not break the CLI.
|
|
|
157
160
|
|
|
158
161
|
## What gets synced vs. not
|
|
159
162
|
|
|
160
|
-
| Category
|
|
161
|
-
|
|
|
162
|
-
| **Synced**
|
|
163
|
-
| **Generated**
|
|
164
|
-
| **Remapped**
|
|
165
|
-
| **Per-project extras**
|
|
166
|
-
| **
|
|
167
|
-
| **
|
|
163
|
+
| Category | Items | Behavior |
|
|
164
|
+
| ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
165
|
+
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `hooks/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/`. |
|
|
166
|
+
| **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`; rewritten every pull. |
|
|
167
|
+
| **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
|
|
168
|
+
| **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>/`. |
|
|
169
|
+
| **Shared support dirs** | Opt-in global `~/.claude/` dirs like a tool's `get-shit-done/` | Opt-in via the `sharedDirs` field in `path-map.json`; symlinked into `~/.claude/` from `shared/`. See [Shared support dirs](#shared-support-dirs-shareddirs). |
|
|
170
|
+
| **Never synced** | OAuth and MCP state, shell history, per-host overrides, caches, scratch dirs | Per-host ephemeral state; left untouched in both directions. |
|
|
171
|
+
| **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Re-downloaded by Claude Code from the `enabledPlugins` list; no per-host install. |
|
|
168
172
|
|
|
169
173
|
Pointers and specifics:
|
|
170
174
|
|
|
171
|
-
- **Synced** link names live in `SHARED_LINKS
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
`
|
|
175
|
-
|
|
175
|
+
- **Synced** link names live in `SHARED_LINKS` (and the optional `sharedDirs` field in
|
|
176
|
+
`path-map.json` -- see [Shared support dirs](#shared-support-dirs-shareddirs)), **whitelisted
|
|
177
|
+
extras** names in `SUPPORTED_EXTRAS`, and the full **never-synced** set in `NEVER_SYNC` (all in
|
|
178
|
+
`src/config.ts`).
|
|
179
|
+
- **Never synced**, in full: `~/.claude.json` (OAuth, MCP state), `.credentials.json` (OAuth
|
|
180
|
+
credential store), `history.jsonl`, `settings.local.json` (per-host overrides),
|
|
181
|
+
`stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`,
|
|
182
|
+
`session-env/`, `statsig/`, `telemetry/`, `ide/`, plus host-local caches and runtime state
|
|
183
|
+
(`cache/`, `backups/`, `paste-cache/`, `daemon/`, `jobs/`, `tasks/`, `security/`, `sessions/`).
|
|
184
|
+
This set is also the deny-list the `sharedDirs` opt-in is checked against, so one of these names
|
|
185
|
+
cannot be symlinked into the shared repo by mistake.
|
|
176
186
|
- **Per-project extras** run a pre-pull divergence WARN that flags local edits before they get
|
|
177
187
|
overwritten.
|
|
178
188
|
|
|
@@ -239,6 +249,55 @@ copy and prints a per-file WARN naming anything that differs.
|
|
|
239
249
|
Your existing local content is backed up under `~/.cache/claude-nomad/backup/<ts>/extras/` before
|
|
240
250
|
the pull copy lands, so an unexpected overwrite is always recoverable.
|
|
241
251
|
|
|
252
|
+
## Shared support dirs (sharedDirs)
|
|
253
|
+
|
|
254
|
+
Some tools install a `hooks` block into `settings.json` whose commands point at scripts under
|
|
255
|
+
`~/.claude/hooks/` (and sometimes a support directory such as `~/.claude/get-shit-done/`). Because
|
|
256
|
+
`settings.json` is regenerated on every pull, that hook configuration travels to every host, but the
|
|
257
|
+
scripts it points at did not, so hooks broke on a freshly configured host. `~/.claude/hooks/` is now
|
|
258
|
+
a built-in synced link (it rides the same symlink model as `skills/` and `agents/`), so hook scripts
|
|
259
|
+
travel automatically.
|
|
260
|
+
|
|
261
|
+
For any other global `~/.claude/` support directory a tool needs, the optional top-level
|
|
262
|
+
`sharedDirs` field in `path-map.json` opts it into the same symlink sync:
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"projects": {
|
|
267
|
+
"my-example-repo": {
|
|
268
|
+
"<your-mac>": "/Users/you/code/my-example-repo"
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
"sharedDirs": ["get-shit-done"]
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
What this means for you: each listed name is symlinked from `shared/<name>` into `~/.claude/<name>`
|
|
276
|
+
(the same model as the built-in synced links, not a copy), so editing it on any host updates the one
|
|
277
|
+
shared copy. The field is additive and back-compatible: a `path-map.json` without it behaves exactly
|
|
278
|
+
as before.
|
|
279
|
+
|
|
280
|
+
Entries are validated before anything is linked. A name is accepted only if it is a single path
|
|
281
|
+
segment (no `/`, no `..`), is not one of the never-synced names, and does not collide with a
|
|
282
|
+
reserved `shared/` name (`settings.base.json`, the built-in synced links, `hooks`, `hosts`,
|
|
283
|
+
`path-map.json`). An invalid entry is dropped with a warning rather than aborting the run. The
|
|
284
|
+
contents still go through the same gitleaks scan as everything else on push, so do not point
|
|
285
|
+
`sharedDirs` at a directory that holds credentials.
|
|
286
|
+
|
|
287
|
+
First-time setup on an already-configured repo: a symlink can only form once the directory exists
|
|
288
|
+
under `shared/`. On a fresh repo `nomad init --snapshot` handles this for you. To add `hooks/` (or a
|
|
289
|
+
new `sharedDirs` entry) to a repo that is already set up, move it into `shared/` once on the host
|
|
290
|
+
that has it, then let the normal flow take over:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
$ mv ~/.claude/hooks ~/claude-nomad/shared/hooks # one-time, on the source host
|
|
294
|
+
$ nomad pull # re-creates ~/.claude/hooks as a symlink
|
|
295
|
+
$ nomad push # shares it with your other hosts
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`nomad pull` never writes back to the remote, so it will not seed `shared/` for you; the one-time
|
|
299
|
+
move is deliberate.
|
|
300
|
+
|
|
242
301
|
## Per-host overrides
|
|
243
302
|
|
|
244
303
|
`settings.base.json` holds portable defaults (model, permissions, plugins).
|
|
@@ -321,10 +380,11 @@ Read these before adopting so you opt in with eyes open.
|
|
|
321
380
|
|
|
322
381
|
- `gh` ([GitHub CLI](https://cli.github.com/)), used only by `nomad init` to auto-disable Actions on
|
|
323
382
|
the private repo; if it is missing or unauthenticated, init prints a manual fallback tip and
|
|
324
|
-
continues
|
|
383
|
+
continues. `nomad doctor` reports its presence in the Version Checks section.
|
|
325
384
|
- [curl](https://curl.se/), used by the version/update check (the `nomad doctor` latest-release line
|
|
326
385
|
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
|
|
386
|
+
curl is absent or offline, so the rest of the CLI works without it. `nomad doctor` reports its
|
|
387
|
+
presence in the Version Checks section.
|
|
328
388
|
|
|
329
389
|
## Setup
|
|
330
390
|
|
|
@@ -540,28 +600,35 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
|
|
|
540
600
|
|
|
541
601
|
## Commands
|
|
542
602
|
|
|
543
|
-
| Command | Description
|
|
544
|
-
| -------------------------------- |
|
|
545
|
-
| `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)).
|
|
546
|
-
| `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`.
|
|
547
|
-
| `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.
|
|
548
|
-
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. Errors out if scaffold missing.
|
|
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.
|
|
550
|
-
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state.
|
|
551
|
-
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push.
|
|
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.
|
|
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).
|
|
554
|
-
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool).
|
|
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.
|
|
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)).
|
|
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.
|
|
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.
|
|
603
|
+
| Command | Description |
|
|
604
|
+
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
605
|
+
| `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)). |
|
|
606
|
+
| `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`. |
|
|
607
|
+
| `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. |
|
|
608
|
+
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. Errors out if scaffold missing. |
|
|
609
|
+
| `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. |
|
|
610
|
+
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
611
|
+
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
612
|
+
| `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. |
|
|
613
|
+
| `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). |
|
|
614
|
+
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
615
|
+
| `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, a Hook targets check that fails (`✗`, exit 1) when `settings.json` references a hook command whose script under `~/.claude/` is missing on this host, plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
|
|
616
|
+
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
|
|
617
|
+
| `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). |
|
|
618
|
+
| `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. |
|
|
619
|
+
| `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. |
|
|
560
620
|
|
|
561
621
|
The version-check emits ``⚠︎ claude-nomad: <local> -> <latest> (run `nomad update`)`` when the local
|
|
562
622
|
install is behind the latest upstream release, and `✓ claude-nomad: <local> (latest)` when current.
|
|
563
623
|
It silently skips on network failures.
|
|
564
624
|
|
|
625
|
+
The Hook targets check reads the live `~/.claude/settings.json` `hooks` block and fails (`✗`, exit
|
|
626
|
+
|
|
627
|
+
1. when a hook command points at a script under `~/.claude/` that is missing on this host (the
|
|
628
|
+
freshly-configured-host symptom that motivated syncing `hooks/`). It deliberately skips any
|
|
629
|
+
command it cannot resolve to a `~/.claude/` path (bare binaries like `jq`, unresolved env vars),
|
|
630
|
+
so it never false-fails on a command that does not reference a local script.
|
|
631
|
+
|
|
565
632
|
Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line
|
|
566
633
|
`⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the
|
|
567
634
|
CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so
|
package/package.json
CHANGED
|
@@ -83,10 +83,10 @@ function reportRemediation(
|
|
|
83
83
|
const logical = logicalBySession.get(sid);
|
|
84
84
|
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
85
85
|
if (logical !== undefined) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
` ${dim(`- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
|
|
86
|
+
const rotateLine = dim(
|
|
87
|
+
`- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
|
|
89
88
|
);
|
|
89
|
+
addItem(section, ` ${rotateLine}`);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
addItem(section, ` ${dim('- false positive? add a pattern to .gitleaks.toml')}`);
|
|
@@ -147,7 +147,8 @@ function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): voi
|
|
|
147
147
|
addItem(section, '');
|
|
148
148
|
addItem(section, bold('Finding types'));
|
|
149
149
|
for (const [rule, desc] of descByRule) {
|
|
150
|
-
|
|
150
|
+
const ruleLabel = red(`- [${rule}]`);
|
|
151
|
+
addItem(section, ` ${ruleLabel}: ${dim(desc)}`);
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
154
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { green, okGlyph, warnGlyph, yellow } from './color.ts';
|
|
4
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
5
|
+
import type { SpawnSyncFn } from './gh-actions.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Optional-dependency presence reporter for `nomad doctor`. Probes for `gh`
|
|
9
|
+
* and `curl` (both optional CLIs used by nomad features) and emits one row
|
|
10
|
+
* per binary in the Version Checks section:
|
|
11
|
+
* - present with parsed version: `okGlyph gh: X.Y.Z`
|
|
12
|
+
* - present but version unparseable: `okGlyph gh: present`
|
|
13
|
+
* - not installed (ENOENT): `warnGlyph gh: not installed (optional; ...)`
|
|
14
|
+
*
|
|
15
|
+
* This reporter MUST NOT set `process.exitCode`: absent optional deps are
|
|
16
|
+
* informational only (D-02). Both probes always run unconditionally.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Regex to extract the first X.Y.Z version token from a string. Each segment
|
|
21
|
+
* is bounded (`{1,9}`) rather than `+` so the pattern is provably linear: a
|
|
22
|
+
* `--version` line never has a segment longer than a few digits, and bounding
|
|
23
|
+
* the repetition removes the super-linear backtracking an unbounded
|
|
24
|
+
* `\d+\.\d+\.\d+` carries on a degenerate all-digit input.
|
|
25
|
+
*/
|
|
26
|
+
const VERSION_TOKEN = /(\d{1,9}\.\d{1,9}\.\d{1,9})/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the first X.Y.Z-shaped version token from a string.
|
|
30
|
+
*
|
|
31
|
+
* @param line - A single line of --version output (already trimmed).
|
|
32
|
+
* @returns The first version token, or `null` if none found.
|
|
33
|
+
*/
|
|
34
|
+
function parseFirstVersion(line: string): string | null {
|
|
35
|
+
const m = VERSION_TOKEN.exec(line);
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Discriminated union for binary probe results. */
|
|
40
|
+
type DepProbeResult = { status: 'present'; version: string | null } | { status: 'not-installed' };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Probe a binary by running `bin --version` and parsing the first output line.
|
|
44
|
+
* Returns a DepProbeResult: present (with optional version token) or
|
|
45
|
+
* not-installed (ENOENT). Non-ENOENT errors are treated as present with no
|
|
46
|
+
* version (D-03: "never FAIL on unexpected --version output").
|
|
47
|
+
*
|
|
48
|
+
* @param bin - The binary name to probe (e.g. `gh` or `curl`).
|
|
49
|
+
* @param run - Injectable subprocess runner; defaults to `execFileSync`.
|
|
50
|
+
*/
|
|
51
|
+
function probeOptionalDep(bin: string, run: SpawnSyncFn): DepProbeResult {
|
|
52
|
+
try {
|
|
53
|
+
const firstLine = run(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
54
|
+
.toString()
|
|
55
|
+
.split('\n')[0]
|
|
56
|
+
.trim();
|
|
57
|
+
const version = parseFirstVersion(firstLine);
|
|
58
|
+
return { status: 'present', version };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
61
|
+
return { status: 'not-installed' };
|
|
62
|
+
}
|
|
63
|
+
// Non-ENOENT: binary may exist but --version misbehaved; report as present.
|
|
64
|
+
return { status: 'present', version: null };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Emit presence rows for the optional `gh` and `curl` CLIs into the given
|
|
70
|
+
* doctor section. Each row shows the binary's install status and version (if
|
|
71
|
+
* parseable). Absent binaries emit a WARN naming the features they enable.
|
|
72
|
+
* Never sets `process.exitCode` (D-02): both deps are optional.
|
|
73
|
+
*
|
|
74
|
+
* @param section - The Version Checks section to append rows to.
|
|
75
|
+
* @param run - Injectable subprocess runner; defaults to `execFileSync`.
|
|
76
|
+
*/
|
|
77
|
+
export function reportOptionalDeps(section: DoctorSection, run: SpawnSyncFn = execFileSync): void {
|
|
78
|
+
const gh = probeOptionalDep('gh', run);
|
|
79
|
+
if (gh.status === 'present') {
|
|
80
|
+
addItem(section, `${green(okGlyph)} gh: ${gh.version ?? 'present'}`);
|
|
81
|
+
} else {
|
|
82
|
+
addItem(
|
|
83
|
+
section,
|
|
84
|
+
`${yellow(warnGlyph)} gh: not installed (optional; needed for nomad init Actions auto-disable + mirror-Actions drift check)`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const curl = probeOptionalDep('curl', run);
|
|
89
|
+
if (curl.status === 'present') {
|
|
90
|
+
addItem(section, `${green(okGlyph)} curl: ${curl.version ?? 'present'}`);
|
|
91
|
+
} else {
|
|
92
|
+
addItem(
|
|
93
|
+
section,
|
|
94
|
+
`${yellow(warnGlyph)} curl: not installed (optional; needed for release-version staleness check + nomad doctor --check-schema)`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { dim, failGlyph, green, infoGlyph, okGlyph, red } from './color.ts';
|
|
5
|
+
import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
|
|
6
|
+
import { CLAUDE_HOME, HOME } from './config.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Always-on `nomad doctor` reporter. Reads `~/.claude/settings.json`, walks
|
|
10
|
+
* every `{ type: "command", command }` entry in the `hooks` block, and FAILs
|
|
11
|
+
* with `process.exitCode = 1` for each command token that confidently resolves
|
|
12
|
+
* to a path under `~/.claude` but is missing on disk. Commands with no
|
|
13
|
+
* resolvable `~/.claude` path (bare binaries, unresolved env vars) are silently
|
|
14
|
+
* skipped per D-09: the check only surfaces the issue-#170 case of synced hook
|
|
15
|
+
* config pointing at unsynced local scripts.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Expand a leading `~`, `$HOME`, or `${HOME}` to the resolved HOME directory so
|
|
20
|
+
* the resulting path can be passed to `existsSync` and compared against the
|
|
21
|
+
* absolute `~/.claude` location. A token with no home-relative prefix (a bare
|
|
22
|
+
* binary, an already-absolute path) is returned unchanged.
|
|
23
|
+
*
|
|
24
|
+
* @param token - A raw path token extracted from a hook command string.
|
|
25
|
+
* @returns The path with any leading home-relative syntax resolved to HOME.
|
|
26
|
+
*/
|
|
27
|
+
function expandHome(token: string): string {
|
|
28
|
+
return token
|
|
29
|
+
.replace(/^\$\{HOME\}/, HOME)
|
|
30
|
+
.replace(/^\$HOME/, HOME)
|
|
31
|
+
.replace(/^~/, HOME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strip shell quoting and trailing control punctuation from a raw command
|
|
36
|
+
* token so a real path is not mistaken for a missing one. Without this, a
|
|
37
|
+
* quoted compound command like `bash -c 'a.sh; ~/.claude/hooks/run.sh'` yields
|
|
38
|
+
* the token `~/.claude/hooks/run.sh'` (trailing quote), and `existsSync` would
|
|
39
|
+
* FAIL on a script that is actually present (a D-09 false-FAIL). Removes
|
|
40
|
+
* leading quotes and any trailing run of `'"`;)|&>` characters. A genuine path
|
|
41
|
+
* never carries these on its boundary, so stripping them is safe.
|
|
42
|
+
*
|
|
43
|
+
* @param token - A raw whitespace-delimited token from a command segment.
|
|
44
|
+
* @returns The token with boundary shell punctuation removed.
|
|
45
|
+
*/
|
|
46
|
+
function stripShellPunctuation(token: string): string {
|
|
47
|
+
return token.replace(/^['"]+/, '').replace(/['"`;)|&>]+$/, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Yield every command token that resolves to a path under `~/.claude`. Each
|
|
52
|
+
* `&&`-, `||`-, `;`-, or `|`-separated sub-command is scanned token by token
|
|
53
|
+
* (not just its leading word), so wrappers like `bash ~/.claude/hooks/run.sh`
|
|
54
|
+
* are caught and not just `~/.claude/hooks/run.sh` on its own. Every token is
|
|
55
|
+
* stripped of shell quoting and home-expanded before comparison, so the
|
|
56
|
+
* literal `~`, `$HOME`, and `${HOME}` forms collapse to one check. Tokens that
|
|
57
|
+
* do not resolve under `~/.claude` (bare binaries, flags, unresolved env vars)
|
|
58
|
+
* are skipped per D-09, so the check only ever FAILs on a real `~/.claude`
|
|
59
|
+
* target.
|
|
60
|
+
*
|
|
61
|
+
* @param command - The raw `command` string from a hook entry.
|
|
62
|
+
* @returns Iterable of absolute resolved paths under `~/.claude`.
|
|
63
|
+
*/
|
|
64
|
+
function* claudePathsIn(command: string): Iterable<string> {
|
|
65
|
+
const claudePrefix = `${CLAUDE_HOME}/`;
|
|
66
|
+
for (const segment of command.split(/&&|\|\||;|\|/)) {
|
|
67
|
+
for (const raw of segment.trim().split(/\s+/).filter(Boolean)) {
|
|
68
|
+
const expanded = expandHome(stripShellPunctuation(raw));
|
|
69
|
+
if (expanded.startsWith(claudePrefix)) yield expanded;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A hook entry in flat format: `{ type: "command"; command: string }`.
|
|
76
|
+
* Used internally by `commandsFromFlat` to narrow the parsed JSON shape.
|
|
77
|
+
*/
|
|
78
|
+
type FlatEntry = { type: unknown; command?: unknown };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Yield command strings from a flat-format entry list (each element is
|
|
82
|
+
* directly `{ type: "command", command: string }`). Skips non-object and
|
|
83
|
+
* non-command entries silently (T-25-07 defence).
|
|
84
|
+
*
|
|
85
|
+
* @param entries - Array of flat hook entries to walk.
|
|
86
|
+
*/
|
|
87
|
+
function* commandsFromFlat(entries: unknown[]): Iterable<string> {
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (typeof entry !== 'object' || entry === null) continue;
|
|
90
|
+
const e = entry as FlatEntry;
|
|
91
|
+
if (e.type === 'command' && typeof e.command === 'string') yield e.command;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Yield every `{ type: "command"; command: string }` entry from a single
|
|
97
|
+
* hook group, which may be a flat array entry or a grouped object with a
|
|
98
|
+
* nested `hooks` array. Non-object / non-command entries are silently skipped
|
|
99
|
+
* (D-09 / T-25-07 defence: malformed input degrades to skips, never throws).
|
|
100
|
+
*
|
|
101
|
+
* @param group - One element of a hooks event array.
|
|
102
|
+
* @returns Iterable of command strings from command-type entries.
|
|
103
|
+
*/
|
|
104
|
+
function* commandsFromGroup(group: unknown): Iterable<string> {
|
|
105
|
+
if (typeof group !== 'object' || group === null) return;
|
|
106
|
+
const g = group as Record<string, unknown>;
|
|
107
|
+
// Grouped shape: { matcher?, hooks: HookEntry[] }
|
|
108
|
+
if (Array.isArray(g.hooks)) {
|
|
109
|
+
yield* commandsFromFlat(g.hooks);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Flat shape: the group itself is { type: "command", command: string }
|
|
113
|
+
if (g.type === 'command' && typeof g.command === 'string') yield g.command;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Walk all hook groups for a single event and emit FAIL items for every
|
|
118
|
+
* resolved-but-missing `~/.claude` target. Returns true when at least one
|
|
119
|
+
* FAIL was emitted (used by the caller to suppress the OK summary line).
|
|
120
|
+
*
|
|
121
|
+
* @param section - Doctor section to append items to.
|
|
122
|
+
* @param event - Hook event name (e.g. `PostToolUse`).
|
|
123
|
+
* @param groups - Array of hook groups for this event.
|
|
124
|
+
* @returns True when any missing target was found.
|
|
125
|
+
*/
|
|
126
|
+
function checkEventGroups(section: DoctorSection, event: string, groups: unknown[]): boolean {
|
|
127
|
+
let anyFail = false;
|
|
128
|
+
for (const group of groups) {
|
|
129
|
+
for (const cmd of commandsFromGroup(group)) {
|
|
130
|
+
for (const resolved of claudePathsIn(cmd)) {
|
|
131
|
+
if (existsSync(resolved)) continue;
|
|
132
|
+
addItem(section, `${red(failGlyph)} hooks/${event}: command target missing: ${resolved}`);
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
anyFail = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return anyFail;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Append the Hook-targets check result to the supplied section. Reads
|
|
143
|
+
* `~/.claude/settings.json`, walks every command entry in the `hooks` block,
|
|
144
|
+
* and emits a `✗` FAIL for each `~/.claude` target that is absent on disk.
|
|
145
|
+
* Commands with no resolvable local path are silently skipped (D-09).
|
|
146
|
+
* Emits a `✓` OK line when all resolvable targets exist (or none were found).
|
|
147
|
+
* Emits a `ℹ︎` info skip when `settings.json` is absent.
|
|
148
|
+
*
|
|
149
|
+
* @param section - The doctor section to append items to.
|
|
150
|
+
*/
|
|
151
|
+
export function reportHooksTargetCheck(section: DoctorSection): void {
|
|
152
|
+
const settingsPath = join(CLAUDE_HOME, 'settings.json');
|
|
153
|
+
if (!existsSync(settingsPath)) {
|
|
154
|
+
addItem(section, `${dim(infoGlyph)} no ~/.claude/settings.json; skipping hook target check`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const settings = readJsonSafe<Record<string, unknown>>(settingsPath, settingsPath, section);
|
|
159
|
+
if (settings === null) return;
|
|
160
|
+
|
|
161
|
+
const hooks = settings.hooks;
|
|
162
|
+
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
|
|
163
|
+
addItem(section, `${green(okGlyph)} hooks: all command targets present`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let anyFail = false;
|
|
168
|
+
for (const [event, groups] of Object.entries(hooks as Record<string, unknown>)) {
|
|
169
|
+
if (!Array.isArray(groups)) continue;
|
|
170
|
+
if (checkEventGroups(section, event, groups)) anyFail = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!anyFail) {
|
|
174
|
+
addItem(section, `${green(okGlyph)} hooks: all command targets present`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
warnGlyph,
|
|
14
14
|
yellow,
|
|
15
15
|
} from './color.ts';
|
|
16
|
-
import { CLAUDE_HOME, HOST, REPO_HOME,
|
|
16
|
+
import { allSharedLinks, CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
17
17
|
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
18
18
|
import { classifyRepoState, reasonForPartial } from './init.classify.ts';
|
|
19
19
|
|
|
@@ -157,8 +157,9 @@ function classifySymlinkTarget(name: string, p: string): { line: string; fail: b
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* Emits a per-entry status line for each name in
|
|
161
|
-
*
|
|
160
|
+
* Emits a per-entry status line for each name in `allSharedLinks(map)` (the
|
|
161
|
+
* static shared-link set plus any validated `sharedDirs` entries) using
|
|
162
|
+
* okGlyph/warnGlyph/infoGlyph/failGlyph. A non-symlink blocks sync and FAILs
|
|
162
163
|
* via process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
|
|
163
164
|
* that vanishes or becomes unreadable between the probe and the stat yields a
|
|
164
165
|
* row instead of an unhandled throw that aborts the whole doctor run. Severity
|
|
@@ -168,8 +169,8 @@ function classifySymlinkTarget(name: string, p: string): { line: string; fail: b
|
|
|
168
169
|
* sync). A symlink whose target cannot be resolved is never a healthy OK, so a
|
|
169
170
|
* dangling or unreadable link is not masked.
|
|
170
171
|
*/
|
|
171
|
-
export function reportSharedLinks(section: DoctorSection): void {
|
|
172
|
-
for (const name of
|
|
172
|
+
export function reportSharedLinks(section: DoctorSection, map: PathMap): void {
|
|
173
|
+
for (const name of allSharedLinks(map)) {
|
|
173
174
|
const p = join(CLAUDE_HOME, name);
|
|
174
175
|
const { line, fail } = classifySharedLink(name, p);
|
|
175
176
|
addItem(section, line);
|
|
@@ -28,10 +28,16 @@ import { gitStatusPorcelainZ } from './utils.ts';
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Probes for gitleaks on PATH
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* Probes for gitleaks on PATH. Emits okGlyph with version when found. When
|
|
32
|
+
* gitleaks is absent (ENOENT), emits warnGlyph and does NOT set exitCode:
|
|
33
|
+
* gitleaks is required for `nomad push` but is an optional dependency for the
|
|
34
|
+
* read-only doctor check, so its absence degrades to WARN per the project
|
|
35
|
+
* convention that optional-dependency absence must never affect exit code. A
|
|
36
|
+
* non-ENOENT error (broken binary, permission denied) still emits failGlyph
|
|
37
|
+
* and sets exitCode=1 because a present-but-unrunnable gitleaks is a real
|
|
38
|
+
* defect that would break `nomad push`. Returns `true` when a usable binary
|
|
39
|
+
* was found so the caller can skip a redundant second `version` probe (e.g.
|
|
40
|
+
* the `--check-shared` Shared scan section).
|
|
35
41
|
*/
|
|
36
42
|
export function reportGitleaksProbe(section: DoctorSection): boolean {
|
|
37
43
|
try {
|
|
@@ -42,11 +48,11 @@ export function reportGitleaksProbe(section: DoctorSection): boolean {
|
|
|
42
48
|
return true;
|
|
43
49
|
} catch (err) {
|
|
44
50
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
45
|
-
addItem(section, `${
|
|
51
|
+
addItem(section, `${yellow(warnGlyph)} gitleaks: not on PATH (required for nomad push)`);
|
|
46
52
|
} else {
|
|
47
53
|
addItem(section, `${red(failGlyph)} gitleaks: probe failed: ${(err as Error).message}`);
|
|
54
|
+
process.exitCode = 1;
|
|
48
55
|
}
|
|
49
|
-
process.exitCode = 1;
|
|
50
56
|
return false;
|
|
51
57
|
}
|
|
52
58
|
}
|
package/src/commands.doctor.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
1
4
|
import {
|
|
2
5
|
reportHostAndPaths,
|
|
3
6
|
reportRepoState,
|
|
@@ -17,9 +20,12 @@ import {
|
|
|
17
20
|
} from './commands.doctor.checks.repository.ts';
|
|
18
21
|
import { reportCheckSchema } from './commands.doctor.check-schema.ts';
|
|
19
22
|
import { reportCheckShared } from './commands.doctor.check-shared.ts';
|
|
23
|
+
import { reportHooksTargetCheck } from './commands.doctor.checks.hooks.ts';
|
|
24
|
+
import { REPO_HOME, type PathMap } from './config.ts';
|
|
20
25
|
import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
|
|
21
|
-
import { renderDoctor, section } from './commands.doctor.format.ts';
|
|
26
|
+
import { readJsonSafe, renderDoctor, section } from './commands.doctor.format.ts';
|
|
22
27
|
import { reportGitleaksVersionCheck } from './commands.doctor.gitleaks-version.ts';
|
|
28
|
+
import { reportOptionalDeps } from './commands.doctor.checks.deps.ts';
|
|
23
29
|
import { reportMirrorActions } from './commands.doctor.mirror-actions.ts';
|
|
24
30
|
import { reportVersionCheck } from './commands.doctor.version.ts';
|
|
25
31
|
|
|
@@ -47,7 +53,16 @@ export function cmdDoctor(opts: { checkShared?: boolean; checkSchema?: boolean }
|
|
|
47
53
|
reportRepoState(host);
|
|
48
54
|
|
|
49
55
|
const links = section('Shared links');
|
|
50
|
-
|
|
56
|
+
// Tolerantly read path-map.json for sharedDirs: doctor is read-only and
|
|
57
|
+
// must not throw on a missing or malformed map. Fall back to { projects: {} }
|
|
58
|
+
// so hooks + static SHARED_LINKS rows still emit on a fresh host.
|
|
59
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
60
|
+
const rawMap = existsSync(mapPath) ? readJsonSafe<PathMap>(mapPath, mapPath, links) : null;
|
|
61
|
+
const map: PathMap = rawMap ?? { projects: {} };
|
|
62
|
+
reportSharedLinks(links, map);
|
|
63
|
+
|
|
64
|
+
const hooksScan = section('Hook targets');
|
|
65
|
+
reportHooksTargetCheck(hooksScan);
|
|
51
66
|
|
|
52
67
|
const settings = section('Settings');
|
|
53
68
|
const base = loadBaseSettings(settings);
|
|
@@ -71,6 +86,7 @@ export function cmdDoctor(opts: { checkShared?: boolean; checkSchema?: boolean }
|
|
|
71
86
|
reportVersionCheck(version);
|
|
72
87
|
reportNodeEngineCheck(version);
|
|
73
88
|
reportGitleaksVersionCheck(version);
|
|
89
|
+
reportOptionalDeps(version);
|
|
74
90
|
|
|
75
91
|
const sharedScan = section('Shared scan');
|
|
76
92
|
// Reuse the Repository-section readiness probe so reportCheckShared does not
|
|
@@ -86,6 +102,7 @@ export function cmdDoctor(opts: { checkShared?: boolean; checkSchema?: boolean }
|
|
|
86
102
|
version,
|
|
87
103
|
host,
|
|
88
104
|
links,
|
|
105
|
+
hooksScan,
|
|
89
106
|
settings,
|
|
90
107
|
pathMap,
|
|
91
108
|
neverSync,
|
package/src/commands.pull.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
buildSessionsSection,
|
|
7
7
|
buildSettingsSection,
|
|
8
8
|
} from './commands.push.sections.ts';
|
|
9
|
-
import { HOME, HOST, REPO_HOME } from './config.ts';
|
|
9
|
+
import { HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
10
10
|
import { divergenceCheckExtras, remapExtrasPull } from './extras-sync.ts';
|
|
11
11
|
import { applySharedLinks, regenerateSettings } from './links.ts';
|
|
12
12
|
import { renderTree, section, addItem } from './output-tree.ts';
|
|
@@ -16,6 +16,7 @@ import { emitSummary, summaryRow } from './summary.ts';
|
|
|
16
16
|
import { die, fail, gitOrFatal, log, NomadFatal } from './utils.ts';
|
|
17
17
|
import { freshBackupTs } from './utils.fs.ts';
|
|
18
18
|
import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
19
|
+
import { readPathMap } from './utils.json.ts';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Run the WET (non-dry-run) pull side effects in order and render the
|
|
@@ -30,8 +31,8 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
|
30
31
|
*
|
|
31
32
|
* @param ts - backup timestamp namespace shared by every WET side effect.
|
|
32
33
|
*/
|
|
33
|
-
function applyWetPull(ts: string): void {
|
|
34
|
-
applySharedLinks(ts);
|
|
34
|
+
function applyWetPull(ts: string, map: PathMap): void {
|
|
35
|
+
applySharedLinks(ts, map);
|
|
35
36
|
const { label } = regenerateSettings(ts);
|
|
36
37
|
const remapResult = remapPull(ts);
|
|
37
38
|
const extrasResult = remapExtrasPull(ts);
|
|
@@ -133,20 +134,25 @@ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
|
|
|
133
134
|
: `pull on host=${HOST} (backup=${ts})`,
|
|
134
135
|
);
|
|
135
136
|
gitOrFatal(['pull', '--rebase', '--autostash'], 'git pull --rebase', REPO_HOME);
|
|
137
|
+
// Read path-map.json for sharedDirs/symlink threading. Falls back to a
|
|
138
|
+
// no-sharedDirs map when the file is absent (fresh-clone before init).
|
|
139
|
+
// A parse failure routes through NomadFatal -> catch -> lock release.
|
|
140
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
141
|
+
const map: PathMap = existsSync(mapPath) ? readPathMap(mapPath) : { projects: {} };
|
|
136
142
|
// Read-only pre-pull check: fires in BOTH wet and dry modes (D-08).
|
|
137
143
|
// Runs AFTER the rebase (so origin content is fetched) and BEFORE any
|
|
138
144
|
// mutation (so local state is intact for byte-level comparison). The
|
|
139
145
|
// function itself silently skips when no `extras` key is declared.
|
|
140
146
|
divergenceCheckExtras(ts);
|
|
141
147
|
if (dryRun) {
|
|
142
|
-
const previewResult = computePreview(ts);
|
|
148
|
+
const previewResult = computePreview(ts, map);
|
|
143
149
|
// dryRun deliberately omits remapExtrasPull to preserve the
|
|
144
150
|
// zero-mutation contract; users still see the divergence WARN above.
|
|
145
151
|
// BYTE-IDENTICAL dry-run output: standalone emitSummary, no tree.
|
|
146
152
|
log('dry-run complete; no mutation');
|
|
147
153
|
emitSummary('pull', previewResult.unmapped);
|
|
148
154
|
} else {
|
|
149
|
-
applyWetPull(ts);
|
|
155
|
+
applyWetPull(ts, map);
|
|
150
156
|
}
|
|
151
157
|
} catch (err) {
|
|
152
158
|
// Catch fatal errors here so the finally block runs and releases the
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NEVER_SYNC, PUSH_ALLOWED_STATIC, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
|
|
2
|
+
import { isValidSharedDir } from './config.sharedDirs.guard.ts';
|
|
2
3
|
import { fail, NomadFatal } from './utils.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -98,6 +99,7 @@ export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
|
|
|
98
99
|
.filter((n) => extrasWhitelist.includes(n))
|
|
99
100
|
.flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`]),
|
|
100
101
|
),
|
|
102
|
+
...(map.sharedDirs ?? []).filter((d) => isValidSharedDir(d)).map((d) => `shared/${d}/`),
|
|
101
103
|
];
|
|
102
104
|
const neverSyncHits: string[] = [];
|
|
103
105
|
const violations: string[] = [];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NEVER_SYNC } from './config.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single-segment path characters allowed in a `sharedDirs` entry. Mirrors
|
|
5
|
+
* `SAFE_LOGICAL` in `extras-sync.guards.ts` but applied to global support
|
|
6
|
+
* directory names rather than per-project logical names. Must match
|
|
7
|
+
* `^[A-Za-z0-9._-]+$` so no path separator, no shell-special character, no
|
|
8
|
+
* leading dot that would collide with a hidden state directory.
|
|
9
|
+
*/
|
|
10
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Names that already exist under `shared/` (as repo-structural files or as
|
|
14
|
+
* members of `SHARED_LINKS`) that a `sharedDirs` entry must not collide with.
|
|
15
|
+
* Adding a `sharedDirs` entry matching one of these would either shadow a
|
|
16
|
+
* structural file or create a duplicate symlink pointing at the same target.
|
|
17
|
+
*/
|
|
18
|
+
const RESERVED_SHARED = new Set([
|
|
19
|
+
'settings.base.json',
|
|
20
|
+
'CLAUDE.md',
|
|
21
|
+
'agents',
|
|
22
|
+
'skills',
|
|
23
|
+
'commands',
|
|
24
|
+
'rules',
|
|
25
|
+
'my-statusline.cjs',
|
|
26
|
+
'hooks',
|
|
27
|
+
'hosts',
|
|
28
|
+
'path-map.json',
|
|
29
|
+
'extras',
|
|
30
|
+
'projects',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns `true` when `entry` is a valid `sharedDirs` path segment: a single
|
|
35
|
+
* path segment (no `/` or `..`), not present in `NEVER_SYNC`, and not a
|
|
36
|
+
* reserved `shared/` name. Invalid entries are dropped with a WARN by the
|
|
37
|
+
* caller (`allSharedLinks` in `config.ts`) rather than throwing a fatal error,
|
|
38
|
+
* mirroring the resilience of the existing extras path.
|
|
39
|
+
*
|
|
40
|
+
* Accepts `unknown` because `path-map.json` is runtime input: a malformed
|
|
41
|
+
* `sharedDirs` array can hold non-string values (numbers, objects, null) that
|
|
42
|
+
* `SAFE_SEGMENT.test` would otherwise string-coerce (e.g. `42` -> `"42"`).
|
|
43
|
+
* Rejecting non-strings first drops those shapes deterministically, and the
|
|
44
|
+
* `entry is string` predicate narrows the value for callers that filter on it.
|
|
45
|
+
*
|
|
46
|
+
* @param entry - Candidate `sharedDirs` value from `path-map.json`.
|
|
47
|
+
* @returns `true` if the entry is safe to use as a symlink target under `~/.claude/`.
|
|
48
|
+
*/
|
|
49
|
+
export function isValidSharedDir(entry: unknown): entry is string {
|
|
50
|
+
if (typeof entry !== 'string') return false;
|
|
51
|
+
if (!SAFE_SEGMENT.test(entry) || entry === '.' || entry === '..') return false;
|
|
52
|
+
if (NEVER_SYNC.has(entry)) return false;
|
|
53
|
+
if (RESERVED_SHARED.has(entry)) return false;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { homedir, hostname } from 'node:os';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { isValidSharedDir } from './config.sharedDirs.guard.ts';
|
|
5
|
+
import { warn } from './utils.ts';
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Resolved home directory. Uses Node's `os.homedir()` which reads `$HOME` on
|
|
6
9
|
* POSIX and falls back to `getpwuid_r()` when the env var is unset. Returns
|
|
@@ -74,8 +77,34 @@ export const SHARED_LINKS = [
|
|
|
74
77
|
'commands',
|
|
75
78
|
'rules',
|
|
76
79
|
'my-statusline.cjs',
|
|
80
|
+
'hooks',
|
|
77
81
|
] as const;
|
|
78
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Returns the union of `SHARED_LINKS` and any validated entries from
|
|
85
|
+
* `map.sharedDirs`. Entries that fail the `isValidSharedDir` guard (path
|
|
86
|
+
* separators, NEVER_SYNC names, reserved shared/ names) are dropped with a
|
|
87
|
+
* single WARN per entry; the remaining valid entries are appended after the
|
|
88
|
+
* static `SHARED_LINKS` names. Callers iterate the result with `for...of` to
|
|
89
|
+
* apply the same symlink machinery to both built-in and user-configured dirs.
|
|
90
|
+
*
|
|
91
|
+
* @param map - Parsed `path-map.json` content.
|
|
92
|
+
* @returns Array of link names to symlink under `~/.claude/`.
|
|
93
|
+
*/
|
|
94
|
+
export function allSharedLinks(map: PathMap): string[] {
|
|
95
|
+
const extras: string[] = [];
|
|
96
|
+
for (const entry of map.sharedDirs ?? []) {
|
|
97
|
+
if (isValidSharedDir(entry)) {
|
|
98
|
+
extras.push(entry);
|
|
99
|
+
} else {
|
|
100
|
+
warn(
|
|
101
|
+
`sharedDirs entry ${JSON.stringify(entry)} is invalid (path separator, reserved name, or NEVER_SYNC); skipping`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...SHARED_LINKS, ...extras];
|
|
106
|
+
}
|
|
107
|
+
|
|
79
108
|
/**
|
|
80
109
|
* Whitelist of names allowed in `path-map.json`'s top-level `extras` field.
|
|
81
110
|
* Each entry is either a directory name (e.g. `.planning`) OR a single
|
|
@@ -94,9 +123,13 @@ export const SUPPORTED_EXTRAS = ['.planning', 'CLAUDE.md'] as const;
|
|
|
94
123
|
* Path segments that must never cross the sync boundary in either direction.
|
|
95
124
|
* Defense-in-depth pair with `PUSH_ALLOWED_STATIC`: even if the allow-list
|
|
96
125
|
* misses a path, anything containing one of these segments is hard-blocked.
|
|
126
|
+
* Also the deny-list the `sharedDirs` opt-in is validated against, so a user
|
|
127
|
+
* cannot symlink a host-local secret or cache into the shared repo by naming
|
|
128
|
+
* it in `path-map.json`.
|
|
97
129
|
*/
|
|
98
130
|
export const NEVER_SYNC = new Set([
|
|
99
131
|
'.claude.json',
|
|
132
|
+
'.credentials.json',
|
|
100
133
|
'history.jsonl',
|
|
101
134
|
'settings.local.json',
|
|
102
135
|
'stats-cache.json',
|
|
@@ -109,6 +142,16 @@ export const NEVER_SYNC = new Set([
|
|
|
109
142
|
'statsig',
|
|
110
143
|
'telemetry',
|
|
111
144
|
'ide',
|
|
145
|
+
// Host-local caches and runtime state: never useful to share, and named here
|
|
146
|
+
// so the sharedDirs guard rejects an accidental opt-in.
|
|
147
|
+
'cache',
|
|
148
|
+
'backups',
|
|
149
|
+
'paste-cache',
|
|
150
|
+
'daemon',
|
|
151
|
+
'jobs',
|
|
152
|
+
'tasks',
|
|
153
|
+
'security',
|
|
154
|
+
'sessions',
|
|
112
155
|
]);
|
|
113
156
|
|
|
114
157
|
// Schema-drift baseline for `~/.claude/settings.json`; top-level keys not in
|
|
@@ -134,6 +177,7 @@ export const PUSH_ALLOWED_STATIC = [
|
|
|
134
177
|
'shared/commands/',
|
|
135
178
|
'shared/rules/',
|
|
136
179
|
'shared/.gitignore',
|
|
180
|
+
'shared/hooks/',
|
|
137
181
|
'hosts/',
|
|
138
182
|
'path-map.json',
|
|
139
183
|
] as const;
|
|
@@ -150,8 +194,16 @@ export const PUSH_ALLOWED_STATIC = [
|
|
|
150
194
|
* consumers against `SUPPORTED_EXTRAS`. Absence of the field is equivalent
|
|
151
195
|
* to no extras for any project; legacy `path-map.json` files without an
|
|
152
196
|
* `extras` block continue to work unchanged (no migration required).
|
|
197
|
+
*
|
|
198
|
+
* Optional `sharedDirs` field (additive, top-level): opt-in global support
|
|
199
|
+
* directories under `~/.claude/` to include in the `SHARED_LINKS` symlink set.
|
|
200
|
+
* Each entry is a single path segment (e.g. `"get-shit-done"`); entries that
|
|
201
|
+
* fail the `isValidSharedDir` guard are dropped with a WARN and never reach the
|
|
202
|
+
* filesystem. Absence of the field is equivalent to no extra shared dirs; legacy
|
|
203
|
+
* `path-map.json` files without a `sharedDirs` block continue to work unchanged.
|
|
153
204
|
*/
|
|
154
205
|
export type PathMap = {
|
|
155
206
|
projects: Record<string, Record<string, string>>;
|
|
156
207
|
extras?: Record<string, string[]>;
|
|
208
|
+
sharedDirs?: string[];
|
|
157
209
|
};
|
package/src/diff-lines.ts
CHANGED
|
@@ -27,14 +27,13 @@ export function diffLinesToUnified(oldStr: string, newStr: string): string[] {
|
|
|
27
27
|
for (const part of parts) {
|
|
28
28
|
const partLines = part.value.split('\n');
|
|
29
29
|
// A part value ending in '\n' yields a trailing '' after split; drop it.
|
|
30
|
-
if (partLines
|
|
30
|
+
if (partLines.at(-1) === '') {
|
|
31
31
|
partLines.pop();
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
: (line: string) => ` ${line}`;
|
|
33
|
+
let prefix: (line: string) => string;
|
|
34
|
+
if (part.removed) prefix = (line) => red(`-${line}`);
|
|
35
|
+
else if (part.added) prefix = (line) => green(`+${line}`);
|
|
36
|
+
else prefix = (line) => ` ${line}`;
|
|
38
37
|
for (const line of partLines) {
|
|
39
38
|
lines.push(prefix(line));
|
|
40
39
|
}
|
package/src/diff.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { HOME, REPO_HOME } from './config.ts';
|
|
4
|
+
import { HOME, REPO_HOME, type PathMap } from './config.ts';
|
|
5
5
|
import { computePreview } from './preview.ts';
|
|
6
6
|
import { emitSummary } from './summary.ts';
|
|
7
7
|
import { die, fail, NomadFatal } from './utils.ts';
|
|
8
8
|
import { freshBackupTs } from './utils.fs.ts';
|
|
9
|
+
import { readPathMap } from './utils.json.ts';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* `nomad diff` command. Offline-safe, read-only preview surface that runs
|
|
@@ -37,7 +38,11 @@ export function cmdDiff(): void {
|
|
|
37
38
|
const ts = freshBackupTs(backupBase);
|
|
38
39
|
// Preview log lines reference `ts` so output stays consistent with
|
|
39
40
|
// pull --dry-run; the backup root itself is intentionally NOT created.
|
|
40
|
-
|
|
41
|
+
// Read the map tolerantly (offline-safe: fall back to no-sharedDirs when
|
|
42
|
+
// path-map.json is absent from a partially-scaffolded repo).
|
|
43
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
44
|
+
const map: PathMap = existsSync(mapPath) ? readPathMap(mapPath) : { projects: {} };
|
|
45
|
+
const result = computePreview(ts, map);
|
|
41
46
|
emitSummary('diff', result.unmapped);
|
|
42
47
|
} catch (err) {
|
|
43
48
|
if (err instanceof NomadFatal) {
|
package/src/extras-sync.ts
CHANGED
|
@@ -3,21 +3,17 @@ import { join } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import { HOME, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
|
|
5
5
|
import { listDivergingFiles } from './extras-sync.diff.ts';
|
|
6
|
-
import {
|
|
7
|
-
copyExtras,
|
|
8
|
-
eachExtrasTarget,
|
|
9
|
-
loadValidatedExtras,
|
|
10
|
-
type ExtrasCounts,
|
|
11
|
-
type ValidatedExtras,
|
|
12
|
-
} from './extras-sync.core.ts';
|
|
6
|
+
import { eachExtrasTarget, loadValidatedExtras, type ExtrasCounts } from './extras-sync.core.ts';
|
|
13
7
|
import { assertSafeLogical } from './extras-sync.guards.ts';
|
|
14
8
|
import { warn } from './utils.ts';
|
|
15
9
|
import { encodePath } from './utils.json.ts';
|
|
16
10
|
|
|
17
11
|
// Re-export the shared primitives so existing import sites that pull them from
|
|
18
12
|
// `./extras-sync.ts` (tests call `copyExtras` directly) keep working unchanged.
|
|
19
|
-
export { copyExtras
|
|
20
|
-
export type {
|
|
13
|
+
export { copyExtras } from './extras-sync.core.ts';
|
|
14
|
+
export type { ValidatedExtras } from './extras-sync.core.ts';
|
|
15
|
+
export { eachExtrasTarget, loadValidatedExtras };
|
|
16
|
+
export type { ExtrasCounts };
|
|
21
17
|
|
|
22
18
|
// The two public remap ops live in the sibling module to hold the soft
|
|
23
19
|
// line-cap; re-exported here so `./extras-sync.ts` stays the public surface.
|
package/src/init.snapshot.ts
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
import { copyFileSync, cpSync, existsSync, rmSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { CLAUDE_HOME, HOST, REPO_HOME,
|
|
4
|
+
import { allSharedLinks, CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
5
|
import { die, log } from './utils.ts';
|
|
6
6
|
import { writeJsonAtomic } from './utils.fs.ts';
|
|
7
7
|
import { readJson } from './utils.json.ts';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Overlay `~/.claude/`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* translates `~/.claude/settings.json`
|
|
18
|
-
* via `writeJsonAtomic`. Does NOT
|
|
19
|
-
* user-visible next-step +
|
|
20
|
-
* phrasing stays co-located
|
|
10
|
+
* Overlay `~/.claude/` entries for every name in `allSharedLinks(map)` (the
|
|
11
|
+
* static shared-link set plus any validated `sharedDirs` entries) onto the
|
|
12
|
+
* freshly-written scaffold under `REPO_HOME/shared/`. Regular files
|
|
13
|
+
* (`CLAUDE.md`, `my-statusline.cjs`) go through `copyFileSync` so the
|
|
14
|
+
* placeholder is overwritten; directories (`agents`, `skills`, `commands`,
|
|
15
|
+
* `rules`) drop their just-written `.gitkeep` marker first and then go through
|
|
16
|
+
* `cpSync` with `force: false`, so any unexpected pre-existing destination
|
|
17
|
+
* content surfaces as an error. Also translates `~/.claude/settings.json`
|
|
18
|
+
* (when present) into `hosts/<HOST>.json` via `writeJsonAtomic`. Does NOT
|
|
19
|
+
* modify `~/.claude/`; the caller emits the user-visible next-step +
|
|
20
|
+
* originals-not-removed log lines so the canonical phrasing stays co-located
|
|
21
|
+
* with `cmdInit` itself.
|
|
21
22
|
*/
|
|
22
|
-
export function snapshotIntoShared(): void {
|
|
23
|
-
for (const name of
|
|
23
|
+
export function snapshotIntoShared(map: PathMap): void {
|
|
24
|
+
for (const name of allSharedLinks(map)) {
|
|
24
25
|
const src = join(CLAUDE_HOME, name);
|
|
25
26
|
if (!existsSync(src)) continue;
|
|
26
27
|
const dst = join(REPO_HOME, 'shared', name);
|
package/src/init.ts
CHANGED
|
@@ -29,7 +29,7 @@ const SHARED_CLAUDE_MD =
|
|
|
29
29
|
* with the SHARED_LINKS contract in `src/config.ts` (those same names are
|
|
30
30
|
* symlinked into `~/.claude/` on every pull).
|
|
31
31
|
*/
|
|
32
|
-
const SHARED_KEEP_DIRS = ['agents', 'skills', 'commands', 'rules'] as const;
|
|
32
|
+
const SHARED_KEEP_DIRS = ['agents', 'skills', 'commands', 'rules', 'hooks'] as const;
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Pre-flight refuse-to-clobber list. If ANY of these absolute paths
|
|
@@ -112,7 +112,10 @@ export function cmdInit(
|
|
|
112
112
|
log('created path-map.json');
|
|
113
113
|
|
|
114
114
|
if (snapshot) {
|
|
115
|
-
|
|
115
|
+
// In the init path, path-map.json was just written as `{ projects: {} }`
|
|
116
|
+
// (preflight refuses a pre-existing one), so sharedDirs is empty by
|
|
117
|
+
// construction. Pass the minimal map literal to satisfy the type.
|
|
118
|
+
snapshotIntoShared({ projects: {} });
|
|
116
119
|
log(`snapshot staged in shared/; review, then 'nomad push' to share with other hosts.`);
|
|
117
120
|
log('~/.claude/ originals were NOT removed.');
|
|
118
121
|
}
|
package/src/links.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { existsSync, lstatSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { CLAUDE_HOME, HOST, REPO_HOME,
|
|
4
|
+
import { allSharedLinks, CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
5
|
import { die, log, warn } from './utils.ts';
|
|
6
6
|
import { backupBeforeWrite, ensureSymlink, writeJsonAtomic } from './utils.fs.ts';
|
|
7
7
|
import { deepMerge, readJson } from './utils.json.ts';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Symlink
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* Symlink every name in `allSharedLinks(map)` (the static shared-link set
|
|
11
|
+
* plus any validated `sharedDirs` entries from `path-map.json`) from the
|
|
12
|
+
* repo's `shared/` dir into `~/.claude/`. Two-pass: first back up and remove
|
|
13
|
+
* any pre-existing non-symlink at each link path (auto-move using `ts` as the
|
|
14
|
+
* backup timestamp), then create the symlinks. Skips a link entirely when the
|
|
15
|
+
* repo has no `shared/<name>` counterpart, so a host where `shared/commands/`
|
|
16
|
+
* does not exist keeps its local `~/.claude/commands/` instead of having it
|
|
17
|
+
* silently deleted. `sharedDirs` entries route through the identical two-pass
|
|
18
|
+
* logic (refuse-non-symlink / backup / dryRun-log behavior is unchanged).
|
|
17
19
|
*
|
|
18
20
|
* `opts.dryRun` (default `false`): when `true`, no disk mutation occurs. The
|
|
19
21
|
* function logs `would auto-move non-symlink:` and `would create symlink:`
|
|
@@ -21,9 +23,12 @@ import { deepMerge, readJson } from './utils.json.ts';
|
|
|
21
23
|
* call with no opts arg or with `dryRun: false` keeps the prior mutating
|
|
22
24
|
* behavior.
|
|
23
25
|
*/
|
|
24
|
-
export function applySharedLinks(ts: string, opts: { dryRun?: boolean } = {}): void {
|
|
26
|
+
export function applySharedLinks(ts: string, map: PathMap, opts: { dryRun?: boolean } = {}): void {
|
|
25
27
|
const dryRun = opts.dryRun === true;
|
|
26
|
-
|
|
28
|
+
// Derive once: allSharedLinks emits a WARN per invalid sharedDirs entry, so
|
|
29
|
+
// calling it per loop would double every such warning in a single run.
|
|
30
|
+
const linkNames = allSharedLinks(map);
|
|
31
|
+
for (const name of linkNames) {
|
|
27
32
|
const linkPath = join(CLAUDE_HOME, name);
|
|
28
33
|
const target = join(REPO_HOME, 'shared', name);
|
|
29
34
|
if (!existsSync(linkPath)) continue;
|
|
@@ -36,7 +41,7 @@ export function applySharedLinks(ts: string, opts: { dryRun?: boolean } = {}): v
|
|
|
36
41
|
backupBeforeWrite(linkPath, ts);
|
|
37
42
|
rmSync(linkPath, { recursive: true, force: true });
|
|
38
43
|
}
|
|
39
|
-
for (const name of
|
|
44
|
+
for (const name of linkNames) {
|
|
40
45
|
const target = join(REPO_HOME, 'shared', name);
|
|
41
46
|
if (!existsSync(target)) continue;
|
|
42
47
|
if (dryRun) {
|
package/src/preview.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
|
|
4
|
+
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
5
|
import { diffLinesToUnified } from './diff-lines.ts';
|
|
6
6
|
import { applySharedLinks } from './links.ts';
|
|
7
7
|
import { remapPull } from './remap.ts';
|
|
@@ -102,13 +102,16 @@ function previewSettings(basePath: string, hostPath: string, settingsPath: strin
|
|
|
102
102
|
*
|
|
103
103
|
* Settings diff output goes through `log()` so each line gets the info-prefixed
|
|
104
104
|
* prefix, keeping output channels consistent across the three sections.
|
|
105
|
+
*
|
|
106
|
+
* @param map - parsed path-map.json; callers fall back to `{ projects: {} }`
|
|
107
|
+
* when the file is absent so the offline/fresh-clone contract holds.
|
|
105
108
|
*/
|
|
106
|
-
export function computePreview(ts: string): { unmapped: number; collisions: number } {
|
|
109
|
+
export function computePreview(ts: string, map: PathMap): { unmapped: number; collisions: number } {
|
|
107
110
|
log(`would pull on host=${HOST} (dry-run; no mutation)`);
|
|
108
111
|
|
|
109
112
|
// Symlinks: applySharedLinks emits its own would-create / would-auto-move
|
|
110
113
|
// lines. dryRun:true is mandatory; a real call here would mutate disk.
|
|
111
|
-
applySharedLinks(ts, { dryRun: true });
|
|
114
|
+
applySharedLinks(ts, map, { dryRun: true });
|
|
112
115
|
|
|
113
116
|
previewSettings(
|
|
114
117
|
join(REPO_HOME, 'shared', 'settings.base.json'),
|
package/src/summary.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { green, okGlyph, warnGlyph, yellow } from './color.ts';
|
|
2
2
|
import { ok, warn } from './utils.ts';
|
|
3
3
|
|
|
4
|
+
/** The three originating commands that share the end-of-run summary line. */
|
|
5
|
+
type SummaryVerb = 'pull' | 'push' | 'diff';
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Pure phrasing core for the end-of-run summary line shared by cmdPull,
|
|
6
9
|
* cmdPush, and cmdDiff. Returns the message `text` (without any status glyph)
|
|
@@ -27,7 +30,7 @@ import { ok, warn } from './utils.ts';
|
|
|
27
30
|
* @returns `{ text, clean }` where `clean` is true on the no-warning outcome.
|
|
28
31
|
*/
|
|
29
32
|
export function summaryText(
|
|
30
|
-
verb:
|
|
33
|
+
verb: SummaryVerb,
|
|
31
34
|
unmapped: number,
|
|
32
35
|
collisions = 0,
|
|
33
36
|
extrasSkipped = 0,
|
|
@@ -63,7 +66,7 @@ export function summaryText(
|
|
|
63
66
|
* @returns the rendered row string for the Summary section.
|
|
64
67
|
*/
|
|
65
68
|
export function summaryRow(
|
|
66
|
-
verb:
|
|
69
|
+
verb: SummaryVerb,
|
|
67
70
|
unmapped: number,
|
|
68
71
|
collisions = 0,
|
|
69
72
|
extrasSkipped = 0,
|
|
@@ -85,7 +88,7 @@ export function summaryRow(
|
|
|
85
88
|
* contract). `cmdDiff` still calls this for its standalone summary line.
|
|
86
89
|
*/
|
|
87
90
|
export function emitSummary(
|
|
88
|
-
verb:
|
|
91
|
+
verb: SummaryVerb,
|
|
89
92
|
unmapped: number,
|
|
90
93
|
collisions = 0,
|
|
91
94
|
extrasSkipped = 0,
|