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 +7 -0
- package/README.md +80 -12
- package/package.json +1 -1
- 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-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,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,
|
|
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
|
@@ -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
|
|
@@ -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
|
+
}
|