claude-nomad 0.26.1 → 0.27.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 +14 -0
- package/README.md +80 -12
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.scan.ts +54 -33
- package/src/commands.doctor.format.ts +2 -81
- package/src/commands.pull.ts +69 -13
- package/src/commands.push.sections.ts +171 -0
- package/src/commands.push.ts +136 -71
- package/src/extras-sync.core.ts +96 -0
- package/src/extras-sync.remap.ts +138 -0
- package/src/extras-sync.ts +22 -168
- package/src/links.ts +14 -3
- package/src/output-tree.ts +91 -0
- package/src/push-gitleaks.scan.ts +9 -6
- package/src/push-gitleaks.ts +19 -3
- package/src/push-leak-verdict.ts +154 -0
- package/src/push-preview.ts +160 -0
- package/src/remap.ts +46 -27
- package/src/summary.ts +75 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.27.0](https://github.com/funkadelic/claude-nomad/compare/v0.26.2...v0.27.0) (2026-05-28)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **output:** grouped tree output for push/pull and dry-run leak preview ([#163](https://github.com/funkadelic/claude-nomad/issues/163)) ([fff6f1e](https://github.com/funkadelic/claude-nomad/commit/fff6f1e28116b072ee4eceda36d87c13f4c1bc1c))
|
|
9
|
+
|
|
10
|
+
## [0.26.2](https://github.com/funkadelic/claude-nomad/compare/v0.26.1...v0.26.2) (2026-05-28)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **gitleaks:** condense push output and group doctor check-shared layout ([#161](https://github.com/funkadelic/claude-nomad/issues/161)) ([d9e5758](https://github.com/funkadelic/claude-nomad/commit/d9e57589f616e70b8e2d6249c957af55a25a578b))
|
|
16
|
+
|
|
3
17
|
## [0.26.1](https://github.com/funkadelic/claude-nomad/compare/v0.26.0...v0.26.1) (2026-05-27)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -525,7 +525,7 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
|
|
|
525
525
|
| `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
|
|
526
526
|
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
527
527
|
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
528
|
-
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage,
|
|
528
|
+
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
|
|
529
529
|
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` and the sibling `shared/projects/*/<id>/` subagent directory from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` and `<id>/` tree are preserved. See [Recovery flows](#recovery-flows). |
|
|
530
530
|
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
531
531
|
| `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. |
|
|
@@ -547,20 +547,82 @@ re-enabled, complementing the auto-disable that runs on `nomad init` (see
|
|
|
547
547
|
[Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub
|
|
548
548
|
origin, `gh` unauthed, public repo, or Actions already off).
|
|
549
549
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
`nomad
|
|
550
|
+
### Reading push and pull output
|
|
551
|
+
|
|
552
|
+
`nomad push` and `nomad pull` print a grouped tree, the same left-gutter layout you already see from
|
|
553
|
+
`nomad doctor`. There is a header line naming the command and host, then a few named sections
|
|
554
|
+
(`Sessions`, `Extras`, and so on), each with its items hanging off `├`/`└` connectors. A status
|
|
555
|
+
glyph leads every line: `✓` green for something that synced, `ℹ︎` dim for an informational count, `⚠︎`
|
|
556
|
+
yellow for a warning, and `✗` red for a failure. What this means for you: instead of one long flat
|
|
557
|
+
list with a line per project, related work is grouped and the noise is collapsed.
|
|
558
|
+
|
|
559
|
+
A clean `nomad push` looks like this (one `✓` row per project whose sessions were copied up, the
|
|
560
|
+
projects this host does not track folded into a single count, then the secret-scan result and a
|
|
561
|
+
one-line summary):
|
|
562
|
+
|
|
563
|
+
```text
|
|
564
|
+
push on host=workstation
|
|
565
|
+
Sessions
|
|
566
|
+
├ ✓ claude-nomad
|
|
567
|
+
├ ✓ my-side-project
|
|
568
|
+
└ ℹ︎ 4 not in path-map (run nomad doctor to list)
|
|
569
|
+
Extras
|
|
570
|
+
└ ✓ claude-nomad/.planning
|
|
571
|
+
Leak scan
|
|
572
|
+
└ ✓ no leaks
|
|
573
|
+
Summary
|
|
574
|
+
└ ✓ summary: clean
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
The `ℹ︎ 4 not in path-map` row is the collapse: rather than printing one line per project that this
|
|
578
|
+
host does not sync, push and pull now show a single count and point you at `nomad doctor`, which
|
|
579
|
+
lists those projects by name if you want the detail. The `Leak scan` section is the secret check
|
|
580
|
+
that runs before anything is published: `✓ no leaks` when the staged transcripts are clean. If a
|
|
581
|
+
secret IS found, that row turns into `✗ gitleaks detected secrets in N session transcript(s)` and
|
|
582
|
+
the full recovery block (which sessions, how to scrub them) still prints below the tree, exactly as
|
|
583
|
+
before (see
|
|
584
|
+
[Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)).
|
|
585
|
+
The same `Leak scan` row shows up under `nomad push --dry-run`, which runs that secret scan as a
|
|
586
|
+
read-only preview (nothing is written to the sync repo) and exits non-zero if the preview finds
|
|
587
|
+
anything.
|
|
588
|
+
|
|
589
|
+
A `nomad pull` is the mirror image, leading with the settings file it regenerated and then the
|
|
590
|
+
sessions and extras it copied down for this host:
|
|
591
|
+
|
|
592
|
+
```text
|
|
593
|
+
pull on host=workstation (backup=2026-05-27T14-02-09Z)
|
|
594
|
+
Settings
|
|
595
|
+
└ ✓ settings.json (base + workstation.json)
|
|
596
|
+
Sessions
|
|
597
|
+
├ ✓ claude-nomad
|
|
598
|
+
└ ℹ︎ 2 not in path-map (run nomad doctor to list)
|
|
599
|
+
Extras
|
|
600
|
+
└ ✓ claude-nomad/.planning
|
|
601
|
+
Summary
|
|
602
|
+
└ ✓ summary: clean
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
The `Summary` row is the final verdict for the run. It reads `✓ summary: clean` when everything
|
|
606
|
+
synced, or a `⚠︎` warning naming the counts when something was skipped:
|
|
553
607
|
|
|
554
608
|
```text
|
|
555
|
-
✓ summary: clean
|
|
556
609
|
⚠︎ summary: 3 unmapped on pull (run nomad doctor to list)
|
|
557
610
|
⚠︎ summary: 2 unmapped on push, 1 collisions (run nomad doctor to list)
|
|
558
611
|
```
|
|
559
612
|
|
|
560
|
-
`✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
613
|
+
`✓` lines go to stdout; `⚠︎` and `✗` lines go to stderr. An early, pre-tree fatal abort (for example
|
|
614
|
+
gitleaks missing when push checks for it, or a rebase conflict before anything is staged) suppresses
|
|
615
|
+
the tree entirely, so you do not see "summary: clean" stacked under an error. A later leak-scan
|
|
616
|
+
finding is different: by then the tree has already been built, so it still renders in full with a
|
|
617
|
+
`✗` Leak scan row and the recovery block below it (see "Recovery flow: gitleaks FATAL on a session
|
|
618
|
+
JSONL"). Projects with no entry in `path-map.json` for this host count as unmapped and fold into the
|
|
619
|
+
collapsed `ℹ︎ ... not in path-map` count; the hint points at `nomad doctor`, which lists them by
|
|
620
|
+
logical name.
|
|
621
|
+
|
|
622
|
+
`nomad pull --dry-run` keeps its own readable preview format (a unified diff of the `settings.json`
|
|
623
|
+
changes plus the transcripts a real pull would overwrite) rather than the grouped tree, so that
|
|
624
|
+
preview stays easy to scan; only a real `nomad pull` prints the tree above. `nomad diff` is
|
|
625
|
+
unchanged.
|
|
564
626
|
|
|
565
627
|
## Recovery flows
|
|
566
628
|
|
|
@@ -603,9 +665,15 @@ same secret.
|
|
|
603
665
|
### Recovery flow: gitleaks FATAL on a session JSONL
|
|
604
666
|
|
|
605
667
|
`nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you
|
|
606
|
-
push (and without mutating anything),
|
|
607
|
-
|
|
608
|
-
|
|
668
|
+
push (and without mutating anything), two read-only options are available:
|
|
669
|
+
`nomad doctor --check-shared` scans the session transcripts a push would publish;
|
|
670
|
+
`nomad push --dry-run` runs the same scan AND also covers opted-in extras (`.planning`,
|
|
671
|
+
`CLAUDE.md`), which `--check-shared` does not. Both stage content into a throwaway temp copy and
|
|
672
|
+
never write to the sync repo. A leak-scan finding is the contrast to an early, pre-tree fatal:
|
|
673
|
+
because the scan runs after the tree is built, the push aborts but the grouped tree still renders in
|
|
674
|
+
full, with a `✗ gitleaks detected secrets in N session transcript(s)` row in its `Leak scan`
|
|
675
|
+
section, and then the full recovery block prints below it, naming every affected session id and the
|
|
676
|
+
recovery command:
|
|
609
677
|
|
|
610
678
|
```text
|
|
611
679
|
✗ gitleaks detected secrets in 1 session transcript(s).
|
package/package.json
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
* Owns the post-stage block: run the shared `scanStagedTree` (the same git
|
|
8
8
|
* init + add + `gitleaks protect --staged` mechanism push uses), classify the
|
|
9
9
|
* findings via `partitionFindings`, and emit the doctor glyph rows (clean,
|
|
10
|
-
* per-session leak
|
|
11
|
-
*
|
|
10
|
+
* per-session leak rows, then a Remediation block, then a Finding types legend).
|
|
11
|
+
* All external work flows through `scanStagedTree`; this module spawns
|
|
12
12
|
* nothing itself.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
-
import { green, red, dim, okGlyph, failGlyph } from './color.ts';
|
|
17
|
+
import { green, red, dim, bold, 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';
|
|
@@ -32,31 +32,18 @@ function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, s
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* Emit one fail row per affected session
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* rather than print a wrong path if the invariant ever breaks; the leak row is
|
|
40
|
-
* always emitted.
|
|
35
|
+
* Emit one fail row per affected session (the `✗ ... in session` rows only)
|
|
36
|
+
* and set `process.exitCode = 1`. Rotate-and-scrub guidance and the
|
|
37
|
+
* false-positive hint are NOT emitted here; they belong to `reportRemediation`
|
|
38
|
+
* which is called after all finding rows.
|
|
41
39
|
*/
|
|
42
40
|
function reportSessionFindings(
|
|
43
41
|
section: DoctorSection,
|
|
44
42
|
bySession: Map<string, Map<string, number>>,
|
|
45
|
-
logicalBySession: Map<string, string>,
|
|
46
|
-
logicalToEncoded: Map<string, string>,
|
|
47
43
|
): void {
|
|
48
44
|
for (const [sid, counts] of bySession) {
|
|
49
45
|
const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
|
|
50
46
|
addItem(section, `${red(failGlyph)} ${red(summary)} in session ${sid}`);
|
|
51
|
-
const logical = logicalBySession.get(sid);
|
|
52
|
-
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
53
|
-
if (logical !== undefined) {
|
|
54
|
-
addItem(
|
|
55
|
-
section,
|
|
56
|
-
` ${dim(`rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
addItem(section, ` ${dim('false positive? add a pattern to .gitleaks.toml')}`);
|
|
60
47
|
}
|
|
61
48
|
process.exitCode = 1;
|
|
62
49
|
}
|
|
@@ -75,6 +62,36 @@ function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
|
|
|
75
62
|
process.exitCode = 1;
|
|
76
63
|
}
|
|
77
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Emit the remediation block after all finding rows. Findings-first ordering
|
|
67
|
+
* means guidance is grouped at the end, not interleaved between session rows.
|
|
68
|
+
* Emits a leading blank, a bold `Remediation` header, one rotate-and-scrub
|
|
69
|
+
* line per session (using `logicalBySession` to build the scrub path), and
|
|
70
|
+
* exactly ONE false-positive hint after the loop (deduped, not once per session).
|
|
71
|
+
* The hint guard omits a rotate row rather than print a wrong path if the
|
|
72
|
+
* invariant ever breaks; the false-positive line is always appended.
|
|
73
|
+
*/
|
|
74
|
+
function reportRemediation(
|
|
75
|
+
section: DoctorSection,
|
|
76
|
+
bySession: Map<string, Map<string, number>>,
|
|
77
|
+
logicalBySession: Map<string, string>,
|
|
78
|
+
logicalToEncoded: Map<string, string>,
|
|
79
|
+
): void {
|
|
80
|
+
addItem(section, '');
|
|
81
|
+
addItem(section, bold('Remediation'));
|
|
82
|
+
for (const [sid] of bySession) {
|
|
83
|
+
const logical = logicalBySession.get(sid);
|
|
84
|
+
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
85
|
+
if (logical !== undefined) {
|
|
86
|
+
addItem(
|
|
87
|
+
section,
|
|
88
|
+
` ${dim(`- rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
addItem(section, ` ${dim('- false positive? add a pattern to .gitleaks.toml')}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
78
95
|
/**
|
|
79
96
|
* Captures both the `<logical>` segment and the `<sid>` from a repo-relative
|
|
80
97
|
* `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
|
|
@@ -112,14 +129,14 @@ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
|
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
/**
|
|
115
|
-
* Emit a deduplicated description legend in the footer: one
|
|
116
|
-
* description` row per distinct RuleID across all findings,
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* (older gitleaks, custom rules) are skipped, and the whole block
|
|
120
|
-
* the surrounding
|
|
121
|
-
* legend lives in the footer so a rule hit across many files
|
|
122
|
-
* (e.g. `sonar-api-token`) is explained once, not per occurrence.
|
|
132
|
+
* Emit a deduplicated description legend in the footer: one `- [rule-id]:
|
|
133
|
+
* description` row per distinct RuleID across all findings, headed by a bold
|
|
134
|
+
* `Finding types` header with a leading blank but no trailing blank (renderSection
|
|
135
|
+
* attaches the `└` elbow to the last non-empty item). Rules whose description
|
|
136
|
+
* is absent (older gitleaks, custom rules) are skipped, and the whole block
|
|
137
|
+
* (including the surrounding blank and header) is omitted when no descriptions
|
|
138
|
+
* are available. The legend lives in the footer so a rule hit across many files
|
|
139
|
+
* or sessions (e.g. `sonar-api-token`) is explained once, not per occurrence.
|
|
123
140
|
*/
|
|
124
141
|
function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): void {
|
|
125
142
|
const descByRule = new Map<string, string>();
|
|
@@ -128,16 +145,19 @@ function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): voi
|
|
|
128
145
|
}
|
|
129
146
|
if (descByRule.size === 0) return;
|
|
130
147
|
addItem(section, '');
|
|
148
|
+
addItem(section, bold('Finding types'));
|
|
131
149
|
for (const [rule, desc] of descByRule) {
|
|
132
|
-
addItem(section, ` ${red(
|
|
150
|
+
addItem(section, ` ${red(`- [${rule}]`)}: ${dim(desc)}`);
|
|
133
151
|
}
|
|
134
|
-
addItem(section, '');
|
|
135
152
|
}
|
|
136
153
|
|
|
137
154
|
/**
|
|
138
155
|
* Scan the staged temp tree through the shared `scanStagedTree` and emit the
|
|
139
|
-
* result rows
|
|
140
|
-
*
|
|
156
|
+
* result rows in findings-first order: other-bucket leak rows, then per-session
|
|
157
|
+
* leak rows, then a `Remediation` block (gated on bySession.size > 0), then a
|
|
158
|
+
* `Finding types` legend (gated on at least one finding carrying a Description).
|
|
159
|
+
* Isolates the deepest nesting from `reportCheckShared`: the scan try/catch
|
|
160
|
+
* (failure -> fail row + exit 1, carrying `err.message` only, never
|
|
141
161
|
* stderr/stdout), the unparseable `findings === null` branch, `partitionFindings`,
|
|
142
162
|
* and the clean / `other` / `bySession` rows. BOTH buckets gate the clean row: a
|
|
143
163
|
* finding in `other` (nested transcripts matching neither the flat `SESSION_PATH`
|
|
@@ -175,8 +195,9 @@ export function scanAndReport(
|
|
|
175
195
|
return;
|
|
176
196
|
}
|
|
177
197
|
if (other.length > 0) reportOtherFindings(section, other);
|
|
198
|
+
if (bySession.size > 0) reportSessionFindings(section, bySession);
|
|
178
199
|
if (bySession.size > 0) {
|
|
179
|
-
|
|
200
|
+
reportRemediation(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
|
|
180
201
|
}
|
|
181
202
|
emitDescriptionLegend(section, findings);
|
|
182
203
|
}
|
|
@@ -1,43 +1,8 @@
|
|
|
1
1
|
import { failGlyph, red } from './color.ts';
|
|
2
|
+
import { addItem, type DoctorSection } from './output-tree.ts';
|
|
2
3
|
import { readJson } from './utils.json.ts';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
* Bare `failGlyph` codepoint (`✗`, U+2717) without any WSL padding the
|
|
6
|
-
* `failGlyph` constant may carry. Header rendering composes its own
|
|
7
|
-
* spacing (`${red(failGlyph)} ${header}`), so the section-header path
|
|
8
|
-
* must use the unpadded codepoint to avoid a double space on WSL.
|
|
9
|
-
*/
|
|
10
|
-
const FAIL_GLYPH_BARE = '✗';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Tree-style output builder for `cmdDoctor`. Doctor builds an ordered list of
|
|
14
|
-
* `DoctorSection`s, each reporter pushes plain-text items into the relevant
|
|
15
|
-
* section, then the orchestrator calls `renderDoctor` to emit a Claude Code
|
|
16
|
-
* `/doctor`-style tree (`Header` / ` ├ item` / ` └ last`) on stdout.
|
|
17
|
-
*
|
|
18
|
-
* Color and status glyphs (okGlyph/warnGlyph/failGlyph/infoGlyph) already
|
|
19
|
-
* live inside the item text; this module never re-colors or re-tokenizes.
|
|
20
|
-
* Sections with zero items are skipped at render time (no empty headers).
|
|
21
|
-
*
|
|
22
|
-
* Output goes directly through `console.log` rather than `utils.log` so the
|
|
23
|
-
* dim `ℹ︎` info glyph used by `pull` / `push` / `init` does NOT appear in
|
|
24
|
-
* doctor output (doctor has its own glyphs per row). Test assertions continue
|
|
25
|
-
* to spy on `console.log`.
|
|
26
|
-
*/
|
|
27
|
-
export type DoctorSection = {
|
|
28
|
-
header: string;
|
|
29
|
-
items: string[];
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/** Construct an empty section with the given header. */
|
|
33
|
-
export function section(header: string): DoctorSection {
|
|
34
|
-
return { header, items: [] };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Append one rendered line to a section. */
|
|
38
|
-
export function addItem(s: DoctorSection, text: string): void {
|
|
39
|
-
s.items.push(text);
|
|
40
|
-
}
|
|
5
|
+
export { section, addItem, renderTree, renderDoctor, type DoctorSection } from './output-tree.ts';
|
|
41
6
|
|
|
42
7
|
/**
|
|
43
8
|
* Tolerant JSON reader for `cmdDoctor`. Doctor reads three JSON files
|
|
@@ -55,47 +20,3 @@ export function readJsonSafe<T>(path: string, label: string, section: DoctorSect
|
|
|
55
20
|
return null;
|
|
56
21
|
}
|
|
57
22
|
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* True when any item in the section contains the FAIL glyph.
|
|
61
|
-
* Color-wrapped failGlyph (`[31m✗[39m`) still contains the
|
|
62
|
-
* glyph as a substring, so this works for both color-on and color-off output.
|
|
63
|
-
*/
|
|
64
|
-
function sectionFailed(s: DoctorSection): boolean {
|
|
65
|
-
return s.items.some((line) => line.includes(failGlyph));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Emit the full doctor report. Skips empty sections, prefixes failed-section
|
|
70
|
-
* headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
|
|
71
|
-
* `grep -F '✗'` catches both row and header failures), and writes one blank
|
|
72
|
-
* line between rendered sections (no leading or trailing blank).
|
|
73
|
-
*
|
|
74
|
-
* An empty-string item renders as a true blank line (no tree connector), which
|
|
75
|
-
* lets a reporter set off a footer block (e.g. the `--check-shared` description
|
|
76
|
-
* legend) with vertical whitespace. The `└` connector attaches to the last
|
|
77
|
-
* non-empty item rather than the last array slot so a trailing blank does not
|
|
78
|
-
* strand the elbow on an empty line.
|
|
79
|
-
*/
|
|
80
|
-
/**
|
|
81
|
-
* Render one section: a (possibly fail-glyph-prefixed) header followed by its
|
|
82
|
-
* items as a tree. Empty-string items print as true blank lines; the `└` elbow
|
|
83
|
-
* attaches to the last non-empty item so a trailing blank cannot strand it.
|
|
84
|
-
*/
|
|
85
|
-
function renderSection(s: DoctorSection): void {
|
|
86
|
-
const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
|
|
87
|
-
console.log(header);
|
|
88
|
-
const lastContent = s.items.reduce((acc, item, j) => (item !== '' ? j : acc), -1);
|
|
89
|
-
for (let j = 0; j < s.items.length; j++) {
|
|
90
|
-
if (s.items[j] === '') console.log('');
|
|
91
|
-
else console.log(`${j === lastContent ? ' └ ' : ' ├ '}${s.items[j]}`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function renderDoctor(sections: DoctorSection[]): void {
|
|
96
|
-
const visible = sections.filter((s) => s.items.length > 0);
|
|
97
|
-
for (let i = 0; i < visible.length; i++) {
|
|
98
|
-
if (i > 0) console.log('');
|
|
99
|
-
renderSection(visible[i]);
|
|
100
|
-
}
|
|
101
|
-
}
|
package/src/commands.pull.ts
CHANGED
|
@@ -1,16 +1,57 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
buildExtrasSection,
|
|
6
|
+
buildSessionsSection,
|
|
7
|
+
buildSettingsSection,
|
|
8
|
+
} from './commands.push.sections.ts';
|
|
4
9
|
import { HOME, HOST, REPO_HOME } from './config.ts';
|
|
5
10
|
import { divergenceCheckExtras, remapExtrasPull } from './extras-sync.ts';
|
|
6
11
|
import { applySharedLinks, regenerateSettings } from './links.ts';
|
|
12
|
+
import { renderTree, section, addItem } from './output-tree.ts';
|
|
7
13
|
import { computePreview } from './preview.ts';
|
|
8
14
|
import { remapPull } from './remap.ts';
|
|
9
|
-
import { emitSummary } from './summary.ts';
|
|
15
|
+
import { emitSummary, summaryRow } from './summary.ts';
|
|
10
16
|
import { die, fail, gitOrFatal, log, NomadFatal } from './utils.ts';
|
|
11
17
|
import { freshBackupTs } from './utils.fs.ts';
|
|
12
18
|
import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
13
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Run the WET (non-dry-run) pull side effects in order and render the
|
|
22
|
+
* doctor-style grouped tree once at the end: a `pull on host=... (backup=<ts>)`
|
|
23
|
+
* header followed by `Settings` / `Sessions` / `Extras` / `Summary` sections.
|
|
24
|
+
* `applySharedLinks` stays silent (no Links group by design);
|
|
25
|
+
* `regenerateSettings` returns its override-source label so the Settings row
|
|
26
|
+
* surfaces what was written without logging inline. Sessions/Extras reuse the
|
|
27
|
+
* verb-agnostic builders shared with `cmdPush`, fed the pull-side `pulled`
|
|
28
|
+
* detail arrays. The combined session + extras unmapped count and the
|
|
29
|
+
* extras-skipped count drive the Summary row exactly as `emitSummary` did.
|
|
30
|
+
*
|
|
31
|
+
* @param ts - backup timestamp namespace shared by every WET side effect.
|
|
32
|
+
*/
|
|
33
|
+
function applyWetPull(ts: string): void {
|
|
34
|
+
applySharedLinks(ts);
|
|
35
|
+
const { label } = regenerateSettings(ts);
|
|
36
|
+
const remapResult = remapPull(ts);
|
|
37
|
+
const extrasResult = remapExtrasPull(ts);
|
|
38
|
+
// Combine session-unmapped and extras-unmapped into one user-visible count;
|
|
39
|
+
// from the operator's perspective both mean "couldn't sync this for the
|
|
40
|
+
// host". extras-skipped (non-whitelisted dirname) stays separate because it
|
|
41
|
+
// signals config misuse, not a host-config gap.
|
|
42
|
+
const summary = section('Summary');
|
|
43
|
+
addItem(
|
|
44
|
+
summary,
|
|
45
|
+
summaryRow('pull', remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped),
|
|
46
|
+
);
|
|
47
|
+
renderTree([
|
|
48
|
+
buildSettingsSection(label),
|
|
49
|
+
buildSessionsSection(remapResult.pulled, remapResult.unmapped),
|
|
50
|
+
buildExtrasSection(extrasResult.pulled, extrasResult.skipped),
|
|
51
|
+
summary,
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
/**
|
|
15
56
|
* `nomad pull` command. Acquires the push/pull lock, takes a backup
|
|
16
57
|
* timestamp, runs `git pull --rebase --autostash` in `REPO_HOME`, then
|
|
@@ -23,6 +64,19 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
|
23
64
|
* 5. `remapExtrasPull` (copy `shared/extras/<logical>/<dirname>/` back
|
|
24
65
|
* into each project's localRoot; SKIPPED under dryRun)
|
|
25
66
|
*
|
|
67
|
+
* WET output is a doctor-style grouped tree (`applyWetPull`): a `pull on
|
|
68
|
+
* host=... (backup=<ts>)` header, then `Settings` / `Sessions` / `Extras` /
|
|
69
|
+
* `Summary` sections rendered with `├`/`└` connectors. The Settings row shows
|
|
70
|
+
* `✓ settings.json (base + <label>)`; pulled sessions and extras list as `✓`
|
|
71
|
+
* rows; the per-project "not in path-map" skips collapse to one `ℹ︎` count
|
|
72
|
+
* row. There is no Links group (`applySharedLinks` stays silent by design).
|
|
73
|
+
*
|
|
74
|
+
* The WET-path Summary row (including the warn glyph case) renders to STDOUT as
|
|
75
|
+
* part of the grouped tree via `renderTree`, not to stderr via `warn` as in the
|
|
76
|
+
* pre-tree behavior. The dry-run path still routes its summary through
|
|
77
|
+
* `emitSummary` (stderr). This wet-stdout/dry-stderr stream split is
|
|
78
|
+
* intentional (the dry-run output is left byte-identical) and not a regression.
|
|
79
|
+
*
|
|
26
80
|
* `opts.dryRun` (default `false`): when `true`, the lock IS still acquired
|
|
27
81
|
* and `git pull --rebase` still runs (so concurrent invocations cannot race
|
|
28
82
|
* and the user sees the same network round-trip as a real pull).
|
|
@@ -30,7 +84,10 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
|
30
84
|
* `computePreview` runs in place of the four mutating steps. The per-run
|
|
31
85
|
* backup directory under `~/.cache/claude-nomad/backup/<ts>/` is
|
|
32
86
|
* intentionally NOT created (no backups are written under dryRun and an
|
|
33
|
-
* empty dir would pollute the cache).
|
|
87
|
+
* empty dir would pollute the cache). The dry-run path is BYTE-IDENTICAL:
|
|
88
|
+
* it keeps the `pulling on host=... (backup=...; dry-run)` header, the
|
|
89
|
+
* `computePreview` adjacent diff, its standalone `emitSummary`, and the
|
|
90
|
+
* `dry-run complete; no mutation` line; it does NOT build the tree.
|
|
34
91
|
*
|
|
35
92
|
* Any `NomadFatal` thrown along the way is caught here so the `finally` block
|
|
36
93
|
* releases the lock before exit (a raw `process.exit()` would skip `finally`
|
|
@@ -67,7 +124,14 @@ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
|
|
|
67
124
|
die(`could not create backup dir: ${(err as Error).message}`);
|
|
68
125
|
}
|
|
69
126
|
}
|
|
70
|
-
|
|
127
|
+
// WET header becomes the tree header (no `pulling`/`ℹ︎` prefix). The
|
|
128
|
+
// dry-run header phrasing is LEFT byte-identical so the readable diff path
|
|
129
|
+
// does not regress.
|
|
130
|
+
log(
|
|
131
|
+
dryRun
|
|
132
|
+
? `pulling on host=${HOST} (backup=${ts}; dry-run)`
|
|
133
|
+
: `pull on host=${HOST} (backup=${ts})`,
|
|
134
|
+
);
|
|
71
135
|
gitOrFatal(['pull', '--rebase', '--autostash'], 'git pull --rebase', REPO_HOME);
|
|
72
136
|
// Read-only pre-pull check: fires in BOTH wet and dry modes (D-08).
|
|
73
137
|
// Runs AFTER the rebase (so origin content is fetched) and BEFORE any
|
|
@@ -78,19 +142,11 @@ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
|
|
|
78
142
|
const previewResult = computePreview(ts);
|
|
79
143
|
// dryRun deliberately omits remapExtrasPull to preserve the
|
|
80
144
|
// zero-mutation contract; users still see the divergence WARN above.
|
|
145
|
+
// BYTE-IDENTICAL dry-run output: standalone emitSummary, no tree.
|
|
81
146
|
log('dry-run complete; no mutation');
|
|
82
147
|
emitSummary('pull', previewResult.unmapped);
|
|
83
148
|
} else {
|
|
84
|
-
|
|
85
|
-
regenerateSettings(ts);
|
|
86
|
-
const remapResult = remapPull(ts);
|
|
87
|
-
const extrasResult = remapExtrasPull(ts);
|
|
88
|
-
log('pull complete');
|
|
89
|
-
// Combine session-unmapped and extras-unmapped into one user-visible
|
|
90
|
-
// count; from the operator's perspective both mean "couldn't sync this
|
|
91
|
-
// for the host". extras-skipped (non-whitelisted dirname) stays
|
|
92
|
-
// separate because it signals config misuse, not a host-config gap.
|
|
93
|
-
emitSummary('pull', remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped);
|
|
149
|
+
applyWetPull(ts);
|
|
94
150
|
}
|
|
95
151
|
} catch (err) {
|
|
96
152
|
// Catch fatal errors here so the finally block runs and releases the
|