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.
- package/CHANGELOG.md +20 -0
- package/README.md +132 -31
- package/package.json +1 -1
- package/src/commands.doctor.check-schema.ts +72 -0
- package/src/commands.doctor.format.ts +2 -81
- package/src/commands.doctor.ts +20 -2
- package/src/commands.drop-session.scrub-hint.ts +72 -0
- package/src/commands.drop-session.ts +5 -1
- package/src/commands.pull.ts +69 -13
- package/src/commands.push.sections.ts +171 -0
- package/src/commands.push.ts +136 -71
- package/src/config.ts +13 -42
- 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/nomad.help.ts +2 -0
- package/src/nomad.ts +9 -4
- 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/settings-keys.ts +124 -0
- package/src/summary.ts +75 -27
|
@@ -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
|
+
}
|
package/src/extras-sync.ts
CHANGED
|
@@ -1,102 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { HOME,
|
|
4
|
+
import { HOME, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
|
|
5
5
|
import { listDivergingFiles } from './extras-sync.diff.ts';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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 } = {}):
|
|
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
|
-
|
|
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
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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(
|
|
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;
|