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.
- package/CHANGELOG.md +46 -0
- package/README.md +50 -46
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.ts +309 -0
- package/src/commands.doctor.checks.ts +9 -2
- package/src/commands.doctor.gitleaks-version.ts +132 -0
- package/src/commands.doctor.mirror-actions.ts +83 -0
- package/src/commands.doctor.ts +20 -3
- package/src/commands.drop-session.ts +60 -19
- package/src/commands.push.ts +23 -11
- package/src/commands.update.ts +27 -2
- package/src/config.ts +23 -10
- package/src/extras-sync.ts +30 -1
- package/src/nomad.ts +16 -5
- package/src/push-gitleaks.ts +18 -16
- package/src/remap.ts +1 -1
- package/src/update.fork-extras.ts +101 -0
- package/src/utils.ts +22 -2
|
@@ -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
|
-
/**
|
|
276
|
-
|
|
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
|
+
}
|
package/src/commands.doctor.ts
CHANGED
|
@@ -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
|
-
|
|
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/`.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
|
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,
|
package/src/commands.push.ts
CHANGED
|
@@ -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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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,
|
|
103
|
-
|
|
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
|
-
|
|
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
|
package/src/commands.update.ts
CHANGED
|
@@ -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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
164
|
-
*
|
|
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).
|
package/src/extras-sync.ts
CHANGED
|
@@ -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
|