claude-nomad 0.23.0 → 0.25.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.
@@ -272,13 +272,19 @@ export function reportNeverSync(section: DoctorSection): void {
272
272
  addItem(section, `${dim(infoGlyph)} never-sync items: ${[...NEVER_SYNC].join(', ')}`);
273
273
  }
274
274
 
275
- /** Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with ENOENT vs other-error distinction (sets exitCode=1). */
276
- export function reportGitleaksProbe(section: DoctorSection): void {
275
+ /**
276
+ * Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with
277
+ * ENOENT vs other-error distinction (sets exitCode=1). Returns `true` when a
278
+ * usable binary was found so the caller can skip a redundant second `version`
279
+ * probe (e.g. the `--check-shared` Shared scan section).
280
+ */
281
+ export function reportGitleaksProbe(section: DoctorSection): boolean {
277
282
  try {
278
283
  const v = execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] })
279
284
  .toString()
280
285
  .trim();
281
286
  addItem(section, `${green(okGlyph)} gitleaks: ${dim(v)}`);
287
+ return true;
282
288
  } catch (err) {
283
289
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
284
290
  addItem(section, `${red(failGlyph)} gitleaks: not on PATH (required for nomad push)`);
@@ -286,6 +292,7 @@ export function reportGitleaksProbe(section: DoctorSection): void {
286
292
  addItem(section, `${red(failGlyph)} gitleaks: probe failed: ${(err as Error).message}`);
287
293
  }
288
294
  process.exitCode = 1;
295
+ return false;
289
296
  }
290
297
  }
291
298
 
