claude-nomad 0.25.0 → 0.25.2
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 +19 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.scan.ts +158 -0
- package/src/commands.doctor.check-shared.ts +58 -189
- package/src/commands.doctor.checks.pathmap.ts +101 -0
- package/src/commands.doctor.checks.repo.ts +133 -0
- package/src/commands.doctor.checks.repository.ts +105 -0
- package/src/commands.doctor.checks.settings.ts +88 -0
- package/src/commands.doctor.format.ts +18 -0
- package/src/commands.doctor.ts +10 -7
- package/src/commands.drop-session.git.ts +81 -0
- package/src/commands.drop-session.ts +79 -138
- package/src/commands.pull.ts +3 -2
- package/src/commands.push.allowlist.ts +119 -0
- package/src/commands.push.ts +6 -121
- package/src/commands.update.git.ts +90 -0
- package/src/commands.update.resolve.ts +138 -0
- package/src/commands.update.test-helpers.git.ts +107 -0
- package/src/commands.update.ts +4 -221
- package/src/diff.ts +2 -1
- package/src/extras-sync.diff.ts +40 -0
- package/src/extras-sync.guards.ts +52 -0
- package/src/extras-sync.ts +146 -236
- package/src/init.classify.ts +1 -1
- package/src/init.snapshot.ts +3 -1
- package/src/init.ts +2 -1
- package/src/links.ts +3 -10
- package/src/nomad.dispatch.ts +25 -0
- package/src/nomad.help.ts +43 -0
- package/src/nomad.ts +6 -68
- package/src/preview.ts +2 -1
- package/src/push-gitleaks.scan.ts +115 -0
- package/src/push-gitleaks.ts +50 -106
- package/src/remap.ts +3 -1
- package/src/resume.ts +2 -1
- package/src/update.fork-extras.ts +2 -1
- package/src/utils.fs.ts +152 -0
- package/src/utils.json.ts +55 -0
- package/src/utils.lockfile.ts +168 -0
- package/src/utils.ts +0 -327
- package/src/commands.doctor.checks.ts +0 -350
|
@@ -3,23 +3,17 @@ 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';
|
|
6
|
-
import {
|
|
6
|
+
import { expandStagedDir, isInIndex, isTrackedInHead } from './commands.drop-session.git.ts';
|
|
7
|
+
import { die, fail, log, NomadFatal } from './utils.ts';
|
|
8
|
+
import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Surgical removal of a contaminated session from the staged tree of
|
|
10
|
-
* `~/claude-nomad/`.
|
|
11
|
-
*
|
|
12
|
-
* `<id>/` (
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* - tracked-in-HEAD -> `git restore --staged --worktree -- <rel>`
|
|
17
|
-
* - newly-staged -> `git rm --cached -f -- <rel>`
|
|
18
|
-
*
|
|
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.
|
|
12
|
+
* `~/claude-nomad/`. Thin orchestrator: validates the id, acquires the
|
|
13
|
+
* lock, collects every staged path matching the flat `<id>.jsonl` and the
|
|
14
|
+
* sibling subagent directory `<id>/` (via `collectMatches`), then unstages
|
|
15
|
+
* each (via `unstageOne`). This closes the leak where a "dropped" session
|
|
16
|
+
* still shipped its subagent transcripts.
|
|
23
17
|
*
|
|
24
18
|
* Idempotent: entries not in the index are skipped silently. Exits 0 on
|
|
25
19
|
* any drop (including an idempotent re-run); exits 1 with `✗ no staged
|
|
@@ -27,8 +21,8 @@ import { acquireLock, die, fail, log, NomadFatal, releaseLock } from './utils.ts
|
|
|
27
21
|
* `<id>/` directory with staged entries exists anywhere in the tree.
|
|
28
22
|
*
|
|
29
23
|
* Defense-in-depth: the id is validated against the same allowlist regex
|
|
30
|
-
* used in `src/resume.ts` before any path composition
|
|
31
|
-
* for every git invocation.
|
|
24
|
+
* used in `src/resume.ts` before any path composition or lock acquisition.
|
|
25
|
+
* argv-array form for every git invocation.
|
|
32
26
|
*
|
|
33
27
|
* NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl` or the local
|
|
34
28
|
* `<id>/` tree; the local copies are preserved so they race-safely
|
|
@@ -54,68 +48,11 @@ export function cmdDropSession(id: string): void {
|
|
|
54
48
|
if (!existsSync(repoProjects)) {
|
|
55
49
|
throw new NomadFatal(`no staged session matches ${id}`);
|
|
56
50
|
}
|
|
57
|
-
|
|
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.
|
|
61
|
-
const matches: string[] = [];
|
|
62
|
-
for (const logical of readdirSync(repoProjects)) {
|
|
63
|
-
const candidate = join(repoProjects, logical, `${id}.jsonl`);
|
|
64
|
-
if (existsSync(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);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
51
|
+
const matches = collectMatches(repoProjects, id);
|
|
79
52
|
if (matches.length === 0) {
|
|
80
53
|
throw new NomadFatal(`no staged session matches ${id}`);
|
|
81
54
|
}
|
|
82
|
-
for (const rel of matches)
|
|
83
|
-
// Pitfall 7: skip files that are not in the index at all (the
|
|
84
|
-
// load-bearing guard for the idempotent second-run case, where the
|
|
85
|
-
// first drop already removed the entry from the index but left the
|
|
86
|
-
// working tree file in place).
|
|
87
|
-
if (!isInIndex(rel)) {
|
|
88
|
-
log(`dropped ${rel} (already absent from index)`);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
if (isTrackedInHead(rel)) {
|
|
93
|
-
execFileSync('git', ['restore', '--staged', '--worktree', '--', rel], {
|
|
94
|
-
cwd: REPO_HOME,
|
|
95
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
-
});
|
|
97
|
-
} else {
|
|
98
|
-
execFileSync('git', ['rm', '--cached', '-f', '--', rel], {
|
|
99
|
-
cwd: REPO_HOME,
|
|
100
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
} catch (err) {
|
|
104
|
-
// Convert raw execFileSync failures (non-zero git exit, EACCES on
|
|
105
|
-
// .git/index, EPERM, etc.) into NomadFatal so the outer catch can
|
|
106
|
-
// emit a clean `✗ ...` line instead of letting the ExecException
|
|
107
|
-
// bubble past nomad.ts's NomadFatal-only dispatcher.
|
|
108
|
-
// The `?? err.message` fallback only fires when git fails without
|
|
109
|
-
// producing stderr (spawn-level error before the process emits
|
|
110
|
-
// anything), which `cmdPush`'s gitleaks probe already rules out
|
|
111
|
-
// for the typical install path. Excluded from coverage.
|
|
112
|
-
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
|
113
|
-
/* c8 ignore next */
|
|
114
|
-
const detail = e.stderr?.toString().trim() ?? e.message;
|
|
115
|
-
throw new NomadFatal(`git failed to unstage ${rel}: ${detail}`);
|
|
116
|
-
}
|
|
117
|
-
log(`dropped ${rel}`);
|
|
118
|
-
}
|
|
55
|
+
for (const rel of matches) unstageOne(rel);
|
|
119
56
|
} catch (err) {
|
|
120
57
|
// Defensive escape hatch: only fires if a non-NomadFatal error escapes
|
|
121
58
|
// the try block. All execFileSync mutation failures are wrapped in
|
|
@@ -137,78 +74,82 @@ export function cmdDropSession(id: string): void {
|
|
|
137
74
|
}
|
|
138
75
|
|
|
139
76
|
/**
|
|
140
|
-
*
|
|
141
|
-
* `
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* per-entry idempotency guard rather than escalating to a FATAL.
|
|
77
|
+
* Collect repo-relative staged paths matching the session `id`. For each
|
|
78
|
+
* `shared/projects/<logical>/` child, match the flat `<id>.jsonl` plus the
|
|
79
|
+
* sibling subagent directory `<id>/`. The directory is expanded into its
|
|
80
|
+
* staged entries via `expandStagedDir` so every nested file flows through
|
|
81
|
+
* the same per-entry unstage loop as the flat jsonl.
|
|
146
82
|
*
|
|
147
|
-
* @param
|
|
83
|
+
* @param repoProjects Absolute path to `<REPO_HOME>/shared/projects`.
|
|
84
|
+
* @param id Already-validated session id (see `cmdDropSession`'s entry guard).
|
|
85
|
+
* @returns Repo-relative paths to unstage (possibly empty).
|
|
148
86
|
*/
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
87
|
+
function collectMatches(repoProjects: string, id: string): string[] {
|
|
88
|
+
const matches: string[] = [];
|
|
89
|
+
for (const logical of readdirSync(repoProjects)) {
|
|
90
|
+
const candidate = join(repoProjects, logical, `${id}.jsonl`);
|
|
91
|
+
if (existsSync(candidate)) {
|
|
92
|
+
matches.push(relative(REPO_HOME, candidate));
|
|
93
|
+
}
|
|
94
|
+
const dir = join(repoProjects, logical, id);
|
|
95
|
+
if (existsSync(dir) && statSync(dir).isDirectory()) {
|
|
96
|
+
const dirRel = relative(REPO_HOME, dir);
|
|
97
|
+
const staged = expandStagedDir(dirRel);
|
|
98
|
+
// A dir present on disk but absent from the index is an already-dropped
|
|
99
|
+
// rerun: push the dir path itself so the per-entry isInIndex() guard
|
|
100
|
+
// logs it as "already absent" rather than letting an empty match set
|
|
101
|
+
// escalate to the no-match fatal (idempotency for dir-only sessions).
|
|
102
|
+
if (staged.length > 0) matches.push(...staged);
|
|
103
|
+
else matches.push(dirRel);
|
|
104
|
+
}
|
|
161
105
|
}
|
|
106
|
+
return matches;
|
|
162
107
|
}
|
|
163
108
|
|
|
164
109
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* the index too, which would misclassify newly-staged paths.
|
|
110
|
+
* Unstage one repo-relative path via the tracked-vs-newly-staged primitive.
|
|
111
|
+
* Skips paths absent from the index (Pitfall 7 idempotency guard), then
|
|
112
|
+
* classifies via `isTrackedInHead` and unstages with `git restore
|
|
113
|
+
* --staged --worktree` (tracked-in-HEAD) or `git rm --cached -f`
|
|
114
|
+
* (newly-staged). Git calls keep `execFileSync` argv-array form (PUSH-04).
|
|
171
115
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* (e.g., `.git/refs/heads/main` was deleted manually), and (c) the
|
|
175
|
-
* specific path simply does not exist in a valid HEAD. Git produces the
|
|
176
|
-
* same exit 128 and the same stderr (`fatal: invalid object name 'HEAD'`)
|
|
177
|
-
* for (a) and (b), so a probe-based distinction would require additional
|
|
178
|
-
* git-plumbing reads (`rev-parse --verify HEAD`, `.git/refs/heads/`
|
|
179
|
-
* inspection) that are brittle and break the empty-repo path every
|
|
180
|
-
* existing test runs through. The downstream `git rm --cached -f` is
|
|
181
|
-
* idempotent and produces the user-intended unstage outcome regardless
|
|
182
|
-
* of which case fired, so the collapsed return is intentional. Repo
|
|
183
|
-
* health belongs to `nomad doctor`, not drop-session.
|
|
116
|
+
* @param rel Repo-relative path to unstage.
|
|
117
|
+
* @throws NomadFatal when the underlying git invocation fails.
|
|
184
118
|
*/
|
|
185
|
-
function
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return false;
|
|
119
|
+
function unstageOne(rel: string): void {
|
|
120
|
+
// Pitfall 7: skip files that are not in the index at all (the
|
|
121
|
+
// load-bearing guard for the idempotent second-run case, where the
|
|
122
|
+
// first drop already removed the entry from the index but left the
|
|
123
|
+
// working tree file in place).
|
|
124
|
+
if (!isInIndex(rel)) {
|
|
125
|
+
log(`dropped ${rel} (already absent from index)`);
|
|
126
|
+
return;
|
|
194
127
|
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Is `rel` present in the index at all? Wraps `git ls-files -- <rel>` and
|
|
199
|
-
* checks for non-empty stdout. Required for the Pitfall 7 idempotency
|
|
200
|
-
* guard: a second invocation on the same id finds the file on disk (per
|
|
201
|
-
* `existsSync`) but absent from the index, and must NOT call `git rm
|
|
202
|
-
* --cached` on it (which would fail with exit 128).
|
|
203
|
-
*/
|
|
204
|
-
function isInIndex(rel: string): boolean {
|
|
205
128
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
129
|
+
if (isTrackedInHead(rel)) {
|
|
130
|
+
execFileSync('git', ['restore', '--staged', '--worktree', '--', rel], {
|
|
131
|
+
cwd: REPO_HOME,
|
|
132
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
133
|
+
});
|
|
134
|
+
} else {
|
|
135
|
+
execFileSync('git', ['rm', '--cached', '-f', '--', rel], {
|
|
136
|
+
cwd: REPO_HOME,
|
|
137
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// Convert raw execFileSync failures (non-zero git exit, EACCES on
|
|
142
|
+
// .git/index, EPERM, etc.) into NomadFatal so the outer catch can
|
|
143
|
+
// emit a clean `✗ ...` line instead of letting the ExecException
|
|
144
|
+
// bubble past nomad.ts's NomadFatal-only dispatcher.
|
|
145
|
+
// The `?? err.message` fallback only fires when git fails without
|
|
146
|
+
// producing stderr (spawn-level error before the process emits
|
|
147
|
+
// anything), which `cmdPush`'s gitleaks probe already rules out
|
|
148
|
+
// for the typical install path. Excluded from coverage.
|
|
149
|
+
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
|
150
|
+
/* c8 ignore next */
|
|
151
|
+
const detail = e.stderr?.toString().trim() ?? e.message;
|
|
152
|
+
throw new NomadFatal(`git failed to unstage ${rel}: ${detail}`);
|
|
213
153
|
}
|
|
154
|
+
log(`dropped ${rel}`);
|
|
214
155
|
}
|
package/src/commands.pull.ts
CHANGED
|
@@ -7,8 +7,9 @@ import { applySharedLinks, regenerateSettings } from './links.ts';
|
|
|
7
7
|
import { computePreview } from './preview.ts';
|
|
8
8
|
import { remapPull } from './remap.ts';
|
|
9
9
|
import { emitSummary } from './summary.ts';
|
|
10
|
-
|
|
11
|
-
import {
|
|
10
|
+
import { die, fail, gitOrFatal, log, NomadFatal } from './utils.ts';
|
|
11
|
+
import { freshBackupTs } from './utils.fs.ts';
|
|
12
|
+
import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `nomad pull` command. Acquires the push/pull lock, takes a backup
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { NEVER_SYNC, PUSH_ALLOWED_STATIC, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
|
|
2
|
+
import { fail, NomadFatal } from './utils.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Match `path` against an entry in the push allow-list. Exact match for
|
|
6
|
+
* non-`/`-terminated entries; prefix match for `/`-terminated entries; and
|
|
7
|
+
* a special case for `hosts/`: only `hosts/<name>.json` (single-level,
|
|
8
|
+
* `.json` extension) is allowed, so arbitrary credentials like
|
|
9
|
+
* `hosts/dell-wsl.key` are rejected even though they share the prefix.
|
|
10
|
+
*/
|
|
11
|
+
function isAllowed(path: string, allowed: readonly string[]): boolean {
|
|
12
|
+
for (const entry of allowed) {
|
|
13
|
+
if (path === entry) return true;
|
|
14
|
+
if (entry === 'hosts/') {
|
|
15
|
+
if (/^hosts\/[^/]+\.json$/.test(path)) return true;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (entry.endsWith('/') && path.startsWith(entry)) return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when any path segment matches a `NEVER_SYNC` entry (hard-block list).
|
|
25
|
+
* Scope exception (Pitfall 6): paths beginning with `shared/extras/` are
|
|
26
|
+
* exempt. The segment list was authored against `~/.claude/` semantics for
|
|
27
|
+
* ephemeral Claude Code state (`todos/`, `shell-snapshots/`, etc.); inside
|
|
28
|
+
* the extras tree, `.planning/todos/` is a meaningful GSD-managed path. The
|
|
29
|
+
* narrowed scope preserves the original hard-block for all other surface.
|
|
30
|
+
*/
|
|
31
|
+
function isNeverSync(path: string): boolean {
|
|
32
|
+
if (path.startsWith('shared/extras/')) return false;
|
|
33
|
+
for (const segment of path.split('/')) {
|
|
34
|
+
if (NEVER_SYNC.has(segment)) return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse `git status --porcelain=v1 -z` (NUL-delimited) output into a flat
|
|
41
|
+
* list of paths. Handles rename (`R`) and copy (`C`) records, which span
|
|
42
|
+
* two NUL fields (`XY new\0old\0`): both halves are returned so the
|
|
43
|
+
* allow-list can reject either side. `-z` avoids the quoting that LF
|
|
44
|
+
* porcelain applies to paths containing spaces or specials, which would
|
|
45
|
+
* otherwise cause parser misclassification.
|
|
46
|
+
*/
|
|
47
|
+
export function parsePorcelainZ(statusPorcelain: string): string[] {
|
|
48
|
+
const records = statusPorcelain.split('\0');
|
|
49
|
+
const paths: string[] = [];
|
|
50
|
+
for (let i = 0; i < records.length; i++) {
|
|
51
|
+
const rec = records[i];
|
|
52
|
+
if (rec === undefined || rec === '') continue;
|
|
53
|
+
// Each record starts with "XY " (2 status chars + 1 space). The path is
|
|
54
|
+
// everything after byte 3. For R/C the NEXT record holds the old path.
|
|
55
|
+
if (rec.length < 4) continue;
|
|
56
|
+
const xy = rec.slice(0, 2);
|
|
57
|
+
const newPath = rec.slice(3);
|
|
58
|
+
paths.push(newPath);
|
|
59
|
+
// Check BOTH XY positions: X is the index status, Y is the working-tree
|
|
60
|
+
// status. Either can carry R (rename) or C (copy), and the old-path record
|
|
61
|
+
// follows the new-path record in -z porcelain regardless of which column
|
|
62
|
+
// detected the rename. Missing the Y-column case (e.g. ` R`) would skip
|
|
63
|
+
// the consume and let the next iteration misread the old path as a new
|
|
64
|
+
// record, smuggling unallowed sources past the allow-list.
|
|
65
|
+
if (/[RC]/.test(xy)) {
|
|
66
|
+
const oldPath = records[i + 1];
|
|
67
|
+
if (oldPath !== undefined && oldPath !== '') paths.push(oldPath);
|
|
68
|
+
i++; // consume the paired old-path record
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return paths;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Reject any staged path that is not on the push allow-list or that matches a
|
|
76
|
+
* `NEVER_SYNC` entry. Builds the runtime allow-list by combining
|
|
77
|
+
* `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
|
|
78
|
+
* in `path-map.json` AND, per (logical, whitelisted name) pair in
|
|
79
|
+
* `map.extras ?? {}`, an exact `shared/extras/<logical>/<name>` entry plus a
|
|
80
|
+
* `shared/extras/<logical>/<name>/` prefix entry (Pitfall 4 closed:
|
|
81
|
+
* data-driven, no hand-rolled bypass). The exact entry permits the declared
|
|
82
|
+
* name when it is a single root file (e.g. `CLAUDE.md`); the prefix entry
|
|
83
|
+
* permits the declared name's subtree when it is a directory. Neither widens
|
|
84
|
+
* to a logical-only prefix, so an arbitrary sibling file under the same
|
|
85
|
+
* logical stays rejected. The name filter (`SUPPORTED_EXTRAS`) is the same one
|
|
86
|
+
* `remapExtrasPush` honors, so manually staged content under a non-whitelisted
|
|
87
|
+
* name surfaces as a FATAL instead of riding through. Logs every violation as
|
|
88
|
+
* a FATAL line so the user sees the full set (not just the first), then throws
|
|
89
|
+
* `NomadFatal` to unwind the caller's try/finally and release the push lock.
|
|
90
|
+
*/
|
|
91
|
+
export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
|
|
92
|
+
const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
93
|
+
const allowed = [
|
|
94
|
+
...PUSH_ALLOWED_STATIC,
|
|
95
|
+
...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
|
|
96
|
+
...Object.entries(map.extras ?? {}).flatMap(([l, names]) =>
|
|
97
|
+
names
|
|
98
|
+
.filter((n) => extrasWhitelist.includes(n))
|
|
99
|
+
.flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`]),
|
|
100
|
+
),
|
|
101
|
+
];
|
|
102
|
+
const neverSyncHits: string[] = [];
|
|
103
|
+
const violations: string[] = [];
|
|
104
|
+
for (const path of parsePorcelainZ(statusPorcelain)) {
|
|
105
|
+
if (isNeverSync(path)) {
|
|
106
|
+
neverSyncHits.push(path);
|
|
107
|
+
} else if (!isAllowed(path, allowed)) {
|
|
108
|
+
violations.push(path);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (neverSyncHits.length === 0 && violations.length === 0) return;
|
|
112
|
+
for (const p of neverSyncHits) {
|
|
113
|
+
fail(`${p} is in NEVER_SYNC and must never be pushed`);
|
|
114
|
+
}
|
|
115
|
+
for (const p of violations) {
|
|
116
|
+
fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
|
|
117
|
+
}
|
|
118
|
+
throw new NomadFatal('push allow-list violations');
|
|
119
|
+
}
|
package/src/commands.push.ts
CHANGED
|
@@ -1,132 +1,17 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
import {
|
|
4
|
+
import { HOME, HOST, REPO_HOME } from './config.ts';
|
|
5
|
+
import { enforceAllowList } from './commands.push.allowlist.ts';
|
|
6
6
|
import { remapExtrasPush } from './extras-sync.ts';
|
|
7
7
|
import { findGitlinks, probeGitleaks, rebaseBeforePush } from './push-checks.ts';
|
|
8
8
|
import { runGitleaksScan } from './push-gitleaks.ts';
|
|
9
9
|
import { remapPush } from './remap.ts';
|
|
10
10
|
import { emitSummary } from './summary.ts';
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* Match `path` against an entry in the push allow-list. Exact match for
|
|
16
|
-
* non-`/`-terminated entries; prefix match for `/`-terminated entries; and
|
|
17
|
-
* a special case for `hosts/`: only `hosts/<name>.json` (single-level,
|
|
18
|
-
* `.json` extension) is allowed, so arbitrary credentials like
|
|
19
|
-
* `hosts/dell-wsl.key` are rejected even though they share the prefix.
|
|
20
|
-
*/
|
|
21
|
-
function isAllowed(path: string, allowed: readonly string[]): boolean {
|
|
22
|
-
for (const entry of allowed) {
|
|
23
|
-
if (path === entry) return true;
|
|
24
|
-
if (entry === 'hosts/') {
|
|
25
|
-
if (/^hosts\/[^/]+\.json$/.test(path)) return true;
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
if (entry.endsWith('/') && path.startsWith(entry)) return true;
|
|
29
|
-
}
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* True when any path segment matches a `NEVER_SYNC` entry (hard-block list).
|
|
35
|
-
* Scope exception (Pitfall 6): paths beginning with `shared/extras/` are
|
|
36
|
-
* exempt. The segment list was authored against `~/.claude/` semantics for
|
|
37
|
-
* ephemeral Claude Code state (`todos/`, `shell-snapshots/`, etc.); inside
|
|
38
|
-
* the extras tree, `.planning/todos/` is a meaningful GSD-managed path. The
|
|
39
|
-
* narrowed scope preserves the original hard-block for all other surface.
|
|
40
|
-
*/
|
|
41
|
-
function isNeverSync(path: string): boolean {
|
|
42
|
-
if (path.startsWith('shared/extras/')) return false;
|
|
43
|
-
for (const segment of path.split('/')) {
|
|
44
|
-
if (NEVER_SYNC.has(segment)) return true;
|
|
45
|
-
}
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Parse `git status --porcelain=v1 -z` (NUL-delimited) output into a flat
|
|
51
|
-
* list of paths. Handles rename (`R`) and copy (`C`) records, which span
|
|
52
|
-
* two NUL fields (`XY new\0old\0`): both halves are returned so the
|
|
53
|
-
* allow-list can reject either side. `-z` avoids the quoting that LF
|
|
54
|
-
* porcelain applies to paths containing spaces or specials, which would
|
|
55
|
-
* otherwise cause parser misclassification.
|
|
56
|
-
*/
|
|
57
|
-
export function parsePorcelainZ(statusPorcelain: string): string[] {
|
|
58
|
-
const records = statusPorcelain.split('\0');
|
|
59
|
-
const paths: string[] = [];
|
|
60
|
-
for (let i = 0; i < records.length; i++) {
|
|
61
|
-
const rec = records[i];
|
|
62
|
-
if (rec === undefined || rec === '') continue;
|
|
63
|
-
// Each record starts with "XY " (2 status chars + 1 space). The path is
|
|
64
|
-
// everything after byte 3. For R/C the NEXT record holds the old path.
|
|
65
|
-
if (rec.length < 4) continue;
|
|
66
|
-
const xy = rec.slice(0, 2);
|
|
67
|
-
const newPath = rec.slice(3);
|
|
68
|
-
paths.push(newPath);
|
|
69
|
-
// Check BOTH XY positions: X is the index status, Y is the working-tree
|
|
70
|
-
// status. Either can carry R (rename) or C (copy), and the old-path record
|
|
71
|
-
// follows the new-path record in -z porcelain regardless of which column
|
|
72
|
-
// detected the rename. Missing the Y-column case (e.g. ` R`) would skip
|
|
73
|
-
// the consume and let the next iteration misread the old path as a new
|
|
74
|
-
// record, smuggling unallowed sources past the allow-list.
|
|
75
|
-
if (/[RC]/.test(xy)) {
|
|
76
|
-
const oldPath = records[i + 1];
|
|
77
|
-
if (oldPath !== undefined && oldPath !== '') paths.push(oldPath);
|
|
78
|
-
i++; // consume the paired old-path record
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return paths;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Reject any staged path that is not on the push allow-list or that matches a
|
|
86
|
-
* `NEVER_SYNC` entry. Builds the runtime allow-list by combining
|
|
87
|
-
* `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
|
|
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.
|
|
100
|
-
*/
|
|
101
|
-
export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
|
|
102
|
-
const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
103
|
-
const allowed = [
|
|
104
|
-
...PUSH_ALLOWED_STATIC,
|
|
105
|
-
...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
|
|
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}/`]),
|
|
110
|
-
),
|
|
111
|
-
];
|
|
112
|
-
const neverSyncHits: string[] = [];
|
|
113
|
-
const violations: string[] = [];
|
|
114
|
-
for (const path of parsePorcelainZ(statusPorcelain)) {
|
|
115
|
-
if (isNeverSync(path)) {
|
|
116
|
-
neverSyncHits.push(path);
|
|
117
|
-
} else if (!isAllowed(path, allowed)) {
|
|
118
|
-
violations.push(path);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (neverSyncHits.length === 0 && violations.length === 0) return;
|
|
122
|
-
for (const p of neverSyncHits) {
|
|
123
|
-
fail(`${p} is in NEVER_SYNC and must never be pushed`);
|
|
124
|
-
}
|
|
125
|
-
for (const p of violations) {
|
|
126
|
-
fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
|
|
127
|
-
}
|
|
128
|
-
throw new NomadFatal('push allow-list violations');
|
|
129
|
-
}
|
|
11
|
+
import { die, fail, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal } from './utils.ts';
|
|
12
|
+
import { freshBackupTs } from './utils.fs.ts';
|
|
13
|
+
import { readPathMap } from './utils.json.ts';
|
|
14
|
+
import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
130
15
|
|
|
131
16
|
/**
|
|
132
17
|
* `nomad push` command. Acquires the lock, runs the four pre-push safety
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { REPO_HOME } from './config.ts';
|
|
4
|
+
import { log, NomadFatal } from './utils.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the current Git branch name for the repository at REPO_HOME.
|
|
8
|
+
*
|
|
9
|
+
* Wraps the failure path so a corrupt or missing `.git` directory surfaces as
|
|
10
|
+
* ``✗ ...`` via the top-level dispatcher's `NomadFatal` catch
|
|
11
|
+
* rather than a raw `ExecException` stack trace.
|
|
12
|
+
*
|
|
13
|
+
* @returns The current branch name (trimmed).
|
|
14
|
+
* @throws NomadFatal when the git command fails; if the command produced stderr, that stderr is written to process.stderr before the exception is thrown.
|
|
15
|
+
*/
|
|
16
|
+
export function currentBranch(): string {
|
|
17
|
+
try {
|
|
18
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
19
|
+
cwd: REPO_HOME,
|
|
20
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
21
|
+
})
|
|
22
|
+
.toString()
|
|
23
|
+
.trim();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const e = err as Error & { stderr?: Buffer };
|
|
26
|
+
if (e.stderr) process.stderr.write(e.stderr);
|
|
27
|
+
throw new NomadFatal('git rev-parse --abbrev-ref HEAD failed');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read and return the current `HEAD` commit SHA from the repository.
|
|
33
|
+
*
|
|
34
|
+
* Used to pin the pre-update commit so the post-update lockfile diff is
|
|
35
|
+
* exact regardless of whether the pull was a fast-forward, a no-op, or a
|
|
36
|
+
* merge. `HEAD@{1}` is unreliable here: a no-op `git pull --ff-only` does
|
|
37
|
+
* not always write a reflog entry, and a freshly cloned repo has no
|
|
38
|
+
* `HEAD@{1}` at all.
|
|
39
|
+
*
|
|
40
|
+
* @returns The `HEAD` commit SHA as a trimmed string.
|
|
41
|
+
* @throws NomadFatal if `git rev-parse HEAD` fails (stderr is written to stderr when present).
|
|
42
|
+
*/
|
|
43
|
+
export function headSha(): string {
|
|
44
|
+
try {
|
|
45
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
46
|
+
cwd: REPO_HOME,
|
|
47
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
+
})
|
|
49
|
+
.toString()
|
|
50
|
+
.trim();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const e = err as Error & { stderr?: Buffer };
|
|
53
|
+
if (e.stderr) process.stderr.write(e.stderr);
|
|
54
|
+
throw new NomadFatal('git rev-parse HEAD failed');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* List files changed between the given commit and the current HEAD.
|
|
60
|
+
*
|
|
61
|
+
* @param beforeSha - Commit SHA to compare against HEAD
|
|
62
|
+
* @returns An array of file paths changed between `beforeSha` and `HEAD`; an empty array if there are no changes
|
|
63
|
+
*/
|
|
64
|
+
export function changedFilesSince(beforeSha: string): string[] {
|
|
65
|
+
const out = execFileSync('git', ['diff', '--name-only', `${beforeSha}..HEAD`], {
|
|
66
|
+
cwd: REPO_HOME,
|
|
67
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
68
|
+
}).toString();
|
|
69
|
+
return out.split('\n').filter((line) => line !== '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run `npm install` in the repository only if `package-lock.json` changed since a given commit.
|
|
74
|
+
*
|
|
75
|
+
* If `package-lock.json` did not change between `beforeSha` and `HEAD`, logs
|
|
76
|
+
* a skip message; otherwise runs `npm install` with working directory set to
|
|
77
|
+
* `REPO_HOME`. Routing through `execFileSync` (no shell) keeps the call
|
|
78
|
+
* mockable in tests and prevents any chance of argv injection.
|
|
79
|
+
*
|
|
80
|
+
* @param beforeSha - Commit SHA to compare against `HEAD` when determining whether the lockfile changed
|
|
81
|
+
*/
|
|
82
|
+
export function reinstallIfNeeded(beforeSha: string): void {
|
|
83
|
+
const changed = changedFilesSince(beforeSha);
|
|
84
|
+
if (!changed.includes('package-lock.json')) {
|
|
85
|
+
log('skipping npm install (lockfile unchanged)');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
log('package-lock.json changed, running npm install');
|
|
89
|
+
execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
|
|
90
|
+
}
|