claude-nomad 0.26.2 → 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,12 @@
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
+
3
10
  ## [0.26.2](https://github.com/funkadelic/claude-nomad/compare/v0.26.1...v0.26.2) (2026-05-28)
4
11
 
5
12
 
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.2",
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": [
@@ -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
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Pure section-builder helpers shared by `cmdPush` and `cmdPull` for the
3
+ * doctor-style grouped tree. The builders are verb-agnostic: they take the
4
+ * already-selected detail array (the wet `pushed`/`pulled` list or the dry-run
5
+ * `wouldPush`/`wouldPull` list) plus the relevant skip count, so the same code
6
+ * renders both push and pull rows. Row shapes mirror `nomad doctor`: one
7
+ * `${green(okGlyph)} <item>` row per synced item, then a single collapsed
8
+ * `${dim(infoGlyph)} <N> <noun>` count row instead of one row per skip.
9
+ *
10
+ * Sections returned here may be empty; `renderTree` (in `./output-tree.ts`)
11
+ * skips empty sections, so a Sessions/Extras group with zero items and a zero
12
+ * skip count never prints a header.
13
+ */
14
+
15
+ import { dim, green, infoGlyph, okGlyph } from './color.ts';
16
+ import type { remapExtrasPush } from './extras-sync.ts';
17
+ import { type DoctorSection, addItem, renderTree, section } from './output-tree.ts';
18
+ import type { LeakVerdict } from './push-leak-verdict.ts';
19
+ import type { remapPush } from './remap.ts';
20
+ import { summaryRow } from './summary.ts';
21
+
22
+ /**
23
+ * Build the single collapsed count row, or `null` when `n` is zero. Used for
24
+ * the "not in path-map" session skips and the "extras skipped" extras skips so
25
+ * the noisy per-project skip lines fold into one row that points at
26
+ * `nomad doctor` for the authoritative list.
27
+ *
28
+ * @param n - The skip count (no row is produced when `0`).
29
+ * @param noun - The collapsed-row phrasing after the count (e.g.
30
+ * `'not in path-map (run nomad doctor to list)'` or `'extras skipped'`).
31
+ * @returns The rendered ℹ︎ count row, or `null` when `n` is `0`.
32
+ */
33
+ export function collapsedSkipRow(n: number, noun: string): string | null {
34
+ if (n <= 0) return null;
35
+ return `${dim(infoGlyph)} ${n} ${noun}`;
36
+ }
37
+
38
+ /**
39
+ * Build the Settings section for `cmdPull`: a single
40
+ * `${green(okGlyph)} settings.json (base + <label>)` row. `label` is the
41
+ * override-source tag returned by `regenerateSettings` (`'<HOST>.json'` when a
42
+ * host override exists, else `'no host overrides'`), surfacing what was written
43
+ * without `regenerateSettings` logging the line inline. Push has no Settings
44
+ * section, so this helper is pull-only.
45
+ *
46
+ * @param label - The override-source tag from `regenerateSettings`.
47
+ * @returns A `Settings` `DoctorSection` holding the one settings row.
48
+ */
49
+ export function buildSettingsSection(label: string): DoctorSection {
50
+ const s = section('Settings');
51
+ addItem(s, `${green(okGlyph)} settings.json (base + ${label})`);
52
+ return s;
53
+ }
54
+
55
+ /**
56
+ * Build the Sessions section: one ✓ row per synced logical name plus, when
57
+ * `unmapped > 0`, a single collapsed `${unmapped} not in path-map` count row.
58
+ * Verb-agnostic: pass `remapResult.pushed` (or `wouldPush`, or the pull-side
59
+ * `pulled`/`wouldPull`) as `items`.
60
+ *
61
+ * @param items - The logical names synced this run.
62
+ * @param unmapped - Count of path-map entries skipped for this host.
63
+ * @returns A `Sessions` `DoctorSection` (possibly empty).
64
+ */
65
+ export function buildSessionsSection(items: string[], unmapped: number): DoctorSection {
66
+ const s = section('Sessions');
67
+ for (const logical of items) addItem(s, `${green(okGlyph)} ${logical}`);
68
+ const skip = collapsedSkipRow(unmapped, 'not in path-map (run nomad doctor to list)');
69
+ if (skip !== null) addItem(s, skip);
70
+ return s;
71
+ }
72
+
73
+ /**
74
+ * Build the Extras section: one ✓ row per synced `<logical>/<dirname>` entry
75
+ * plus, when `extrasSkipped > 0`, a single collapsed `${extrasSkipped} extras
76
+ * skipped` count row. Verb-agnostic: pass the wet or dry detail array as
77
+ * `items`.
78
+ *
79
+ * @param items - The `<logical>/<dirname>` entries synced this run.
80
+ * @param extrasSkipped - Count of dirnames the whitelist declined to sync.
81
+ * @returns An `Extras` `DoctorSection` (possibly empty).
82
+ */
83
+ export function buildExtrasSection(items: string[], extrasSkipped: number): DoctorSection {
84
+ const s = section('Extras');
85
+ for (const entry of items) addItem(s, `${green(okGlyph)} ${entry}`);
86
+ const skip = collapsedSkipRow(extrasSkipped, 'extras skipped');
87
+ if (skip !== null) addItem(s, skip);
88
+ return s;
89
+ }
90
+
91
+ /**
92
+ * Collected per-run push state threaded through `cmdPush` so the grouped tree
93
+ * can be assembled once at the end. `remap`/`extras` carry the detail arrays +
94
+ * counts; `dryRun` selects the wet (`pushed`) vs would-* (`wouldPush`) arrays.
95
+ */
96
+ export type PushState = {
97
+ dryRun: boolean;
98
+ remap: ReturnType<typeof remapPush>;
99
+ extras: ReturnType<typeof remapExtrasPush>;
100
+ };
101
+
102
+ /**
103
+ * Assemble the Sessions/Extras sections shared by the real and dry-run push
104
+ * paths, selecting the wet `pushed` detail arrays or the `wouldPush` arrays
105
+ * under `dryRun`. The Leak scan and Summary sections are appended by the caller
106
+ * in path-specific order.
107
+ *
108
+ * @param st - The collected push state.
109
+ * @returns The ordered `[Sessions, Extras]` sections (either may be empty).
110
+ */
111
+ function syncedSections(st: PushState): DoctorSection[] {
112
+ const sessions = st.dryRun ? st.remap.wouldPush : st.remap.pushed;
113
+ const extras = st.dryRun ? st.extras.wouldPush : st.extras.pushed;
114
+ return [
115
+ buildSessionsSection(sessions, st.remap.unmapped),
116
+ buildExtrasSection(extras, st.extras.skipped),
117
+ ];
118
+ }
119
+
120
+ /**
121
+ * Build the single-row Summary section from the combined unmapped count
122
+ * (sessions + extras), the collision count, and the extras-skipped count.
123
+ * Phrasing is delegated to `summaryRow` so it matches `emitSummary` exactly.
124
+ *
125
+ * @param st - The collected push state.
126
+ * @returns A `Summary` `DoctorSection` holding the one summary row.
127
+ */
128
+ function summarySection(st: PushState): DoctorSection {
129
+ const s = section('Summary');
130
+ const unmapped = st.remap.unmapped + st.extras.unmapped;
131
+ addItem(s, summaryRow('push', unmapped, st.remap.collisions, st.extras.skipped));
132
+ return s;
133
+ }
134
+
135
+ /**
136
+ * Render the grouped push tree with a Leak scan section (carrying `verdict`'s
137
+ * row) between Extras and Summary. The caller throws the recovery body as a
138
+ * `NomadFatal` AFTER this returns (real-push leak) or prints it via `fail`
139
+ * (dry-run) so the recovery block follows the tree.
140
+ *
141
+ * @param st - The collected push state.
142
+ * @param verdict - The leak verdict for the Leak scan section.
143
+ */
144
+ export function renderPushTree(st: PushState, verdict: LeakVerdict): void {
145
+ const leakScan = section('Leak scan');
146
+ addItem(leakScan, verdict.verdictRow);
147
+ renderTree([...syncedSections(st), leakScan, summarySection(st)]);
148
+ }
149
+
150
+ /**
151
+ * Render the no-Leak-scan push tree: the Sessions/Extras rows (if any) plus the
152
+ * Summary row. `renderTree` skips empty sections, so an empty push prints only
153
+ * the Summary. Two callers: the real-push nothing-to-commit early return
154
+ * (`noMapHint` omitted) and the dry-run no-`path-map.json` case
155
+ * (`noMapHint: true`), which prepends a `Path map` section carrying a single
156
+ * `${dim(infoGlyph)} no path-map.json (nothing to preview)` row so a dry-run
157
+ * user sees WHY no Leak scan section rendered (no map means nothing to stage).
158
+ *
159
+ * @param st - The collected push state.
160
+ * @param opts.noMapHint - When `true`, prepend the no-path-map hint section.
161
+ * @returns Nothing; renders to stdout.
162
+ */
163
+ export function renderNoScanTree(st: PushState, opts: { noMapHint?: boolean } = {}): void {
164
+ const sections: DoctorSection[] = [];
165
+ if (opts.noMapHint === true) {
166
+ const pathMap = section('Path map');
167
+ addItem(pathMap, `${dim(infoGlyph)} no path-map.json (nothing to preview)`);
168
+ sections.push(pathMap);
169
+ }
170
+ renderTree([...sections, ...syncedSections(st), summarySection(st)]);
171
+ }