@@ -0,0 +1,132 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { green, okGlyph, warnGlyph, yellow } from './color.ts';
6
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
7
+ import { GITLEAKS_PINNED_VERSION, REPO_HOME } from './config.ts';
8
+ import type { SpawnSyncFn } from './gh-actions.ts';
9
+
10
+ /**
11
+ * Soft gitleaks version-drift check appended to the Version section of
12
+ * `nomad doctor`. Parses `gitleaks version` stdout and compares its
13
+ * major.minor against `GITLEAKS_PINNED_VERSION` (the value CI installs),
14
+ * emitting one of:
15
+ * - `✓ gitleaks: X.Y.Z (matches pinned A.B)` when major.minor agree
16
+ * - `⚠︎ gitleaks: <local> -> <pin> (CI pins this; local drift may change scan results)`
17
+ * when major.minor diverge
18
+ * Only major.minor is compared: a patch-only difference is treated as OK,
19
+ * because gitleaks rule/allowlist behavior tracks the minor line, not the
20
+ * patch. Every failure path (gitleaks absent, subprocess error, unparseable
21
+ * or two-segment output) is a SILENT skip; this module never sets
22
+ * `process.exitCode` and never writes to stderr, mirroring the sibling
23
+ * release-version and node-engine checks.
24
+ */
25
+
26
+ /** Strict three-segment matcher capturing major and minor. Anchored on both
27
+ * ends so a two-segment string like `8.30` does not parse (feeding such a
28
+ * value to a triple-segment comparator would be undecidable). */
29
+ const SEMVER_MAJOR_MINOR = /^(\d+)\.(\d+)\.\d+$/;
30
+
31
+ /** Hard cap on the `gitleaks version` subprocess (matching the gh-actions
32
+ * primitives' `GH_TIMEOUT_MS` convention) so a wedged binary cannot hang the
33
+ * synchronous doctor run; the timeout throws and is swallowed as a silent skip. */
34
+ const GITLEAKS_TIMEOUT_MS = 5_000;
35
+
36
+ /**
37
+ * Capture the `[major, minor]` pair from a strict `X.Y.Z` semver string.
38
+ * Returns `null` when the input does not match a three-segment semver (e.g. a
39
+ * two-segment `8.30`, or non-numeric noise), which the caller treats as a
40
+ * silent skip.
41
+ *
42
+ * @param value - Candidate version string (already trimmed).
43
+ * @returns A `[major, minor]` tuple of bare numeric strings, or `null`.
44
+ */
45
+ function majorMinorOf(value: string): [string, string] | null {
46
+ const m = SEMVER_MAJOR_MINOR.exec(value);
47
+ return m === null ? null : [m[1], m[2]];
48
+ }
49
+
50
+ /**
51
+ * Run `gitleaks version` via the injected runner and return the trimmed
52
+ * stdout, or `null` on any throw (missing binary, subprocess failure). Mirrors
53
+ * the `probeGitleaks` invocation form in `push-checks.ts`: argv-array
54
+ * `execFileSync` (no shell), piped stdio, and a conditional
55
+ * `--config <REPO_HOME>/.gitleaks.toml` when that allowlist exists at call
56
+ * time, plus a `GITLEAKS_TIMEOUT_MS` cap so a wedged binary cannot hang the
57
+ * synchronous doctor run. Swallowing the error here is what makes both the
58
+ * absent-gitleaks case and a timeout a silent skip rather than a doctor failure.
59
+ *
60
+ * @param run - Injectable subprocess runner; defaults to `execFileSync`.
61
+ * @param tomlExists - Injectable allowlist-file existence check; defaults to
62
+ * `existsSync`. Injected in tests so the `--config` branch is exercised
63
+ * independent of the host filesystem (REPO_HOME varies per host and in CI).
64
+ * @returns The trimmed `gitleaks version` output, or `null` on any failure.
65
+ */
66
+ function readGitleaksVersion(
67
+ run: SpawnSyncFn,
68
+ tomlExists: (path: string) => boolean,
69
+ ): string | null {
70
+ const tomlPath = join(REPO_HOME, '.gitleaks.toml');
71
+ const args: string[] = ['version'];
72
+ if (tomlExists(tomlPath)) args.push('--config', tomlPath);
73
+ try {
74
+ return run('gitleaks', args, {
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ timeout: GITLEAKS_TIMEOUT_MS,
77
+ })
78
+ .toString()
79
+ .trim();
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Emit a single, non-fatal gitleaks version-drift diagnostic for
87
+ * `nomad doctor` by comparing the host's `gitleaks version` major.minor to
88
+ * `GITLEAKS_PINNED_VERSION`.
89
+ *
90
+ * Logs one of:
91
+ * - `✓ gitleaks: X.Y.Z (matches pinned A.B)` when the major.minor agree
92
+ * (including a patch-only difference from the pin)
93
+ * - `⚠︎ gitleaks: <local> -> <pin> (...)` when the major.minor diverge
94
+ *
95
+ * A missing gitleaks binary, a subprocess error, or output that does not match
96
+ * a strict `X.Y.Z` semver results in no output and does not change
97
+ * `process.exitCode`.
98
+ *
99
+ * @param section - The Version section to append the diagnostic line to.
100
+ * @param run - Injectable subprocess runner; defaults to `execFileSync`.
101
+ * @param tomlExists - Injectable allowlist-file existence check; defaults to
102
+ * `existsSync`. Mirrors the `run` seam so tests cover the `--config` branch
103
+ * deterministically.
104
+ */
105
+ export function reportGitleaksVersionCheck(
106
+ section: DoctorSection,
107
+ run: SpawnSyncFn = execFileSync,
108
+ tomlExists: (path: string) => boolean = existsSync,
109
+ ): void {
110
+ const raw = readGitleaksVersion(run, tomlExists);
111
+ if (raw === null) return;
112
+ const local = majorMinorOf(raw);
113
+ if (local === null) return;
114
+ const pin = majorMinorOf(GITLEAKS_PINNED_VERSION);
115
+ // Defensive: GITLEAKS_PINNED_VERSION is a hardcoded strict semver, so this
116
+ // never fires in practice; skip silently rather than risk a false WARN if a
117
+ // future edit ever malforms the constant.
118
+ /* c8 ignore next */
119
+ if (pin === null) return;
120
+ // Compare major.minor ONLY (D-02). Inline numeric compare on the captured
121
+ // segments; do NOT feed a two-segment string to compareSemver (its
122
+ // triple-segment contract returns an undecidable 0).
123
+ const sameMajorMinor = local[0] === pin[0] && local[1] === pin[1];
124
+ if (sameMajorMinor) {
125
+ addItem(section, `${green(okGlyph)} gitleaks: ${raw} (matches pinned ${pin[0]}.${pin[1]})`);
126
+ return;
127
+ }
128
+ addItem(
129
+ section,
130
+ `${yellow(warnGlyph)} gitleaks: ${raw} -> ${GITLEAKS_PINNED_VERSION} (CI pins this; local drift may change scan results)`,
131
+ );
132
+ }
@@ -0,0 +1,83 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import { warnGlyph, yellow } from './color.ts';
4
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
5
+ import { REPO_HOME } from './config.ts';
6
+ import {
7
+ ghAuthStatus,
8
+ isActionsEnabled,
9
+ isRepoPrivate,
10
+ parseGitHubRemote,
11
+ readOriginRemote,
12
+ type SpawnSyncFn,
13
+ } from './gh-actions.ts';
14
+
15
+ /**
16
+ * Drift check appended to the Repository section of `nomad doctor`. WARNs (never
17
+ * FAILs, never sets `process.exitCode`) when the origin remote is a private
18
+ * GitHub mirror that is gh-authed with Actions re-enabled, the quiet failure
19
+ * mode where Actions get turned back on after `nomad init` auto-disabled them
20
+ * (via the GitHub web UI or a stray `gh` call) and the mirror starts firing its
21
+ * workflows on every push again.
22
+ *
23
+ * Reuses the five `gh-actions.ts` primitives unchanged (no new gh wrapper) and
24
+ * clones the `cmdInit` auto-disable gate ORDER, but strips every tip-log and
25
+ * the `disableActions` call: doctor is read-only and SILENT on every miss where
26
+ * init is chatty. The gate chain returns with no output when any of these hold:
27
+ * gate 1 - the origin remote cannot be read (not a git repo / no origin)
28
+ * gate 2 - the origin is not a GitHub URL (`parseGitHubRemote` null)
29
+ * gate 3 - `gh` is not installed or not authed
30
+ * gate 4 - the repo is public, or the privacy probe throws
31
+ * gate 5 - Actions are already disabled, or the Actions probe throws
32
+ * Only when all five gates pass does it emit the single yellow WARN line with a
33
+ * `gh api -X PUT ... -F enabled=false` remediation hint (the exact shape
34
+ * `disableActions` would run). The three gh probes (gates 3-5) carry the shared
35
+ * `GH_TIMEOUT_MS` internally; gates 1-2 are local (a `git remote get-url` config
36
+ * read and a regex parse), so no network and no timeout needed. No new doctor
37
+ * section; no opt-out flag.
38
+ *
39
+ * @param section - The Repository section to append the WARN line to.
40
+ * @param run - Injectable subprocess runner; defaults to `execFileSync`.
41
+ */
42
+ export function reportMirrorActions(section: DoctorSection, run: SpawnSyncFn = execFileSync): void {
43
+ // Gate 1: origin remote. Throws on no remote / non-repo -> silent skip.
44
+ let remote: string;
45
+ try {
46
+ remote = readOriginRemote(REPO_HOME, run);
47
+ } catch {
48
+ return;
49
+ }
50
+
51
+ // Gate 2: GitHub remote. Non-GitHub URL parses to null -> silent skip.
52
+ const ref = parseGitHubRemote(remote);
53
+ if (ref === null) return;
54
+
55
+ // Gate 3: gh available and authed. Doctor stays silent on both miss reasons
56
+ // (init prints a tip here; doctor does not, per the read-only contract).
57
+ if (ghAuthStatus(run) !== null) return;
58
+
59
+ // Gate 4: private mirror. A public repo, or a probe that throws, is a skip.
60
+ let isPrivate: boolean;
61
+ try {
62
+ isPrivate = isRepoPrivate(ref, run);
63
+ } catch {
64
+ return;
65
+ }
66
+ if (!isPrivate) return;
67
+
68
+ // Gate 5: Actions enabled. Already-disabled, or a probe that throws, is a skip.
69
+ let enabled: boolean;
70
+ try {
71
+ enabled = isActionsEnabled(ref, run);
72
+ } catch {
73
+ return;
74
+ }
75
+ if (!enabled) return;
76
+
77
+ // All gates passed: the private mirror has Actions re-enabled. Emit the
78
+ // single yellow WARN with the exact disable command as the remediation hint.
79
+ addItem(
80
+ section,
81
+ `${yellow(warnGlyph)} mirror Actions: enabled on private mirror ${ref.owner}/${ref.repo} (re-disable with 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false')`,
82
+ );
83
+ }
@@ -12,8 +12,11 @@ import {
12
12
  reportRepoState,
13
13
  reportSharedLinks,
14
14
  } from './commands.doctor.checks.ts';
15
+ import { reportCheckShared } from './commands.doctor.check-shared.ts';
15
16
  import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
16
17
  import { renderDoctor, section } from './commands.doctor.format.ts';
18
+ import { reportGitleaksVersionCheck } from './commands.doctor.gitleaks-version.ts';
19
+ import { reportMirrorActions } from './commands.doctor.mirror-actions.ts';
17
20
  import { reportVersionCheck } from './commands.doctor.version.ts';
18
21
 
19
22
  /**
@@ -24,8 +27,13 @@ import { reportVersionCheck } from './commands.doctor.version.ts';
24
27
  * inside the individual reporters, so a piped
25
28
  * `nomad doctor 2>/dev/null` still exposes failures to scripts. Differs from
26
29
  * `cmdPull` / `cmdPush` / `resumeCmd`, where FATAL is on stderr.
30
+ *
31
+ * `opts.checkShared` (the `--check-shared` sub-flag) appends a "Shared scan"
32
+ * section that runs the gitleaks preflight over the session transcripts a
33
+ * `nomad push` would stage. It is OFF by default so plain `nomad doctor`
34
+ * stays the fast read-only smoke test (no scan, no temp tree).
27
35
  */
28
- export function cmdDoctor(): void {
36
+ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
29
37
  const host = section('Host');
30
38
  reportHostAndPaths(host);
31
39
  reportRepoState(host);
@@ -45,14 +53,23 @@ export function cmdDoctor(): void {
45
53
  reportNeverSync(neverSync);
46
54
 
47
55
  const repository = section('Repository');
48
- reportGitleaksProbe(repository);
56
+ const gitleaksReady = reportGitleaksProbe(repository);
49
57
  reportGitlinks(repository);
50
58
  reportRemote(repository);
51
59
  reportRebaseClean(repository);
60
+ reportMirrorActions(repository);
52
61
 
53
62
  const version = section('Version');
54
63
  reportVersionCheck(version);
55
64
  reportNodeEngineCheck(version);
65
+ reportGitleaksVersionCheck(version);
56
66
 
57
- renderDoctor([version, host, links, settings, pathMap, neverSync, repository]);
67
+ const sharedScan = section('Shared scan');
68
+ // Reuse the Repository-section readiness probe so reportCheckShared does not
69
+ // re-spawn gitleaks for its own readiness on a --check-shared run; it still
70
+ // probes standalone when called without a prior result. (The Version-section
71
+ // drift check above spawns `gitleaks version` separately, by design.)
72
+ if (opts.checkShared === true) reportCheckShared(sharedScan, gitleaksReady);
73
+
74
+ renderDoctor([version, host, links, settings, pathMap, neverSync, repository, sharedScan]);
58
75
  }
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, readdirSync } from 'node:fs';
2
+ import { existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import { join, relative } from 'node:path';
4
4
 
5
5
  import { REPO_HOME } from './config.ts';
@@ -7,27 +7,32 @@ import { acquireLock, die, fail, log, NomadFatal, releaseLock } from './utils.ts
7
7
 
8
8
  /**
9
9
  * Surgical removal of a contaminated session from the staged tree of
10
- * `~/claude-nomad/`. Walks `shared/projects/<logical>/<id>.jsonl` at the
11
- * top level only, classifies each match via `git ls-files --error-unmatch`,
12
- * and unstages with the appropriate primitive:
10
+ * `~/claude-nomad/`. For each `shared/projects/<logical>/` child this
11
+ * matches both the flat `<id>.jsonl` AND the sibling subagent directory
12
+ * `<id>/` (whose nested transcripts are keyed by the same session id),
13
+ * classifies each staged entry via `git ls-files --error-unmatch`, and
14
+ * unstages with the appropriate primitive:
13
15
  *
14
16
  * - tracked-in-HEAD -> `git restore --staged --worktree -- <rel>`
15
17
  * - newly-staged -> `git rm --cached -f -- <rel>`
16
18
  *
17
- * Idempotent: files that are not in the index at all are skipped silently
18
- * rather than treated as errors. Exits 0 on any drop, including an
19
- * idempotent re-run that finds the matches already absent. Exits 1 with
20
- * `✗ no staged session matches <id>` (red `✗` fail glyph) only when no
21
- * `shared/projects/<logical>/<id>.jsonl` exists at all in the shared tree.
19
+ * The directory tree is expanded into its staged entries via
20
+ * `git ls-files -z -- <dir-rel>` so every nested file flows through the
21
+ * same per-entry classification loop as the flat jsonl; this closes the
22
+ * leak where a "dropped" session still shipped its subagent transcripts.
23
+ *
24
+ * Idempotent: entries not in the index are skipped silently. Exits 0 on
25
+ * any drop (including an idempotent re-run); exits 1 with `✗ no staged
26
+ * session matches <id>` only when neither a flat `<id>.jsonl` nor a
27
+ * `<id>/` directory with staged entries exists anywhere in the tree.
22
28
  *
23
29
  * Defense-in-depth: the id is validated against the same allowlist regex
24
30
  * used in `src/resume.ts` before any path composition. argv-array form
25
31
  * for every git invocation.
26
32
  *
27
- * NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl`. The local file
28
- * is preserved so it can race-safely coexist with active Claude Code
29
- * writers; rotate-and-scrub of the local copy is the user's
30
- * responsibility.
33
+ * NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl` or the local
34
+ * `<id>/` tree; the local copies are preserved so they race-safely
35
+ * coexist with active Claude Code writers.
31
36
  *
32
37
  * @param id Session id (filename minus `.jsonl`). Must match `[A-Za-z0-9_-]+`
33
38
  * with length 1..128.
@@ -49,21 +54,32 @@ export function cmdDropSession(id: string): void {
49
54
  if (!existsSync(repoProjects)) {
50
55
  throw new NomadFatal(`no staged session matches ${id}`);
51
56
  }
52
- // Top-level walk only: for each `shared/projects/<logical>/` child,
53
- // check whether `<id>.jsonl` exists. No descent into
54
- // subagents/memory/tool-results subdirectories.
57
+ // For each `shared/projects/<logical>/` child, match the flat
58
+ // `<id>.jsonl` plus the sibling subagent directory `<id>/`. The
59
+ // directory is expanded into its staged entries so every nested file
60
+ // flows through the same per-entry unstage loop as the flat jsonl.
55
61
  const matches: string[] = [];
56
62
  for (const logical of readdirSync(repoProjects)) {
57
63
  const candidate = join(repoProjects, logical, `${id}.jsonl`);
58
64
  if (existsSync(candidate)) {
59
- matches.push(candidate);
65
+ matches.push(relative(REPO_HOME, candidate));
66
+ }
67
+ const dir = join(repoProjects, logical, id);
68
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
69
+ const dirRel = relative(REPO_HOME, dir);
70
+ const staged = expandStagedDir(dirRel);
71
+ // A dir present on disk but absent from the index is an already-dropped
72
+ // rerun: push the dir path itself so the per-entry isInIndex() guard
73
+ // logs it as "already absent" rather than letting an empty match set
74
+ // escalate to the no-match fatal (idempotency for dir-only sessions).
75
+ if (staged.length > 0) matches.push(...staged);
76
+ else matches.push(dirRel);
60
77
  }
61
78
  }
62
79
  if (matches.length === 0) {
63
80
  throw new NomadFatal(`no staged session matches ${id}`);
64
81
  }
65
- for (const m of matches) {
66
- const rel = relative(REPO_HOME, m);
82
+ for (const rel of matches) {
67
83
  // Pitfall 7: skip files that are not in the index at all (the
68
84
  // load-bearing guard for the idempotent second-run case, where the
69
85
  // first drop already removed the entry from the index but left the
@@ -120,6 +136,31 @@ export function cmdDropSession(id: string): void {
120
136
  }
121
137
  }
122
138
 
139
+ /**
140
+ * Expand a repo-relative directory into its staged entries via
141
+ * `git ls-files -z -- <dirRel>` (argv-array form, NUL-split for path
142
+ * safety). Returns repo-relative POSIX paths for every staged file under
143
+ * the directory, or an empty array when none are staged or `git` fails
144
+ * (missing/corrupt index); the caller then falls through to the existing
145
+ * per-entry idempotency guard rather than escalating to a FATAL.
146
+ *
147
+ * @param dirRel Repo-relative directory path (`shared/projects/<logical>/<id>`).
148
+ */
149
+ function expandStagedDir(dirRel: string): string[] {
150
+ try {
151
+ const out = execFileSync('git', ['ls-files', '-z', '--', dirRel], {
152
+ cwd: REPO_HOME,
153
+ stdio: ['ignore', 'pipe', 'pipe'],
154
+ });
155
+ return out
156
+ .toString()
157
+ .split('\0')
158
+ .filter((p) => p !== '');
159
+ } catch {
160
+ return [];
161
+ }
162
+ }
163
+
123
164
  /**
124
165
  * Is `rel` (repo-relative path) present in the HEAD tree? Wraps
125
166
  * `git cat-file -e HEAD:<rel>`: exit 0 means tracked in HEAD,
@@ -85,22 +85,28 @@ export function parsePorcelainZ(statusPorcelain: string): string[] {
85
85
  * Reject any staged path that is not on the push allow-list or that matches a
86
86
  * `NEVER_SYNC` entry. Builds the runtime allow-list by combining
87
87
  * `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
88
- * in `path-map.json` AND one `shared/extras/<logical>/<dirname>/` prefix per
89
- * (logical, whitelisted dirname) pair in `map.extras ?? {}` (Pitfall 4 closed:
90
- * data-driven, no hand-rolled bypass). The dirname filter (`SUPPORTED_EXTRAS`)
91
- * is the same one `remapExtrasPush` honors, so manually staged content under a
92
- * non-whitelisted dirname surfaces as a FATAL instead of riding through on the
93
- * logical-only prefix. Logs every violation as a FATAL line so the user sees
94
- * the full set (not just the first), then throws `NomadFatal` to unwind the
95
- * caller's try/finally and release the push lock.
88
+ * in `path-map.json` AND, per (logical, whitelisted name) pair in
89
+ * `map.extras ?? {}`, an exact `shared/extras/<logical>/<name>` entry plus a
90
+ * `shared/extras/<logical>/<name>/` prefix entry (Pitfall 4 closed:
91
+ * data-driven, no hand-rolled bypass). The exact entry permits the declared
92
+ * name when it is a single root file (e.g. `CLAUDE.md`); the prefix entry
93
+ * permits the declared name's subtree when it is a directory. Neither widens
94
+ * to a logical-only prefix, so an arbitrary sibling file under the same
95
+ * logical stays rejected. The name filter (`SUPPORTED_EXTRAS`) is the same one
96
+ * `remapExtrasPush` honors, so manually staged content under a non-whitelisted
97
+ * name surfaces as a FATAL instead of riding through. Logs every violation as
98
+ * a FATAL line so the user sees the full set (not just the first), then throws
99
+ * `NomadFatal` to unwind the caller's try/finally and release the push lock.
96
100
  */
97
101
  export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
98
102
  const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
99
103
  const allowed = [
100
104
  ...PUSH_ALLOWED_STATIC,
101
105
  ...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
102
- ...Object.entries(map.extras ?? {}).flatMap(([l, dirnames]) =>
103
- dirnames.filter((d) => extrasWhitelist.includes(d)).map((d) => `shared/extras/${l}/${d}/`),
106
+ ...Object.entries(map.extras ?? {}).flatMap(([l, names]) =>
107
+ names
108
+ .filter((n) => extrasWhitelist.includes(n))
109
+ .flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`]),
104
110
  ),
105
111
  ];
106
112
  const neverSyncHits: string[] = [];
@@ -193,7 +199,13 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
193
199
  }
194
200
  // Routed through the shell-free, untrimmed helper because `sh` would .trim()
195
201
  // the leading status-space and shift parsePorcelainZ's offsets.
196
- const status = gitStatusPorcelainZ(REPO_HOME);
202
+ // `untrackedAll` (issue #111): the allow-list runs on this snapshot BEFORE
203
+ // `git add -A`. Without it, a fresh host whose entire `shared/extras/`
204
+ // subtree is untracked yields a single collapsed `?? shared/extras/`
205
+ // record that the `shared/extras/<logical>/<dirname>/` child prefix cannot
206
+ // match, so the first extras push is rejected. Expanding to per-file paths
207
+ // lets the existing allow-list accept them while keeping the gate order.
208
+ const status = gitStatusPorcelainZ(REPO_HOME, { untrackedAll: true });
197
209
  if (!status) {
198
210
  log('nothing to commit');
199
211
  // Combine session-unmapped and extras-unmapped into one user-visible
@@ -3,6 +3,7 @@ import { closeSync, existsSync, openSync, readSync } from 'node:fs';
3
3
 
4
4
  import { cmdDoctor } from './commands.doctor.ts';
5
5
  import { REPO_HOME } from './config.ts';
6
+ import { commitRegeneratedLockfile, precommitForkExtras } from './update.fork-extras.ts';
6
7
  import { loadTopology } from './update.topology.ts';
7
8
  import { die, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, warn } from './utils.ts';
8
9
 
@@ -277,12 +278,21 @@ function tryAutoResolveMergeConflict(opts: CmdUpdateOpts): boolean {
277
278
  * pushes when the answer is `y` or `yes` (case-insensitive). Non-affirmative
278
279
  * answers skip the push and log a "run later" hint.
279
280
  *
281
+ * When the merge (and any extras precommit) leaves `HEAD` unchanged from
282
+ * `beforeSha`, there is nothing new to push: the function logs a one-line
283
+ * "already in sync" and returns without pushing or prompting (issue #66). An
284
+ * auto-resolved conflict always advances `HEAD` via its merge commit, so that
285
+ * path is never mistaken for a no-op.
286
+ *
280
287
  * @param opts - Update options; respected fields are:
281
288
  * - `dryRun`: when true, log actions instead of executing them
282
289
  * - `pushOrigin`: when true, push to `origin/main` without prompting
283
290
  * - `prompt`: optional prompt function used for interactive confirmation
291
+ * @param beforeSha - `HEAD` SHA captured before the fork update began; the
292
+ * post-merge `HEAD` is compared against it to detect a no-op. When omitted
293
+ * (dry-run preview) the no-op short-circuit is skipped.
284
294
  */
285
- function runFork(opts: CmdUpdateOpts): boolean {
295
+ function runFork(opts: CmdUpdateOpts, beforeSha?: string): boolean {
286
296
  const promptFn = opts.prompt ?? defaultPrompt;
287
297
  if (opts.dryRun === true) {
288
298
  log('DRY-RUN: would run `git fetch upstream`');
@@ -295,6 +305,10 @@ function runFork(opts: CmdUpdateOpts): boolean {
295
305
  return false;
296
306
  }
297
307
  gitOrFatal(['fetch', 'upstream'], 'git fetch upstream', REPO_HOME);
308
+ // Pre-commit whitelisted extras (issue #112): otherwise untracked
309
+ // shared/extras/ content that upstream also adds makes the merge abort
310
+ // pre-merge with no UU state, so the lone-lockfile auto-resolve never fires.
311
+ precommitForkExtras();
298
312
  let autoResolved = false;
299
313
  try {
300
314
  gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
@@ -302,6 +316,13 @@ function runFork(opts: CmdUpdateOpts): boolean {
302
316
  if (!tryAutoResolveMergeConflict(opts)) throw err;
303
317
  autoResolved = true;
304
318
  }
319
+ // No-op merge (and no extras precommit): HEAD never moved, so there is
320
+ // nothing new to push. A `beforeSha` of undefined (dry-run never reaches
321
+ // here) can never equal a real SHA, so the comparison is self-guarding.
322
+ if (headSha() === beforeSha) {
323
+ log('already in sync with origin/main, nothing to push');
324
+ return autoResolved;
325
+ }
305
326
  if (opts.pushOrigin === true) {
306
327
  gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
307
328
  return autoResolved;
@@ -376,8 +397,12 @@ export function cmdUpdate(opts: CmdUpdateOpts = {}): void {
376
397
  }
377
398
 
378
399
  const beforeSha = headSha();
379
- const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts);
400
+ const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts, beforeSha);
380
401
 
381
402
  if (!installAlreadyRan) reinstallIfNeeded(beforeSha);
403
+ // Secondary item of issue #112: a post-merge `npm install` that regenerated
404
+ // package-lock.json leaves uncommitted drift the trailing doctor flags.
405
+ // Commit just the lockfile (fork topology only) so the repo is clean.
406
+ if (topology === 'fork') commitRegeneratedLockfile();
382
407
  cmdDoctor();
383
408
  }
package/src/config.ts CHANGED
@@ -36,6 +36,17 @@ export const REPO_HOME = process.env.NOMAD_REPO || resolve(HOME, 'claude-nomad')
36
36
  */
37
37
  export const UPSTREAM_REPO_SLUG = 'funkadelic/claude-nomad';
38
38
 
39
+ /**
40
+ * Pinned gitleaks version. Single source of truth for the gitleaks pin used by
41
+ * `nomad doctor`'s version-drift check (`reportGitleaksVersionCheck`), which
42
+ * WARNs when the host's installed gitleaks major.minor diverges from this
43
+ * value. Mirrors the `GITLEAKS_VERSION` env in both `.github/workflows/tests.yml`
44
+ * and `.github/workflows/gitleaks.yml`; `config.gitleaks-pin.test.ts` asserts
45
+ * all three stay in lockstep so a CI bump that misses this constant (or vice
46
+ * versa) fails the suite. Bump here and in both workflow YAMLs together.
47
+ */
48
+ export const GITLEAKS_PINNED_VERSION = '8.30.1';
49
+
39
50
  /**
40
51
  * Resolved host identity used to pick `hosts/<HOST>.json` and key entries in
41
52
  * `path-map.json`. Reads `NOMAD_HOST` first, falls back to `hostname()`, then
@@ -58,16 +69,18 @@ export const SHARED_LINKS = [
58
69
  ] as const;
59
70
 
60
71
  /**
61
- * Whitelist of directory names allowed in `path-map.json`'s top-level
62
- * `extras` field. Gates the named-extras opt-in mechanism: only entries
63
- * appearing in this list are eligible for sync. Initial set contains
64
- * `.planning` only; widening to include `.notes`, `.scratch`, etc. is a
65
- * one-line edit here with no schema migration required (the field is
66
- * additive on the consumer side). Mirrors `SHARED_LINKS` in shape and
72
+ * Whitelist of names allowed in `path-map.json`'s top-level `extras` field.
73
+ * Each entry is either a directory name (e.g. `.planning`) OR a single
74
+ * root-level file name (e.g. `CLAUDE.md`); both are validated the same way
75
+ * and copied verbatim under `shared/extras/<logical>/<name>`. Gates the
76
+ * named-extras opt-in mechanism: only entries appearing in this list are
77
+ * eligible for sync. Widening to include `.notes`, `.scratch`, `AGENTS.md`,
78
+ * etc. is a one-line edit here with no schema migration required (the field
79
+ * is additive on the consumer side). Mirrors `SHARED_LINKS` in shape and
67
80
  * intent: a short, append-only `as const` tuple that downstream callers
68
81
  * narrow against.
69
82
  */
70
- export const SUPPORTED_EXTRAS = ['.planning'] as const;
83
+ export const SUPPORTED_EXTRAS = ['.planning', 'CLAUDE.md'] as const;
71
84
 
72
85
  /**
73
86
  * Path segments that must never cross the sync boundary in either direction.
@@ -159,9 +172,9 @@ export const PUSH_ALLOWED_STATIC = [
159
172
  * the literal string `'TBD'` as a placeholder while a host has not yet cloned
160
173
  * the project; `remapPull` / `remapPush` skip `'TBD'` entries.
161
174
  *
162
- * Optional `extras` field (additive, top-level): opt-in per-project
163
- * named-directory sync. Keyed by the same logical project name used in
164
- * `projects`; values are arrays of directory names validated by downstream
175
+ * Optional `extras` field (additive, top-level): opt-in per-project sync of
176
+ * named content. Keyed by the same logical project name used in `projects`;
177
+ * values are arrays of directory or root-file names validated by downstream
165
178
  * consumers against `SUPPORTED_EXTRAS`. Absence of the field is equivalent
166
179
  * to no extras for any project; legacy `path-map.json` files without an
167
180
  * `extras` block continue to work unchanged (no migration required).
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
3
3
  import { isAbsolute, join, normalize } from 'node:path';
4
4
 
5
- import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS } from './config.ts';
5
+ import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
6
6
  // prettier-ignore
7
7
  import { backupExtrasWrite, backupRepoWrite, encodePath, log, NomadFatal, readPathMap, warn } from './utils.ts';
8
8
 
@@ -61,6 +61,35 @@ export function copyExtras(src: string, dst: string): void {
61
61
  cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
62
62
  }
63
63
 
64
+ /**
65
+ * Repo-relative `shared/extras/<logical>/<dirname>` paths for every
66
+ * (logical, whitelisted dirname) pair declared in `map.extras`. This is the
67
+ * same prefix set the push allow-list permits (minus the trailing slash, so
68
+ * the values are usable directly as `git add` arguments). Used by the fork
69
+ * update path (issue #112) to pre-commit overlapping extras before
70
+ * `git merge upstream/main`, turning an untracked-overwrite abort into a
71
+ * tracked-file merge. Non-whitelisted dirnames are filtered out so manually
72
+ * staged content under a non-supported dirname is never auto-committed.
73
+ * Logical names are validated for path-traversal safety first, matching the
74
+ * `remapExtras*` contract.
75
+ *
76
+ * @param map - Parsed `path-map.json`. A missing `extras` key yields `[]`.
77
+ * @returns Sorted, de-duplicated repo-relative extras paths (no trailing slash).
78
+ */
79
+ export function whitelistedExtrasPaths(map: PathMap): string[] {
80
+ const extrasMap = map.extras ?? {};
81
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
82
+ const paths = new Set<string>();
83
+ for (const [logical, dirnames] of Object.entries(extrasMap)) {
84
+ assertSafeLogical(logical);
85
+ for (const dirname of dirnames) {
86
+ if (!whitelist.includes(dirname)) continue;
87
+ paths.add(`shared/extras/${logical}/${dirname}`);
88
+ }
89
+ }
90
+ return [...paths].sort((a, b) => a.localeCompare(b));
91
+ }
92
+
64
93
  /**
65
94
  * Push: copy whitelisted extras directories under each project's localRoot
66
95
  * into the repo at `shared/extras/<logical>/<dirname>/`. Returns