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 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, scan, commit, and push. |
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
- Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summary:` line. The
551
- status glyph (`✓` green / `⚠︎` yellow / `✗` red / `ℹ︎` dim) carries the severity, mirroring
552
- `nomad doctor`'s left-gutter format:
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. The summary is suppressed when a fatal (`✗`)
561
- fires mid-run so you do not see "summary: clean" stacked under an error. Drive-by projects that have
562
- no entry in `path-map.json` for this host count as unmapped; the hint points at `nomad doctor`,
563
- which lists them by logical name.
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), run the read-only preflight `nomad doctor --check-shared`,
607
- which stages and scans the exact transcripts a push would publish. When findings live in a session
608
- transcript, the push aborts and names every affected session id and the recovery command:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
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": [
@@ -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 with rotate-and-scrub guidance, and the nested "other"
11
- * bucket). All external work flows through `scanStagedTree`; this module spawns
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 plus rotate-and-scrub + allowlist
36
- * guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
37
- * `<logical>` captured from the same match that keyed `bySession`, so the
38
- * scrub-path hint reuses the authoritative parse. The hint guard omits a row
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 `[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.
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(`[${rule}]`)}: ${dim(desc)}`);
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. Isolates the deepest nesting from `reportCheckShared`: the scan
140
- * try/catch (failure -> fail row + exit 1, carrying `err.message` only, never
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
- reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
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 (`✗`) 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
- }
@@ -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
- log(`pulling on host=${HOST} (backup=${ts}${dryRun ? '; dry-run' : ''})`);
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
- applySharedLinks(ts);
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