claude-nomad 0.25.5 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +40 -40
- package/package.json +2 -1
- package/src/commands.doctor.check-shared.scan.ts +29 -5
- package/src/commands.doctor.checks.pathmap.ts +2 -2
- package/src/commands.doctor.checks.repo.ts +38 -13
- package/src/commands.doctor.format.ts +22 -7
- package/src/commands.doctor.ts +1 -1
- package/src/commands.doctor.version.ts +15 -9
- package/src/diff-lines.ts +43 -0
- package/src/preview.ts +17 -36
- package/src/push-gitleaks.scan.ts +7 -0
- package/src/remap.ts +62 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.26.1](https://github.com/funkadelic/claude-nomad/compare/v0.26.0...v0.26.1) (2026-05-27)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
* **preview:** replace diffJsonStrings parallel walk with jsdiff LCS line diff ([#159](https://github.com/funkadelic/claude-nomad/issues/159)) ([d9c0dca](https://github.com/funkadelic/claude-nomad/commit/d9c0dcae7b417d069e826ce3b825e9f23115ba2d))
|
|
9
|
+
* **remap:** fail closed on path-map collisions in remapPush before any write ([#156](https://github.com/funkadelic/claude-nomad/issues/156)) ([db33157](https://github.com/funkadelic/claude-nomad/commit/db33157faa02b5439278ae4e92a02bd671faee72))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
* **codeql:** skip analysis on release-please PR branches ([#160](https://github.com/funkadelic/claude-nomad/issues/160)) ([2bfed46](https://github.com/funkadelic/claude-nomad/commit/2bfed463ccd76f6331e5ab775bbd5d19df2898e9))
|
|
15
|
+
* **tests:** skip PR-time test matrix on release-please PR branches ([#158](https://github.com/funkadelic/claude-nomad/issues/158)) ([222e27b](https://github.com/funkadelic/claude-nomad/commit/222e27b2643382136505f72e148c17c7ecb7a08f))
|
|
16
|
+
|
|
17
|
+
## [0.26.0](https://github.com/funkadelic/claude-nomad/compare/v0.25.5...v0.26.0) (2026-05-27)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
* **doctor:** readability pass on doctor output ([#154](https://github.com/funkadelic/claude-nomad/issues/154)) ([d4bb912](https://github.com/funkadelic/claude-nomad/commit/d4bb912180dbae8e12dfafb4ddd71abb54d0d441))
|
|
23
|
+
|
|
3
24
|
## [0.25.5](https://github.com/funkadelic/claude-nomad/compare/v0.25.4...v0.25.5) (2026-05-27)
|
|
4
25
|
|
|
5
26
|
|
package/README.md
CHANGED
|
@@ -12,15 +12,16 @@
|
|
|
12
12
|
**Your entire Claude Code setup, on every machine. History included, every push secret-scanned.**
|
|
13
13
|
|
|
14
14
|
Open Claude Code on a second machine and it is a blank slate: none of your custom agents, slash
|
|
15
|
-
commands, tuned settings, or past conversations. claude-nomad keeps all of it in sync through a
|
|
15
|
+
commands, tuned settings, or past conversations. **claude-nomad** keeps all of it in sync through a
|
|
16
16
|
private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything
|
|
17
17
|
is there, conversations included.
|
|
18
18
|
|
|
19
|
-
- **Resume your sessions on any
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
- **Resume your Claude Code [sessions](https://code.claude.com/docs/en/agent-sdk/sessions) on any
|
|
20
|
+
machine.** Start a conversation on your desktop and pick it up on your laptop. **claude-nomad**
|
|
21
|
+
remaps the file paths Claude Code embeds in every transcript, so your history follows you instead
|
|
22
|
+
of getting stranded on the box where it started.
|
|
22
23
|
- **Secret-scanned, private by default.** Your `~/.claude/` also holds OAuth tokens, MCP
|
|
23
|
-
credentials, and the full text of every conversation, so claude-nomad is deliberate about what
|
|
24
|
+
credentials, and the full text of every conversation, so **claude-nomad** is deliberate about what
|
|
24
25
|
leaves your machine: credentials and ephemeral state never sync, only an explicit allow-list of
|
|
25
26
|
paths is pushed, and everything that does go up is scanned by
|
|
26
27
|
[gitleaks](https://github.com/gitleaks/gitleaks) before it leaves your machine; the push aborts on
|
|
@@ -30,7 +31,7 @@ is there, conversations included.
|
|
|
30
31
|
and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on
|
|
31
32
|
top instead of clobbering your shared defaults.
|
|
32
33
|
|
|
33
|
-
Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history
|
|
34
|
+
Not dotfiles, not rsync. **claude-nomad** understands Claude Code's state, so your session history
|
|
34
35
|
survives different file paths and your secrets never ride along.
|
|
35
36
|
|
|
36
37
|
For anyone running Claude Code on more than one machine: a laptop and a desktop, a Mac and a WSL
|
|
@@ -65,8 +66,8 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
|
|
|
65
66
|
|
|
66
67
|
## Quickstart
|
|
67
68
|
|
|
68
|
-
If you already have a private claude-nomad mirror (see [Setup](#setup) for the one-time
|
|
69
|
-
adding a new host is three steps:
|
|
69
|
+
If you already have a private **claude-nomad** mirror (see [Setup](#setup) for the one-time
|
|
70
|
+
bootstrap), adding a new host is three steps:
|
|
70
71
|
|
|
71
72
|
```bash
|
|
72
73
|
$ npm i -g claude-nomad
|
|
@@ -97,8 +98,8 @@ First-host bootstrap and the safe-migration sequence for a populated `~/.claude/
|
|
|
97
98
|
|
|
98
99
|
## How it works (two-repo model)
|
|
99
100
|
|
|
100
|
-
claude-nomad is a **tool**, not a config store. You maintain a separate **private** repo that
|
|
101
|
-
your actual config (`CLAUDE.md`, agents, skills, settings overrides, session transcripts). The
|
|
101
|
+
**claude-nomad** is a **tool**, not a config store. You maintain a separate **private** repo that
|
|
102
|
+
holds your actual config (`CLAUDE.md`, agents, skills, settings overrides, session transcripts). The
|
|
102
103
|
tool's source and your config end up coexisting in one working tree on each host.
|
|
103
104
|
|
|
104
105
|
```text
|
|
@@ -205,8 +206,8 @@ cleanly instead of creating an orphan `~/.claude/projects/TBD/`. Replace each `"
|
|
|
205
206
|
path when you bring up that host.
|
|
206
207
|
|
|
207
208
|
On `push`, sessions in `~/.claude/projects/-Users-you-code-my-example-repo/` get copied to
|
|
208
|
-
`shared/projects/my-example-repo/`. On `pull` on another machine, they get copied to that
|
|
209
|
-
encoded path. `claude --resume` then finds them (see
|
|
209
|
+
`shared/projects/my-example-repo/`. On `nomad pull` on another machine, they get copied to that
|
|
210
|
+
host's encoded path. `claude --resume` then finds them (see
|
|
210
211
|
[What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs) for the
|
|
211
212
|
cross-OS cwd-binding gotcha).
|
|
212
213
|
|
|
@@ -215,10 +216,10 @@ working unchanged. Each value is an array of directory or root-file names (e.g.
|
|
|
215
216
|
`CLAUDE.md`) checked against `SUPPORTED_EXTRAS` in `src/config.ts`; anything outside that whitelist
|
|
216
217
|
is skipped with a log line, so an unrecognized name cannot widen the sync surface.
|
|
217
218
|
|
|
218
|
-
On `push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is
|
|
219
|
-
to `shared/extras/<logical>/<name>` and goes through the same staged-tree gitleaks scan as
|
|
220
|
-
everything else. On `pull`, the reverse copy runs after `git pull --rebase`, and just before
|
|
221
|
-
overwrites your working tree a divergence check compares the incoming content against your local
|
|
219
|
+
On `nomad push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is
|
|
220
|
+
copied to `shared/extras/<logical>/<name>` and goes through the same staged-tree gitleaks scan as
|
|
221
|
+
everything else. On `nomad pull`, the reverse copy runs after `git pull --rebase`, and just before
|
|
222
|
+
it overwrites your working tree a divergence check compares the incoming content against your local
|
|
222
223
|
copy and prints a per-file WARN naming anything that differs.
|
|
223
224
|
|
|
224
225
|
Your existing local content is backed up under `~/.cache/claude-nomad/backup/<ts>/extras/` before
|
|
@@ -229,10 +230,11 @@ the pull copy lands, so an unexpected overwrite is always recoverable.
|
|
|
229
230
|
`settings.base.json` holds portable defaults (model, permissions, plugins).
|
|
230
231
|
`hosts/<NOMAD_HOST>.json` holds machine-specific patches. They're deep-merged on every pull (scalars
|
|
231
232
|
override, objects merge recursively, arrays replace). Keys that used to be force-marked per-host
|
|
232
|
-
because they embedded absolute paths (`statusLine.command`, `hooks`) can live in
|
|
233
|
-
the commands with `$HOME` (e.g.
|
|
234
|
-
Code runs them through a shell so
|
|
235
|
-
machine-specific values (env, MCP URLs,
|
|
233
|
+
because they embedded absolute paths (`statusLine.command`, `hooks`) can live in
|
|
234
|
+
`settings.base.json` if you write the commands with `$HOME` (e.g.
|
|
235
|
+
`"command": "node \"$HOME/.claude/my-statusline.cjs\""`); Claude Code runs them through a shell so
|
|
236
|
+
shell expansion applies. Reserve per-host files for truly machine-specific values (env, MCP URLs,
|
|
237
|
+
host-only model overrides).
|
|
236
238
|
|
|
237
239
|
`shared/settings.base.json`:
|
|
238
240
|
|
|
@@ -243,7 +245,7 @@ machine-specific values (env, MCP URLs, host-only model overrides).
|
|
|
243
245
|
}
|
|
244
246
|
```
|
|
245
247
|
|
|
246
|
-
`hosts/<your-
|
|
248
|
+
`hosts/<your-other-host>.json`:
|
|
247
249
|
|
|
248
250
|
```json
|
|
249
251
|
{
|
|
@@ -252,7 +254,7 @@ machine-specific values (env, MCP URLs, host-only model overrides).
|
|
|
252
254
|
}
|
|
253
255
|
```
|
|
254
256
|
|
|
255
|
-
|
|
257
|
+
Results on `your-other-host`: opus 4.7, the local Ollama env var, plus the shared permissions array.
|
|
256
258
|
|
|
257
259
|
> [!CAUTION] Never hand-edit `~/.claude/settings.json` on a synced host. It's regenerated on every
|
|
258
260
|
> `nomad pull` from base + host, so your edits will be clobbered. Edit the base or host file in the
|
|
@@ -273,8 +275,8 @@ Read these before adopting so you opt in with eyes open.
|
|
|
273
275
|
surface. Unsafe path-map values (path-traversal in `logical` keys, non-absolute or unnormalized
|
|
274
276
|
`localRoot` values) abort the run before any file is touched, so a malformed entry fails loudly
|
|
275
277
|
instead of corrupting state.
|
|
276
|
-
- **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so
|
|
277
|
-
picker's `cd ... && claude --resume <id>` line fails on a different host. Use
|
|
278
|
+
- **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so
|
|
279
|
+
Claude Code's picker's `cd ... && claude --resume <id>` line fails on a different host. Use
|
|
278
280
|
`nomad doctor --resume-cmd <id>` for a host-local equivalent (see
|
|
279
281
|
[Cross-OS resume](#cross-os-resume)). The sidecar approach preserves transcript byte-equality.
|
|
280
282
|
- **Empty directories don't survive sync.** Git doesn't track empty dirs; `nomad doctor` reports
|
|
@@ -293,13 +295,14 @@ Read these before adopting so you opt in with eyes open.
|
|
|
293
295
|
it is absent or mismatched)
|
|
294
296
|
- A **private** GitHub repo (or any Git remote you control)
|
|
295
297
|
|
|
296
|
-
**Optional:**
|
|
298
|
+
**Optional, but recommended:**
|
|
297
299
|
|
|
298
|
-
- `gh` (GitHub CLI), used only by `nomad init` to auto-disable Actions on
|
|
299
|
-
missing or unauthenticated, init prints a manual fallback tip and
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
the
|
|
300
|
+
- `gh` ([GitHub CLI](https://cli.github.com/)), used only by `nomad init` to auto-disable Actions on
|
|
301
|
+
the private repo; if it is missing or unauthenticated, init prints a manual fallback tip and
|
|
302
|
+
continues
|
|
303
|
+
- [curl](https://curl.se/), used only by the version/update check (the `nomad doctor` latest-release
|
|
304
|
+
line and the post-`nomad update` check); it degrades silently when curl is absent or offline, so
|
|
305
|
+
the rest of the CLI works without it
|
|
303
306
|
|
|
304
307
|
## Setup
|
|
305
308
|
|
|
@@ -329,7 +332,7 @@ enforces an Actions policy upstream).
|
|
|
329
332
|
|
|
330
333
|
> [!WARNING] If you ever flip the mirror to public, both protections evaporate: CI starts firing on
|
|
331
334
|
> every `nomad push` against `main`, and your session transcripts (which include conversation
|
|
332
|
-
> content) become world-readable. Keep it private
|
|
335
|
+
> content) become world-readable. **Keep it private.**
|
|
333
336
|
|
|
334
337
|
### Bootstrap
|
|
335
338
|
|
|
@@ -342,10 +345,10 @@ $ gh repo create <your-username>/claude-nomad --private
|
|
|
342
345
|
# 2. Copy the public tool into your private repo. A bare clone followed by a
|
|
343
346
|
# mirror push makes a complete, independent copy (every branch and tag) with
|
|
344
347
|
# no fork link back to upstream, which is what lets you keep it private. Once, ever.
|
|
345
|
-
$ git clone --bare git@github.com:funkadelic/claude-nomad.git /tmp/
|
|
346
|
-
$ cd /tmp/
|
|
348
|
+
$ git clone --bare git@github.com:funkadelic/claude-nomad.git /tmp/claude-nomad.git # download a full copy
|
|
349
|
+
$ cd /tmp/claude-nomad.git
|
|
347
350
|
$ git push --mirror git@github.com:<your-username>/claude-nomad.git # upload it to your private repo
|
|
348
|
-
$ cd .. && rm -rf /tmp/
|
|
351
|
+
$ cd .. && rm -rf /tmp/claude-nomad.git
|
|
349
352
|
|
|
350
353
|
# 3. Install the CLI globally and clone your private copy. Repeat on every host.
|
|
351
354
|
$ npm i -g claude-nomad
|
|
@@ -380,9 +383,6 @@ $ nomad init
|
|
|
380
383
|
# starting point. Stages shared/ and writes hosts/<NOMAD_HOST>.json from
|
|
381
384
|
# your current ~/.claude/settings.json. Does NOT touch the originals.
|
|
382
385
|
$ nomad init --snapshot
|
|
383
|
-
|
|
384
|
-
# Either form accepts --keep-actions to skip the auto-disable.
|
|
385
|
-
$ nomad init --keep-actions
|
|
386
386
|
```
|
|
387
387
|
|
|
388
388
|
`nomad init` refuses to clobber existing scaffold artifacts, so re-running on a populated repo is a
|
|
@@ -533,9 +533,9 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
|
|
|
533
533
|
| `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). |
|
|
534
534
|
| `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
535
|
|
|
536
|
-
The version-check emits ``⚠︎
|
|
537
|
-
install is behind the latest upstream release, and `✓
|
|
538
|
-
silently skips on network failures.
|
|
536
|
+
The version-check emits ``⚠︎ claude-nomad: <local> -> <latest> (run `nomad update`)`` when the local
|
|
537
|
+
install is behind the latest upstream release, and `✓ claude-nomad: <local> (latest)` when current.
|
|
538
|
+
It silently skips on network failures.
|
|
539
539
|
|
|
540
540
|
Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line
|
|
541
541
|
`⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-nomad",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
|
|
6
6
|
"keywords": [
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"vitest": "^4.1.6"
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
|
+
"diff": "^9.0.0",
|
|
83
84
|
"picocolors": "^1.1.1",
|
|
84
85
|
"tsx": "^4.22.2"
|
|
85
86
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
-
import { green, red, okGlyph, failGlyph } from './color.ts';
|
|
17
|
+
import { green, red, dim, okGlyph, failGlyph } from './color.ts';
|
|
18
18
|
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
19
19
|
import { CLAUDE_HOME } from './config.ts';
|
|
20
20
|
import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
|
|
@@ -47,16 +47,16 @@ function reportSessionFindings(
|
|
|
47
47
|
): void {
|
|
48
48
|
for (const [sid, counts] of bySession) {
|
|
49
49
|
const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
|
|
50
|
-
addItem(section, `${red(failGlyph)}
|
|
50
|
+
addItem(section, `${red(failGlyph)} ${red(summary)} in session ${sid}`);
|
|
51
51
|
const logical = logicalBySession.get(sid);
|
|
52
52
|
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
53
53
|
if (logical !== undefined) {
|
|
54
54
|
addItem(
|
|
55
55
|
section,
|
|
56
|
-
` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
|
|
56
|
+
` ${dim(`rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
|
-
addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
|
|
59
|
+
addItem(section, ` ${dim('false positive? add a pattern to .gitleaks.toml')}`);
|
|
60
60
|
}
|
|
61
61
|
process.exitCode = 1;
|
|
62
62
|
}
|
|
@@ -70,7 +70,7 @@ function reportSessionFindings(
|
|
|
70
70
|
*/
|
|
71
71
|
function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
|
|
72
72
|
for (const f of other) {
|
|
73
|
-
addItem(section, `${red(failGlyph)}
|
|
73
|
+
addItem(section, `${red(failGlyph)} ${red(f.RuleID)} leak in ${f.File}`);
|
|
74
74
|
}
|
|
75
75
|
process.exitCode = 1;
|
|
76
76
|
}
|
|
@@ -111,6 +111,29 @@ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
|
|
|
111
111
|
return logicalBySession;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Emit a deduplicated description legend in the footer: one `[rule-id]:
|
|
116
|
+
* description` row per distinct RuleID across all findings, set off by a blank
|
|
117
|
+
* line before and after. Sourced from the `Description` gitleaks bakes into
|
|
118
|
+
* each finding, so it needs no network; rules whose description is absent
|
|
119
|
+
* (older gitleaks, custom rules) are skipped, and the whole block (including
|
|
120
|
+
* the surrounding blanks) is omitted when no descriptions are available. The
|
|
121
|
+
* legend lives in the footer so a rule hit across many files or sessions
|
|
122
|
+
* (e.g. `sonar-api-token`) is explained once, not per occurrence.
|
|
123
|
+
*/
|
|
124
|
+
function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): void {
|
|
125
|
+
const descByRule = new Map<string, string>();
|
|
126
|
+
for (const f of findings) {
|
|
127
|
+
if (f.Description && !descByRule.has(f.RuleID)) descByRule.set(f.RuleID, f.Description);
|
|
128
|
+
}
|
|
129
|
+
if (descByRule.size === 0) return;
|
|
130
|
+
addItem(section, '');
|
|
131
|
+
for (const [rule, desc] of descByRule) {
|
|
132
|
+
addItem(section, ` ${red(`[${rule}]`)}: ${dim(desc)}`);
|
|
133
|
+
}
|
|
134
|
+
addItem(section, '');
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
/**
|
|
115
138
|
* Scan the staged temp tree through the shared `scanStagedTree` and emit the
|
|
116
139
|
* result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
|
|
@@ -155,4 +178,5 @@ export function scanAndReport(
|
|
|
155
178
|
if (bySession.size > 0) {
|
|
156
179
|
reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
|
|
157
180
|
}
|
|
181
|
+
emitDescriptionLegend(section, findings);
|
|
158
182
|
}
|
|
@@ -13,7 +13,7 @@ import { encodePath } from './utils.json.ts';
|
|
|
13
13
|
* `process.exitCode = 1`. Read-only: FAIL lines stay on stdout.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
/** Emits the mapped-projects header for the current host and one line per mapped project. */
|
|
16
|
+
/** Emits the mapped-projects header for the current host and one indented child line per mapped project. */
|
|
17
17
|
function reportMappedProjects(section: DoctorSection, map: PathMap): void {
|
|
18
18
|
const mapped = Object.entries(map.projects).filter(([, hosts]) => hosts[HOST]);
|
|
19
19
|
addItem(
|
|
@@ -21,7 +21,7 @@ function reportMappedProjects(section: DoctorSection, map: PathMap): void {
|
|
|
21
21
|
`${dim(infoGlyph)} mapped projects for ${cyan(HOST)}: ${dim(String(mapped.length))}`,
|
|
22
22
|
);
|
|
23
23
|
for (const [name, hosts] of mapped) {
|
|
24
|
-
addItem(section,
|
|
24
|
+
addItem(section, ` ${name} -> ${blue(hosts[HOST])}`);
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -43,7 +43,7 @@ export function isOverrideActive(): boolean {
|
|
|
43
43
|
* Pushes the host identity (info) and the two key path lines (repo and
|
|
44
44
|
* claude-home) with gutter glyphs. Path presence is reported via warnGlyph
|
|
45
45
|
* (not failGlyph) so an absent CLAUDE_HOME does not flip sectionFailed to
|
|
46
|
-
* decorate the Host header with
|
|
46
|
+
* decorate the Host header with a fail glyph. The authoritative empty-repo FAIL is
|
|
47
47
|
* owned by reportRepoState; these two lines remain informational and do
|
|
48
48
|
* NOT mutate process.exitCode.
|
|
49
49
|
*/
|
|
@@ -83,6 +83,16 @@ export function reportRepoState(section: DoctorSection): void {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* True when the repo has a `shared/<name>` source for this link. `applySharedLinks`
|
|
88
|
+
* only creates a symlink when this source exists, so when it does NOT, an absent
|
|
89
|
+
* or dangling link in `~/.claude/` is expected (nothing to sync), not a problem to
|
|
90
|
+
* fix. Doctor uses this to downgrade those rows from a warn to an info note.
|
|
91
|
+
*/
|
|
92
|
+
function repoHasSharedSource(name: string): boolean {
|
|
93
|
+
return existsSync(join(REPO_HOME, 'shared', name));
|
|
94
|
+
}
|
|
95
|
+
|
|
86
96
|
/**
|
|
87
97
|
* Resolve the display item and optional exit-code side-effect for a single
|
|
88
98
|
* shared-link path. Returns `{ line, fail }` where `fail` true means the
|
|
@@ -99,7 +109,12 @@ function classifySharedLink(name: string, p: string): { line: string; fail: bool
|
|
|
99
109
|
} catch (err) {
|
|
100
110
|
const code = (err as NodeJS.ErrnoException).code;
|
|
101
111
|
if (code === 'ENOENT') {
|
|
102
|
-
return
|
|
112
|
+
return repoHasSharedSource(name)
|
|
113
|
+
? {
|
|
114
|
+
line: `${yellow(warnGlyph)} ${name}: missing (run \`nomad pull\` to restore)`,
|
|
115
|
+
fail: false,
|
|
116
|
+
}
|
|
117
|
+
: { line: `${dim(infoGlyph)} ${name}: not synced (nothing in shared/)`, fail: false };
|
|
103
118
|
}
|
|
104
119
|
return { line: `${red(failGlyph)} ${name}: could not stat (${String(code)})`, fail: true };
|
|
105
120
|
}
|
|
@@ -112,7 +127,10 @@ function classifySharedLink(name: string, p: string): { line: string; fail: bool
|
|
|
112
127
|
/**
|
|
113
128
|
* Resolve the display item for a path already confirmed to be a symlink.
|
|
114
129
|
* Follows the link via statSync; a throw means the target is missing or
|
|
115
|
-
* unreadable.
|
|
130
|
+
* unreadable. Never FAILs (`fail: false`): a dangling link whose source still
|
|
131
|
+
* lives in the repo is a WARN with a `nomad pull` hint, a dangling link whose
|
|
132
|
+
* source is gone from the repo is an info note (stale, safe to remove), and a
|
|
133
|
+
* non-ENOENT stat error is a WARN naming the code.
|
|
116
134
|
*/
|
|
117
135
|
function classifySymlinkTarget(name: string, p: string): { line: string; fail: boolean } {
|
|
118
136
|
try {
|
|
@@ -121,10 +139,15 @@ function classifySymlinkTarget(name: string, p: string): { line: string; fail: b
|
|
|
121
139
|
} catch (err) {
|
|
122
140
|
const code = (err as NodeJS.ErrnoException).code;
|
|
123
141
|
if (code === 'ENOENT') {
|
|
124
|
-
return
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
142
|
+
return repoHasSharedSource(name)
|
|
143
|
+
? {
|
|
144
|
+
line: `${yellow(warnGlyph)} ${name}: broken symlink (target missing, run \`nomad pull\`)`,
|
|
145
|
+
fail: false,
|
|
146
|
+
}
|
|
147
|
+
: {
|
|
148
|
+
line: `${dim(infoGlyph)} ${name}: stale symlink (no longer in shared/, safe to remove)`,
|
|
149
|
+
fail: false,
|
|
150
|
+
};
|
|
128
151
|
}
|
|
129
152
|
return {
|
|
130
153
|
line: `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
|
|
@@ -135,13 +158,15 @@ function classifySymlinkTarget(name: string, p: string): { line: string; fail: b
|
|
|
135
158
|
|
|
136
159
|
/**
|
|
137
160
|
* Emits a per-entry status line for each name in SHARED_LINKS
|
|
138
|
-
* (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs
|
|
139
|
-
* process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
|
|
161
|
+
* (okGlyph/warnGlyph/infoGlyph/failGlyph). A non-symlink blocks sync and FAILs
|
|
162
|
+
* via process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
|
|
140
163
|
* that vanishes or becomes unreadable between the probe and the stat yields a
|
|
141
|
-
* row instead of an unhandled throw that aborts the whole doctor run.
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
164
|
+
* row instead of an unhandled throw that aborts the whole doctor run. Severity
|
|
165
|
+
* keys off whether the repo still has a `shared/<name>` source: an absent or
|
|
166
|
+
* dangling link is a WARN with a `nomad pull` hint when the source exists (a
|
|
167
|
+
* real out-of-sync state), and a calm info note when it does not (nothing to
|
|
168
|
+
* sync). A symlink whose target cannot be resolved is never a healthy OK, so a
|
|
169
|
+
* dangling or unreadable link is not masked.
|
|
145
170
|
*/
|
|
146
171
|
export function reportSharedLinks(section: DoctorSection): void {
|
|
147
172
|
for (const name of SHARED_LINKS) {
|
|
@@ -70,17 +70,32 @@ function sectionFailed(s: DoctorSection): boolean {
|
|
|
70
70
|
* headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
|
|
71
71
|
* `grep -F '✗'` catches both row and header failures), and writes one blank
|
|
72
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.
|
|
73
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
|
+
|
|
74
95
|
export function renderDoctor(sections: DoctorSection[]): void {
|
|
75
96
|
const visible = sections.filter((s) => s.items.length > 0);
|
|
76
97
|
for (let i = 0; i < visible.length; i++) {
|
|
77
98
|
if (i > 0) console.log('');
|
|
78
|
-
|
|
79
|
-
const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
|
|
80
|
-
console.log(header);
|
|
81
|
-
for (let j = 0; j < s.items.length; j++) {
|
|
82
|
-
const isLast = j === s.items.length - 1;
|
|
83
|
-
console.log(`${isLast ? ' └ ' : ' ├ '}${s.items[j]}`);
|
|
84
|
-
}
|
|
99
|
+
renderSection(visible[i]);
|
|
85
100
|
}
|
|
86
101
|
}
|
package/src/commands.doctor.ts
CHANGED
|
@@ -62,7 +62,7 @@ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
|
|
|
62
62
|
reportRebaseClean(repository);
|
|
63
63
|
reportMirrorActions(repository);
|
|
64
64
|
|
|
65
|
-
const version = section('Version');
|
|
65
|
+
const version = section('Version Checks');
|
|
66
66
|
reportVersionCheck(version);
|
|
67
67
|
reportNodeEngineCheck(version);
|
|
68
68
|
reportGitleaksVersionCheck(version);
|
|
@@ -11,9 +11,9 @@ import { HOME, UPSTREAM_REPO_SLUG } from './config.ts';
|
|
|
11
11
|
* Soft, offline-tolerant release-version check appended to `cmdDoctor`. Reads
|
|
12
12
|
* the local `package.json.version`, compares it to the latest release tag on
|
|
13
13
|
* the upstream GitHub repo (cached 1h, 3s curl timeout), and emits one of:
|
|
14
|
-
* - `✓
|
|
15
|
-
* - `⚠︎
|
|
16
|
-
* - `ℹ︎
|
|
14
|
+
* - `✓ claude-nomad: <local> (latest)` when local == latest
|
|
15
|
+
* - `⚠︎ claude-nomad: <local> -> <latest>` when local < latest
|
|
16
|
+
* - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when local > latest
|
|
17
17
|
* Every failure path (offline, curl missing, non-2xx, malformed JSON, missing
|
|
18
18
|
* `tag_name`, missing/unreadable package.json) is a SILENT skip; this module
|
|
19
19
|
* never sets `process.exitCode` and never writes to stderr.
|
|
@@ -153,9 +153,9 @@ function fetchLatestTag(): string | null {
|
|
|
153
153
|
* Emit a single, non-fatal version diagnostic for `nomad doctor` by comparing the local package.json version to the latest upstream release.
|
|
154
154
|
*
|
|
155
155
|
* Logs one of:
|
|
156
|
-
* - `✓
|
|
157
|
-
* - `⚠︎
|
|
158
|
-
* - `ℹ︎
|
|
156
|
+
* - `✓ claude-nomad: <local> (latest)` when the versions match
|
|
157
|
+
* - `⚠︎ claude-nomad: <local> -> <latest> (run \`nomad update\`)` when the local version is behind
|
|
158
|
+
* - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when the local version is ahead
|
|
159
159
|
*
|
|
160
160
|
* Any failure to read the local version, retrieve or parse the latest release, or use the cache results in no output and does not change `process.exitCode`.
|
|
161
161
|
*/
|
|
@@ -181,10 +181,16 @@ export function reportVersionCheck(section: DoctorSection): void {
|
|
|
181
181
|
|
|
182
182
|
const cmp = compareSemver(localPure, latest);
|
|
183
183
|
if (cmp === 0) {
|
|
184
|
-
addItem(section, `${green(okGlyph)}
|
|
184
|
+
addItem(section, `${green(okGlyph)} claude-nomad: ${local} (latest)`);
|
|
185
185
|
} else if (cmp === -1) {
|
|
186
|
-
addItem(
|
|
186
|
+
addItem(
|
|
187
|
+
section,
|
|
188
|
+
`${yellow(warnGlyph)} claude-nomad: ${local} -> ${latest} (run \`nomad update\`)`,
|
|
189
|
+
);
|
|
187
190
|
} else {
|
|
188
|
-
addItem(
|
|
191
|
+
addItem(
|
|
192
|
+
section,
|
|
193
|
+
`${dim(infoGlyph)} claude-nomad: ${local} (ahead of latest release ${latest})`,
|
|
194
|
+
);
|
|
189
195
|
}
|
|
190
196
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { diffLines } from 'diff';
|
|
2
|
+
|
|
3
|
+
import { green, red } from './color.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map a jsdiff `diffLines` result for two pre-stringified JSON strings into
|
|
7
|
+
* an array of unified-diff body lines (the two-line header is the caller's
|
|
8
|
+
* responsibility).
|
|
9
|
+
*
|
|
10
|
+
* Each jsdiff part has a `value` that may span multiple lines and may carry a
|
|
11
|
+
* trailing `\n`. The value is split on `\n` and any trailing empty element
|
|
12
|
+
* (produced by the trailing newline) is dropped so that it does not become a
|
|
13
|
+
* spurious blank output line.
|
|
14
|
+
*
|
|
15
|
+
* Line prefix mapping per part type:
|
|
16
|
+
* - context (neither added nor removed): a single leading space then the line
|
|
17
|
+
* - removed (`part.removed === true`): `red('-' + line)`
|
|
18
|
+
* - added (`part.added === true`): `green('+' + line)`
|
|
19
|
+
*
|
|
20
|
+
* Coloring routes through `color.ts` `red`/`green` helpers, so `NO_COLOR` /
|
|
21
|
+
* non-TTY environments degrade to literal `-` / `+` prefixed lines with no
|
|
22
|
+
* ANSI escape sequences. Picocolors owns the detection logic.
|
|
23
|
+
*/
|
|
24
|
+
export function diffLinesToUnified(oldStr: string, newStr: string): string[] {
|
|
25
|
+
const parts = diffLines(oldStr, newStr);
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
const partLines = part.value.split('\n');
|
|
29
|
+
// A part value ending in '\n' yields a trailing '' after split; drop it.
|
|
30
|
+
if (partLines[partLines.length - 1] === '') {
|
|
31
|
+
partLines.pop();
|
|
32
|
+
}
|
|
33
|
+
const prefix = part.removed
|
|
34
|
+
? (line: string) => red(`-${line}`)
|
|
35
|
+
: part.added
|
|
36
|
+
? (line: string) => green(`+${line}`)
|
|
37
|
+
: (line: string) => ` ${line}`;
|
|
38
|
+
for (const line of partLines) {
|
|
39
|
+
lines.push(prefix(line));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -1,53 +1,34 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { green, red } from './color.ts';
|
|
5
4
|
import { CLAUDE_HOME, HOST, REPO_HOME } from './config.ts';
|
|
5
|
+
import { diffLinesToUnified } from './diff-lines.ts';
|
|
6
6
|
import { applySharedLinks } from './links.ts';
|
|
7
7
|
import { remapPull } from './remap.ts';
|
|
8
8
|
import { log } from './utils.ts';
|
|
9
9
|
import { deepMerge, readJson } from './utils.json.ts';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* LCS line diff for two pre-stringified JSON documents via jsdiff. Returns a
|
|
13
|
+
* unified-diff style string: the two literal header lines
|
|
14
|
+
* `--- ~/.claude/settings.json` and `+++ would write`, followed by body lines
|
|
15
|
+
* where unchanged lines are prefixed with a space, removed lines with `-`
|
|
16
|
+
* (red), and added lines with `+` (green). Coloring routes through `color.ts`
|
|
17
|
+
* so `NO_COLOR` / non-TTY environments degrade to literal prefixes with no
|
|
18
|
+
* ANSI escape sequences.
|
|
19
19
|
*
|
|
20
|
-
* Returns the empty string when
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* The header line `--- ~/.claude/settings.json` / `+++ would write` is
|
|
26
|
-
* literal; callers that want a different header can prepend their own.
|
|
20
|
+
* Returns the empty string when inputs are byte-identical so the caller can
|
|
21
|
+
* suppress the section. jsdiff `diffLines` aligns on the longest common
|
|
22
|
+
* subsequence, so a mid-document insertion does not cascade false `-`/`+`
|
|
23
|
+
* pairs for the unchanged tail.
|
|
27
24
|
*/
|
|
28
25
|
export function diffJsonStrings(currentJsonText: string, newJsonText: string): string {
|
|
29
26
|
if (currentJsonText === newJsonText) return '';
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// Walk both arrays in parallel. Lines that match index-wise are context;
|
|
37
|
-
// others are emitted as -a / +b. A real unified-diff would compute the
|
|
38
|
-
// longest common subsequence; this naive walk is good enough for two JSON
|
|
39
|
-
// documents pretty-printed at the same indentation level.
|
|
40
|
-
const maxLen = Math.max(a.length, b.length);
|
|
41
|
-
for (let i = 0; i < maxLen; i++) {
|
|
42
|
-
const av = a[i];
|
|
43
|
-
const bv = b[i];
|
|
44
|
-
if (av === bv) {
|
|
45
|
-
if (av !== undefined) lines.push(` ${av}`);
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (av !== undefined) lines.push(red(`-${av}`));
|
|
49
|
-
if (bv !== undefined) lines.push(green(`+${bv}`));
|
|
50
|
-
}
|
|
27
|
+
const lines: string[] = [
|
|
28
|
+
'--- ~/.claude/settings.json',
|
|
29
|
+
'+++ would write',
|
|
30
|
+
...diffLinesToUnified(currentJsonText, newJsonText),
|
|
31
|
+
];
|
|
51
32
|
return lines.join('\n');
|
|
52
33
|
}
|
|
53
34
|
|
|
@@ -31,6 +31,13 @@ export type Finding = {
|
|
|
31
31
|
StartLine: number;
|
|
32
32
|
Match: string;
|
|
33
33
|
Fingerprint: string;
|
|
34
|
+
/**
|
|
35
|
+
* Human-readable rule description gitleaks bakes into every finding (the
|
|
36
|
+
* matched rule's `description` from its toml). Optional: absent on older
|
|
37
|
+
* gitleaks reports or custom rules with no description, in which case the
|
|
38
|
+
* doctor legend silently omits the entry (graceful degradation, no network).
|
|
39
|
+
*/
|
|
40
|
+
Description?: string;
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
/**
|
package/src/remap.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'no
|
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
|
|
5
|
-
import { log } from './utils.ts';
|
|
5
|
+
import { die, log } from './utils.ts';
|
|
6
6
|
import { backupBeforeWrite, backupRepoWrite } from './utils.fs.ts';
|
|
7
7
|
import { encodePath, readJson } from './utils.json.ts';
|
|
8
8
|
|
|
@@ -98,17 +98,67 @@ export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapp
|
|
|
98
98
|
return { unmapped };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Build the encoded-key to logical-name reverse map for the current host,
|
|
103
|
+
* failing closed on any `path-map.json` shape that would silently lose session
|
|
104
|
+
* data on push. Both failure modes `die()` (throw `NomadFatal`) before the
|
|
105
|
+
* caller writes anything:
|
|
106
|
+
*
|
|
107
|
+
* - Encoded-path collision: two distinct host paths that `encodePath` maps to
|
|
108
|
+
* the same key (every `/` becomes `-`), which would clobber each other under
|
|
109
|
+
* one repo directory.
|
|
110
|
+
* - Duplicate path: two logical names mapping to the same host path, where only
|
|
111
|
+
* one logical could be pushed and the other's `shared/projects/` copy would
|
|
112
|
+
* be orphaned.
|
|
113
|
+
*
|
|
114
|
+
* @param map - the parsed `path-map.json`
|
|
115
|
+
* @returns reverse lookup from encoded local dir name to logical project name
|
|
116
|
+
*/
|
|
117
|
+
function buildReverseMap(map: PathMap): Map<string, string> {
|
|
118
|
+
const reverse = new Map<string, string>();
|
|
119
|
+
const encodedPaths = new Map<string, string>();
|
|
120
|
+
for (const [logical, hosts] of Object.entries(map.projects)) {
|
|
121
|
+
const p = hosts[HOST];
|
|
122
|
+
if (!p || p === 'TBD') continue;
|
|
123
|
+
const encoded = encodePath(p);
|
|
124
|
+
const prior = encodedPaths.get(encoded);
|
|
125
|
+
if (prior !== undefined) {
|
|
126
|
+
if (prior !== p) {
|
|
127
|
+
die(
|
|
128
|
+
`encoded-path collision in path-map.json: "${prior}" and "${p}" both encode to` +
|
|
129
|
+
` "${encoded}" (encodePath replaces every / with -).` +
|
|
130
|
+
` Edit path-map.json so the two paths do not encode identically.` +
|
|
131
|
+
` Run nomad doctor for the full list of collisions.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
die(
|
|
135
|
+
`duplicate path in path-map.json: logical names "${reverse.get(encoded)}" and "${logical}"` +
|
|
136
|
+
` both map to "${p}" for ${HOST}, so only one could be pushed and the other's` +
|
|
137
|
+
` shared/projects/ copy would be orphaned.` +
|
|
138
|
+
` Edit path-map.json so each host path maps to a single logical name.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
encodedPaths.set(encoded, p);
|
|
142
|
+
reverse.set(encoded, logical);
|
|
143
|
+
}
|
|
144
|
+
return reverse;
|
|
145
|
+
}
|
|
146
|
+
|
|
101
147
|
/**
|
|
102
148
|
* Push: copy local path-encoded dirs back to repo under logical names.
|
|
103
149
|
*
|
|
104
150
|
* Returns `{ unmapped: N, collisions: M }` where `unmapped` is the count of
|
|
105
151
|
* `~/.claude/projects/<dir>/` entries that have no path-map reverse-lookup
|
|
106
|
-
* for this host. `collisions` is
|
|
107
|
-
*
|
|
152
|
+
* for this host. `collisions` is always `0` on the success path: any
|
|
153
|
+
* `path-map.json` shape that would silently lose data (an encoded-path
|
|
154
|
+
* collision between two distinct host paths, or two logical names mapping to
|
|
155
|
+
* the same host path) makes `buildReverseMap` `die()` (throw `NomadFatal`) to
|
|
156
|
+
* refuse the push before any `shared/projects/` content is written. Detection
|
|
157
|
+
* runs during the reverse-map build, so it fires under `dryRun` too.
|
|
108
158
|
*
|
|
109
159
|
* `opts.dryRun` (default `false`): when `true`, log `would push:` lines
|
|
110
|
-
* instead of calling `backupRepoWrite` + `copyDir`.
|
|
111
|
-
* identically in both modes.
|
|
160
|
+
* instead of calling `backupRepoWrite` + `copyDir`. Collision detection
|
|
161
|
+
* runs identically in both modes.
|
|
112
162
|
*/
|
|
113
163
|
export function remapPush(
|
|
114
164
|
ts: string,
|
|
@@ -116,7 +166,6 @@ export function remapPush(
|
|
|
116
166
|
): { unmapped: number; collisions: number } {
|
|
117
167
|
const dryRun = opts.dryRun === true;
|
|
118
168
|
let unmapped = 0;
|
|
119
|
-
const collisions = 0;
|
|
120
169
|
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
121
170
|
if (!existsSync(mapPath)) {
|
|
122
171
|
log('no path-map.json; skipping session export');
|
|
@@ -126,16 +175,14 @@ export function remapPush(
|
|
|
126
175
|
const map = readJson<PathMap>(mapPath);
|
|
127
176
|
const localProjects = join(CLAUDE_HOME, 'projects');
|
|
128
177
|
const repoProjects = join(REPO_HOME, 'shared', 'projects');
|
|
129
|
-
if (!dryRun) mkdirSync(repoProjects, { recursive: true });
|
|
130
178
|
|
|
131
|
-
const reverse =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
179
|
+
const reverse = buildReverseMap(map);
|
|
180
|
+
if (!existsSync(localProjects)) return { unmapped, collisions: 0 };
|
|
181
|
+
// Create the repo destination only after collision detection passes and we
|
|
182
|
+
// know there is something to push, so a failing or no-op push is fully
|
|
183
|
+
// side-effect-free (no empty shared/projects/ left behind).
|
|
184
|
+
if (!dryRun) mkdirSync(repoProjects, { recursive: true });
|
|
137
185
|
|
|
138
|
-
if (!existsSync(localProjects)) return { unmapped, collisions };
|
|
139
186
|
for (const dir of readdirSync(localProjects)) {
|
|
140
187
|
const logical = reverse.get(dir);
|
|
141
188
|
if (!logical) {
|
|
@@ -156,5 +203,5 @@ export function remapPush(
|
|
|
156
203
|
copyDirJsonlOnly(join(localProjects, dir), repoDst);
|
|
157
204
|
log(`pushed ${dir} -> ${logical}`);
|
|
158
205
|
}
|
|
159
|
-
return { unmapped, collisions };
|
|
206
|
+
return { unmapped, collisions: 0 };
|
|
160
207
|
}
|