claude-nomad 0.26.2 → 0.28.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.
@@ -0,0 +1,96 @@
1
+ import { cpSync, existsSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { HOST, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
5
+ import { assertSafeLocalRoot, assertSafeLogical } from './extras-sync.guards.ts';
6
+ import { log } from './utils.ts';
7
+ import { readPathMap } from './utils.json.ts';
8
+
9
+ /** Parsed `path-map.json` plus its validated `extras` block. */
10
+ export type ValidatedExtras = { map: PathMap; extrasMap: Record<string, string[]> };
11
+
12
+ /** Skip counts: `unmapped` per-project (no host path / `'TBD'`), `skipped` per-dirname (not whitelisted). */
13
+ export type ExtrasCounts = { unmapped: number; skipped: number };
14
+
15
+ /**
16
+ * Load and validate `path-map.json` for an extras op, owning the guard order
17
+ * so the "FATAL before any filesystem mutation" contract holds for every
18
+ * caller. Returns the parsed map plus its `extras` block, or `null` on a clean
19
+ * early-exit (missing `path-map.json`, a missing repo extras dir when
20
+ * `requireRepoExtras`, or an empty/absent `extras` key). THE VALIDATION PASS
21
+ * runs here, up-front over the whole map (`assertSafeLogical` per logical,
22
+ * `assertSafeLocalRoot` per mapped non-`'TBD'` path) so a clean entry ahead of
23
+ * a poisoned one cannot let a `mkdirSync`/`cpSync` land before the FATAL fires.
24
+ *
25
+ * @param opts.requireRepoExtras - Also require `shared/extras/` (pull side).
26
+ * @param opts.missingMsg - `log()` line on the missing-prereq exit (omitted for
27
+ * the divergence check, which skips silently).
28
+ */
29
+ export function loadValidatedExtras(opts: {
30
+ requireRepoExtras?: boolean;
31
+ missingMsg?: string;
32
+ }): ValidatedExtras | null {
33
+ const mapPath = join(REPO_HOME, 'path-map.json');
34
+ const repoExtras = join(REPO_HOME, 'shared', 'extras');
35
+ if (!existsSync(mapPath) || (opts.requireRepoExtras === true && !existsSync(repoExtras))) {
36
+ if (opts.missingMsg !== undefined) log(opts.missingMsg);
37
+ return null;
38
+ }
39
+
40
+ const map = readPathMap(mapPath);
41
+ const extrasMap = map.extras ?? {};
42
+ if (Object.keys(extrasMap).length === 0) return null;
43
+
44
+ for (const logical of Object.keys(extrasMap)) {
45
+ assertSafeLogical(logical);
46
+ const localRoot = map.projects[logical]?.[HOST];
47
+ if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
48
+ }
49
+ return { map, extrasMap };
50
+ }
51
+
52
+ /**
53
+ * Yield every surviving `{ logical, localRoot, dirname }` extras target after
54
+ * the per-project and per-dirname skip filters, mutating `counts` as it goes
55
+ * (`unmapped++` for a project with no host path / `'TBD'`, then skip it;
56
+ * `skipped++` for a dirname outside `SUPPORTED_EXTRAS`). Shared by push, pull,
57
+ * and the divergence check so all three walk identical skip/count semantics;
58
+ * the caller builds src/dst from the yielded triple.
59
+ *
60
+ * @param v - validated path-map plus its extras block.
61
+ * @param counts - mutated in place as targets are skipped or yielded. Skips are
62
+ * counted silently (no per-skip log line); the caller's detail arrays and the
63
+ * collapsed count row carry that information to the tree renderer.
64
+ */
65
+ export function* eachExtrasTarget(
66
+ v: ValidatedExtras,
67
+ counts: ExtrasCounts,
68
+ ): Generator<{ logical: string; localRoot: string; dirname: string }> {
69
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
70
+ for (const [logical, dirnames] of Object.entries(v.extrasMap)) {
71
+ const localRoot = v.map.projects[logical]?.[HOST];
72
+ if (!localRoot || localRoot === 'TBD') {
73
+ counts.unmapped++;
74
+ continue;
75
+ }
76
+ for (const dirname of dirnames) {
77
+ if (!whitelist.includes(dirname)) {
78
+ counts.skipped++;
79
+ continue;
80
+ }
81
+ yield { logical, localRoot, dirname };
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Recursive mirror copy: `rmSync` then `cpSync` so dst-only entries are
88
+ * removed (true mirror, not just overwrite). Passes `verbatimSymlinks: true`
89
+ * to keep relative symlink targets unrewritten across hosts (Pitfall 1;
90
+ * nodejs/node issue 41693). Exported so the test file can call it directly;
91
+ * `remapExtrasPush` and `remapExtrasPull` are the primary public API.
92
+ */
93
+ export function copyExtras(src: string, dst: string): void {
94
+ rmSync(dst, { recursive: true, force: true });
95
+ cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
96
+ }
@@ -0,0 +1,138 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { REPO_HOME } from './config.ts';
5
+ import {
6
+ copyExtras,
7
+ eachExtrasTarget,
8
+ loadValidatedExtras,
9
+ type ExtrasCounts,
10
+ type ValidatedExtras,
11
+ } from './extras-sync.core.ts';
12
+ import { backupExtrasWrite, backupRepoWrite } from './utils.fs.ts';
13
+
14
+ /** Detail lists returned by an extras op: items copied (wet) and would-copy (dry). */
15
+ type ExtrasDetail = ExtrasCounts & { done: string[]; would: string[] };
16
+
17
+ /** One yielded extras target: a (logical, host localRoot, dirname) triple. */
18
+ type ExtrasTarget = { logical: string; localRoot: string; dirname: string };
19
+
20
+ /**
21
+ * Shared copy loop for `remapExtrasPush` / `remapExtrasPull`. Walks every
22
+ * surviving extras target (counts mutated via `eachExtrasTarget`; skips are
23
+ * counted silently, no per-skip log line), resolves src/dst through the
24
+ * side-specific `paths(...)`, and either records the would-copy item under
25
+ * `dryRun` or backs up + copies and records the done item. Returns
26
+ * `{ unmapped, skipped, done, would }`; the public wrappers rename
27
+ * `done`/`would` to push/pull-specific field names. No per-item log lines: the
28
+ * detail arrays carry that information to the tree renderer.
29
+ *
30
+ * @param v - validated path-map plus its extras block.
31
+ * @param dryRun - when `true`, collect `would` without mutating.
32
+ * @param paths - resolves `{ src, dst }` for one target (side-specific).
33
+ * @param backup - snapshots the dst before clobber (side-specific).
34
+ * @returns the counts plus the done/would detail lists.
35
+ */
36
+ function runExtrasOp(
37
+ v: ValidatedExtras,
38
+ dryRun: boolean,
39
+ paths: (t: ExtrasTarget) => { src: string; dst: string },
40
+ backup: (dst: string, localRoot: string) => void,
41
+ ): ExtrasDetail {
42
+ const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
43
+ const done: string[] = [];
44
+ const would: string[] = [];
45
+ for (const t of eachExtrasTarget(v, counts)) {
46
+ const { src, dst } = paths(t);
47
+ if (!existsSync(src)) continue;
48
+ const item = `${t.logical}/${t.dirname}`;
49
+ if (dryRun) {
50
+ would.push(item);
51
+ continue;
52
+ }
53
+ backup(dst, t.localRoot);
54
+ copyExtras(src, dst);
55
+ done.push(item);
56
+ }
57
+ return { ...counts, done, would };
58
+ }
59
+
60
+ /**
61
+ * Push: copy whitelisted extras directories under each project's localRoot
62
+ * into the repo at `shared/extras/<logical>/<dirname>/`. Returns
63
+ * `{ unmapped, skipped, pushed, wouldPush }` with intentionally asymmetric
64
+ * count granularity (see `eachExtrasTarget`): `unmapped` per-project, `skipped`
65
+ * per-dirname; both feed the summary row. `pushed` / `wouldPush` hold
66
+ * `<logical>/<dirname>` strings copied (wet) or that would copy under
67
+ * `opts.dryRun` so cmdPush can render a grouped tree. Skips are counted
68
+ * silently and per-item log lines are dropped; counts are unchanged. Legacy
69
+ * `path-map.json` without an `extras` key returns empty arrays and zero counts
70
+ * cleanly.
71
+ *
72
+ * @param ts - backup timestamp namespace.
73
+ * @param opts.dryRun - when `true`, collect `wouldPush` without mutating.
74
+ */
75
+ export function remapExtrasPush(
76
+ ts: string,
77
+ opts: { dryRun?: boolean } = {},
78
+ ): ExtrasCounts & { pushed: string[]; wouldPush: string[] } {
79
+ const dryRun = opts.dryRun === true;
80
+ const v = loadValidatedExtras({ missingMsg: 'no path-map.json; skipping extras push' });
81
+ if (v === null) return { unmapped: 0, skipped: 0, pushed: [], wouldPush: [] };
82
+
83
+ const repoExtras = join(REPO_HOME, 'shared', 'extras');
84
+ if (!dryRun) mkdirSync(repoExtras, { recursive: true });
85
+
86
+ const { unmapped, skipped, done, would } = runExtrasOp(
87
+ v,
88
+ dryRun,
89
+ ({ localRoot, logical, dirname }) => ({
90
+ src: join(localRoot, dirname),
91
+ dst: join(repoExtras, logical, dirname),
92
+ }),
93
+ (dst) => backupRepoWrite(dst, ts, REPO_HOME),
94
+ );
95
+ return { unmapped, skipped, pushed: done, wouldPush: would };
96
+ }
97
+
98
+ /**
99
+ * Pull: copy whitelisted extras from `shared/extras/<logical>/<dirname>/`
100
+ * back into each project's localRoot on this host. Returns
101
+ * `{ unmapped, skipped, pulled, wouldPull }` with the same asymmetric count
102
+ * granularity as `remapExtrasPush`; `pulled` / `wouldPull` hold
103
+ * `<logical>/<dirname>` strings for the grouped tree. Skips are counted
104
+ * silently and per-item log lines are dropped; counts are unchanged. Uses
105
+ * `backupExtrasWrite` (not `backupBeforeWrite`) because
106
+ * `<localRoot>/<dirname>` lives outside `CLAUDE_HOME` and the standard helper's
107
+ * relative-path guard would no-op and lose prior content. Legacy
108
+ * `path-map.json` without an `extras` key, or a missing `shared/extras/`, both
109
+ * produce a clean no-op.
110
+ *
111
+ * @param ts - backup timestamp namespace.
112
+ * @param opts.dryRun - when `true`, collect `wouldPull` without mutating.
113
+ */
114
+ export function remapExtrasPull(
115
+ ts: string,
116
+ opts: { dryRun?: boolean } = {},
117
+ ): ExtrasCounts & { pulled: string[]; wouldPull: string[] } {
118
+ const dryRun = opts.dryRun === true;
119
+ const v = loadValidatedExtras({
120
+ requireRepoExtras: true,
121
+ missingMsg: 'no path-map or repo extras dir; skipping extras remap',
122
+ });
123
+ if (v === null) return { unmapped: 0, skipped: 0, pulled: [], wouldPull: [] };
124
+
125
+ const repoExtras = join(REPO_HOME, 'shared', 'extras');
126
+ const { unmapped, skipped, done, would } = runExtrasOp(
127
+ v,
128
+ dryRun,
129
+ ({ localRoot, logical, dirname }) => ({
130
+ src: join(repoExtras, logical, dirname),
131
+ dst: join(localRoot, dirname),
132
+ }),
133
+ // Snapshot the host-side dst BEFORE copyExtras clobbers it. Anchor on
134
+ // localRoot so the backup tree mirrors the project layout.
135
+ (dst, localRoot) => backupExtrasWrite(dst, ts, localRoot),
136
+ );
137
+ return { unmapped, skipped, pulled: done, wouldPull: would };
138
+ }
@@ -1,102 +1,27 @@
1
- import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
4
+ import { HOME, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
5
5
  import { listDivergingFiles } from './extras-sync.diff.ts';
6
- import { assertSafeLocalRoot, assertSafeLogical } from './extras-sync.guards.ts';
7
- import { log, warn } from './utils.ts';
8
- import { backupExtrasWrite, backupRepoWrite } from './utils.fs.ts';
9
- import { encodePath, readPathMap } from './utils.json.ts';
10
-
11
- /** Parsed `path-map.json` plus its validated `extras` block. */
12
- type ValidatedExtras = { map: PathMap; extrasMap: Record<string, string[]> };
13
-
14
- /** Skip counts: `unmapped` per-project (no host path / `'TBD'`), `skipped` per-dirname (not whitelisted). */
15
- type ExtrasCounts = { unmapped: number; skipped: number };
16
-
17
- /**
18
- * Load and validate `path-map.json` for an extras op, owning the guard order
19
- * so the "FATAL before any filesystem mutation" contract holds for every
20
- * caller. Returns the parsed map plus its `extras` block, or `null` on a clean
21
- * early-exit (missing `path-map.json`, a missing repo extras dir when
22
- * `requireRepoExtras`, or an empty/absent `extras` key). THE VALIDATION PASS
23
- * runs here, up-front over the whole map (`assertSafeLogical` per logical,
24
- * `assertSafeLocalRoot` per mapped non-`'TBD'` path) so a clean entry ahead of
25
- * a poisoned one cannot let a `mkdirSync`/`cpSync` land before the FATAL fires.
26
- *
27
- * @param opts.requireRepoExtras - Also require `shared/extras/` (pull side).
28
- * @param opts.missingMsg - `log()` line on the missing-prereq exit (omitted for
29
- * the divergence check, which skips silently).
30
- */
31
- function loadValidatedExtras(opts: {
32
- requireRepoExtras?: boolean;
33
- missingMsg?: string;
34
- }): ValidatedExtras | null {
35
- const mapPath = join(REPO_HOME, 'path-map.json');
36
- const repoExtras = join(REPO_HOME, 'shared', 'extras');
37
- if (!existsSync(mapPath) || (opts.requireRepoExtras === true && !existsSync(repoExtras))) {
38
- if (opts.missingMsg !== undefined) log(opts.missingMsg);
39
- return null;
40
- }
41
-
42
- const map = readPathMap(mapPath);
43
- const extrasMap = map.extras ?? {};
44
- if (Object.keys(extrasMap).length === 0) return null;
45
-
46
- for (const logical of Object.keys(extrasMap)) {
47
- assertSafeLogical(logical);
48
- const localRoot = map.projects[logical]?.[HOST];
49
- if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
50
- }
51
- return { map, extrasMap };
52
- }
53
-
54
- /**
55
- * Yield every surviving `{ logical, localRoot, dirname }` extras target after
56
- * the per-project and per-dirname skip filters, mutating `counts` as it goes
57
- * (`unmapped++` for a project with no host path / `'TBD'`, then skip it;
58
- * `skipped++` for a dirname outside `SUPPORTED_EXTRAS`). Shared by push, pull,
59
- * and the divergence check so all three walk identical skip/count semantics;
60
- * the caller builds src/dst from the yielded triple.
61
- *
62
- * @param quiet - Suppress the per-skip `log()` lines (the read-only divergence
63
- * check skips silently; push/pull narrate). Counts increment either way.
64
- */
65
- function* eachExtrasTarget(
66
- v: ValidatedExtras,
67
- counts: ExtrasCounts,
68
- quiet = false,
69
- ): Generator<{ logical: string; localRoot: string; dirname: string }> {
70
- const whitelist: readonly string[] = SUPPORTED_EXTRAS;
71
- for (const [logical, dirnames] of Object.entries(v.extrasMap)) {
72
- const localRoot = v.map.projects[logical]?.[HOST];
73
- if (!localRoot || localRoot === 'TBD') {
74
- counts.unmapped++;
75
- if (!quiet) log(`skip ${logical}: no path for ${HOST}`);
76
- continue;
77
- }
78
- for (const dirname of dirnames) {
79
- if (!whitelist.includes(dirname)) {
80
- counts.skipped++;
81
- if (!quiet) log(`skip ${dirname} for ${logical}: not in SUPPORTED_EXTRAS`);
82
- continue;
83
- }
84
- yield { logical, localRoot, dirname };
85
- }
86
- }
87
- }
88
-
89
- /**
90
- * Recursive mirror copy: `rmSync` then `cpSync` so dst-only entries are
91
- * removed (true mirror, not just overwrite). Passes `verbatimSymlinks: true`
92
- * to keep relative symlink targets unrewritten across hosts (Pitfall 1;
93
- * nodejs/node issue 41693). Exported so the test file can call it directly;
94
- * `remapExtrasPush` and `remapExtrasPull` are the primary public API.
95
- */
96
- export function copyExtras(src: string, dst: string): void {
97
- rmSync(dst, { recursive: true, force: true });
98
- cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
99
- }
6
+ import {
7
+ copyExtras,
8
+ eachExtrasTarget,
9
+ loadValidatedExtras,
10
+ type ExtrasCounts,
11
+ type ValidatedExtras,
12
+ } from './extras-sync.core.ts';
13
+ import { assertSafeLogical } from './extras-sync.guards.ts';
14
+ import { warn } from './utils.ts';
15
+ import { encodePath } from './utils.json.ts';
16
+
17
+ // Re-export the shared primitives so existing import sites that pull them from
18
+ // `./extras-sync.ts` (tests call `copyExtras` directly) keep working unchanged.
19
+ export { copyExtras, eachExtrasTarget, loadValidatedExtras };
20
+ export type { ExtrasCounts, ValidatedExtras };
21
+
22
+ // The two public remap ops live in the sibling module to hold the soft
23
+ // line-cap; re-exported here so `./extras-sync.ts` stays the public surface.
24
+ export { remapExtrasPull, remapExtrasPush } from './extras-sync.remap.ts';
100
25
 
101
26
  /**
102
27
  * Repo-relative `shared/extras/<logical>/<dirname>` paths for every (logical,
@@ -125,77 +50,6 @@ export function whitelistedExtrasPaths(map: PathMap): string[] {
125
50
  return [...paths].sort((a, b) => a.localeCompare(b));
126
51
  }
127
52
 
128
- /**
129
- * Push: copy whitelisted extras directories under each project's localRoot
130
- * into the repo at `shared/extras/<logical>/<dirname>/`. Returns
131
- * `{ unmapped, skipped }` with intentionally asymmetric granularity (see
132
- * `eachExtrasTarget`): `unmapped` per-project, `skipped` per-dirname; both feed
133
- * `emitSummary`. `opts.dryRun` logs `would push extras:` lines without writing,
134
- * with identical count semantics. Legacy `path-map.json` without an `extras`
135
- * key returns `{ unmapped: 0, skipped: 0 }` cleanly.
136
- */
137
- export function remapExtrasPush(ts: string, opts: { dryRun?: boolean } = {}): ExtrasCounts {
138
- const dryRun = opts.dryRun === true;
139
- const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
140
- const v = loadValidatedExtras({ missingMsg: 'no path-map.json; skipping extras push' });
141
- if (v === null) return counts;
142
-
143
- const repoExtras = join(REPO_HOME, 'shared', 'extras');
144
- if (!dryRun) mkdirSync(repoExtras, { recursive: true });
145
-
146
- for (const { logical, localRoot, dirname } of eachExtrasTarget(v, counts)) {
147
- const src = join(localRoot, dirname);
148
- if (!existsSync(src)) continue;
149
- const dst = join(repoExtras, logical, dirname);
150
- if (dryRun) {
151
- log(`would push extras: ${src} -> ${dst}`);
152
- continue;
153
- }
154
- backupRepoWrite(dst, ts, REPO_HOME);
155
- copyExtras(src, dst);
156
- log(`pushed extras ${logical}/${dirname} -> shared/extras/${logical}/${dirname}`);
157
- }
158
- return counts;
159
- }
160
-
161
- /**
162
- * Pull: copy whitelisted extras from `shared/extras/<logical>/<dirname>/`
163
- * back into each project's localRoot on this host. Returns `{ unmapped,
164
- * skipped }` with the same asymmetric granularity as `remapExtrasPush`.
165
- * `opts.dryRun` logs `would overwrite extras:` lines without writing. Uses
166
- * `backupExtrasWrite` (not `backupBeforeWrite`) because `<localRoot>/<dirname>`
167
- * lives outside `CLAUDE_HOME` and the standard helper's relative-path guard
168
- * would no-op and lose prior content. Legacy `path-map.json` without an
169
- * `extras` key, or a missing `shared/extras/`, both produce a clean no-op.
170
- */
171
- export function remapExtrasPull(ts: string, opts: { dryRun?: boolean } = {}): ExtrasCounts {
172
- const dryRun = opts.dryRun === true;
173
- const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
174
- const v = loadValidatedExtras({
175
- requireRepoExtras: true,
176
- missingMsg: 'no path-map or repo extras dir; skipping extras remap',
177
- });
178
- if (v === null) return counts;
179
-
180
- const repoExtras = join(REPO_HOME, 'shared', 'extras');
181
-
182
- for (const { logical, localRoot, dirname } of eachExtrasTarget(v, counts)) {
183
- const src = join(repoExtras, logical, dirname);
184
- if (!existsSync(src)) continue;
185
- const dst = join(localRoot, dirname);
186
- if (dryRun) {
187
- log(`would overwrite extras: ${dst} (from ${src})`);
188
- continue;
189
- }
190
- // Snapshot the host-side dst BEFORE copyExtras clobbers it. Anchor
191
- // on localRoot so the backup tree mirrors the project layout.
192
- backupExtrasWrite(dst, ts, localRoot);
193
- copyExtras(src, dst);
194
- log(`pulled extras ${logical}/${dirname} -> ${dst}`);
195
- }
196
- return counts;
197
- }
198
-
199
53
  /**
200
54
  * Read-only pre-pull check: compare local `<localRoot>/<dirname>/` against
201
55
  * the just-pulled `shared/extras/<logical>/<dirname>/` and emit a WARN per
@@ -214,7 +68,7 @@ export function divergenceCheckExtras(ts: string): void {
214
68
 
215
69
  const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
216
70
  const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'extras');
217
- for (const { logical, localRoot, dirname } of eachExtrasTarget(v, counts, true)) {
71
+ for (const { logical, localRoot, dirname } of eachExtrasTarget(v, counts)) {
218
72
  const local = join(localRoot, dirname);
219
73
  const repo = join(REPO_HOME, 'shared', 'extras', logical, dirname);
220
74
  if (!existsSync(local) || !existsSync(repo)) continue;
package/src/links.ts CHANGED
@@ -64,8 +64,19 @@ export function applySharedLinks(ts: string, opts: { dryRun?: boolean } = {}): v
64
64
  * would produce. The unified textual diff of the would-be-written content
65
65
  * is produced by `computePreview` in `src/preview.ts`, not here, to keep
66
66
  * this function's contract simple (mutation or log-only).
67
+ *
68
+ * Returns `{ label }` where `label` is the override-source tag
69
+ * (`'<HOST>.json'` when a host override exists, else `'no host overrides'`).
70
+ * The WET path no longer logs `wrote settings.json (base + <label>)` inline;
71
+ * `cmdPull` consumes the returned label to render the Settings row of its
72
+ * grouped tree. The dry-run `would write settings.json ...` log and the
73
+ * drift WARN are unchanged (the WET success log is the only thing that moved).
74
+ *
75
+ * @param ts - backup timestamp namespace for `backupBeforeWrite`.
76
+ * @param opts.dryRun - when `true`, log the would-write line and skip mutation.
77
+ * @returns `{ label }` describing the override source for the Settings row.
67
78
  */
68
- export function regenerateSettings(ts: string, opts: { dryRun?: boolean } = {}): void {
79
+ export function regenerateSettings(ts: string, opts: { dryRun?: boolean } = {}): { label: string } {
69
80
  const dryRun = opts.dryRun === true;
70
81
  const basePath = join(REPO_HOME, 'shared', 'settings.base.json');
71
82
  const hostPath = join(REPO_HOME, 'hosts', `${HOST}.json`);
@@ -107,10 +118,10 @@ export function regenerateSettings(ts: string, opts: { dryRun?: boolean } = {}):
107
118
 
108
119
  if (dryRun) {
109
120
  log(`would write settings.json (base + ${overrideLabel})`);
110
- return;
121
+ return { label: overrideLabel };
111
122
  }
112
123
 
113
124
  backupBeforeWrite(settingsPath, ts);
114
125
  writeJsonAtomic(settingsPath, merged);
115
- log(`wrote settings.json (base + ${overrideLabel})`);
126
+ return { label: overrideLabel };
116
127
  }
package/src/nomad.help.ts CHANGED
@@ -49,6 +49,8 @@ export const DEFAULT_HELP = [
49
49
  cont('gitleaks, gitlinks).'),
50
50
  row(' --check-shared', 'Preflight gitleaks scan of the session transcripts a'),
51
51
  cont('`nomad push` would stage (a temp copy, never the live dir).'),
52
+ row(' --check-schema', 'Flag settings.json keys absent from the live published'),
53
+ cont('Claude Code settings schema (needs network; degrades offline).'),
52
54
  row(' --resume-cmd <id>', 'Print `cd <abspath> && claude --resume <id>` for a session id'),
53
55
  cont('from ~/.claude/projects/.'),
54
56
  '',
package/src/nomad.ts CHANGED
@@ -131,13 +131,16 @@ try {
131
131
  // Sub-flags: `doctor --resume-cmd <session-id>` dispatches to the
132
132
  // read-only sidecar that prints `cd <abspath> && claude --resume <id>`;
133
133
  // `doctor --check-shared` (no positional) appends the gitleaks preflight
134
- // scan of the transcripts a push would stage. Bare `doctor` runs the
135
- // plain read-only health check. Any other shape (unknown flag, extra
136
- // positional, `--check-shared` with trailing args) is a usage error.
134
+ // scan of the transcripts a push would stage; `doctor --check-schema`
135
+ // (no positional) appends the live settings-schema check. Bare `doctor`
136
+ // runs the plain read-only health check. Any other shape (unknown flag,
137
+ // extra positional, a scan flag with trailing args) is a usage error.
137
138
  if (process.argv[3] === undefined) {
138
139
  cmdDoctor();
139
140
  } else if (process.argv[3] === '--check-shared' && process.argv.length === 4) {
140
141
  cmdDoctor({ checkShared: true });
142
+ } else if (process.argv[3] === '--check-schema' && process.argv.length === 4) {
143
+ cmdDoctor({ checkSchema: true });
141
144
  } else if (process.argv[3] === '--resume-cmd') {
142
145
  const id = process.argv[4];
143
146
  if (process.argv.length !== 5 || typeof id !== 'string' || id.length === 0) {
@@ -146,7 +149,9 @@ try {
146
149
  }
147
150
  resumeCmd(id);
148
151
  } else {
149
- console.error('usage: nomad doctor [--check-shared | --resume-cmd <session-id>]');
152
+ console.error(
153
+ 'usage: nomad doctor [--check-shared | --check-schema | --resume-cmd <session-id>]',
154
+ );
150
155
  process.exit(1);
151
156
  }
152
157
  break;
@@ -0,0 +1,91 @@
1
+ import { failGlyph, red } from './color.ts';
2
+
3
+ /**
4
+ * Bare `failGlyph` codepoint (`✗`, U+2717) without any WSL padding the
5
+ * `failGlyph` constant may carry. Header rendering composes its own
6
+ * spacing (`${red(failGlyph)} ${header}`), so the section-header path
7
+ * must use the unpadded codepoint to avoid a double space on WSL.
8
+ */
9
+ const FAIL_GLYPH_BARE = '✗';
10
+
11
+ /**
12
+ * Tree-style output builder shared by `cmdDoctor`, `cmdPush`, and `cmdPull`.
13
+ * Callers build an ordered list of `DoctorSection`s, push pre-rendered
14
+ * plain-text items into the relevant section, then call `renderTree`
15
+ * (aliased `renderDoctor` for doctor's call site) 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
+ }
41
+
42
+ /**
43
+ * True when any item in the section contains the FAIL glyph.
44
+ * Color-wrapped failGlyph (`[31m✗[39m`) still contains the
45
+ * glyph as a substring, so this works for both color-on and color-off output.
46
+ */
47
+ function sectionFailed(s: DoctorSection): boolean {
48
+ return s.items.some((line) => line.includes(failGlyph));
49
+ }
50
+
51
+ /**
52
+ * Render one section: a (possibly fail-glyph-prefixed) header followed by its
53
+ * items as a tree. Empty-string items print as true blank lines; the `└` elbow
54
+ * attaches to the last non-empty item so a trailing blank cannot strand it.
55
+ */
56
+ function renderSection(s: DoctorSection): void {
57
+ const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
58
+ console.log(header);
59
+ const lastContent = s.items.reduce((acc, item, j) => (item !== '' ? j : acc), -1);
60
+ for (let j = 0; j < s.items.length; j++) {
61
+ if (s.items[j] === '') console.log('');
62
+ else console.log(`${j === lastContent ? ' └ ' : ' ├ '}${s.items[j]}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Emit the full grouped tree. Skips empty sections, prefixes failed-section
68
+ * headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
69
+ * `grep -F '✗'` catches both row and header failures), and writes one blank
70
+ * line between rendered sections (no leading or trailing blank).
71
+ *
72
+ * An empty-string item renders as a true blank line (no tree connector), which
73
+ * lets a reporter set off a footer block (e.g. the `--check-shared` description
74
+ * legend) with vertical whitespace. The `└` connector attaches to the last
75
+ * non-empty item rather than the last array slot so a trailing blank does not
76
+ * strand the elbow on an empty line.
77
+ */
78
+ export function renderTree(sections: DoctorSection[]): void {
79
+ const visible = sections.filter((s) => s.items.length > 0);
80
+ for (let i = 0; i < visible.length; i++) {
81
+ if (i > 0) console.log('');
82
+ renderSection(visible[i]);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Back-compat alias for `renderTree`. Doctor's call site imports
88
+ * `renderDoctor`; push/pull import `renderTree`. Both point at the same
89
+ * implementation so doctor output stays byte-identical.
90
+ */
91
+ export const renderDoctor = renderTree;