claude-nomad 0.17.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/.gitleaks.toml +16 -0
- package/CHANGELOG.md +293 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/package.json +79 -0
- package/shared/.gitignore +8 -0
- package/src/color.ts +81 -0
- package/src/commands.doctor.checks.ts +343 -0
- package/src/commands.doctor.format.ts +68 -0
- package/src/commands.doctor.ts +56 -0
- package/src/commands.doctor.version.ts +190 -0
- package/src/commands.drop-session.ts +173 -0
- package/src/commands.pull.ts +88 -0
- package/src/commands.push.ts +215 -0
- package/src/commands.update.ts +279 -0
- package/src/config.ts +149 -0
- package/src/diff.ts +49 -0
- package/src/init.snapshot.ts +53 -0
- package/src/init.ts +190 -0
- package/src/links.ts +123 -0
- package/src/nomad.ts +227 -0
- package/src/preview.ts +141 -0
- package/src/push-checks.ts +170 -0
- package/src/push-gitleaks.ts +217 -0
- package/src/remap.ts +158 -0
- package/src/resume.ts +159 -0
- package/src/summary.ts +43 -0
- package/src/update.topology.ts +118 -0
- package/src/utils.ts +368 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { REPO_HOME } from './config.ts';
|
|
6
|
+
import { acquireLock, die, fail, log, NomadFatal, releaseLock } from './utils.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
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:
|
|
13
|
+
*
|
|
14
|
+
* - tracked-in-HEAD -> `git restore --staged --worktree -- <rel>`
|
|
15
|
+
* - newly-staged -> `git rm --cached -f -- <rel>`
|
|
16
|
+
*
|
|
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.
|
|
22
|
+
*
|
|
23
|
+
* Defense-in-depth: the id is validated against the same allowlist regex
|
|
24
|
+
* used in `src/resume.ts` before any path composition. argv-array form
|
|
25
|
+
* for every git invocation.
|
|
26
|
+
*
|
|
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.
|
|
31
|
+
*
|
|
32
|
+
* @param id Session id (filename minus `.jsonl`). Must match `[A-Za-z0-9_-]+`
|
|
33
|
+
* with length 1..128.
|
|
34
|
+
* @throws NomadFatal when a lower-level helper translates a git or
|
|
35
|
+
* filesystem failure into a fatal. Caught by the try/catch wrapper
|
|
36
|
+
* which routes it to stderr and sets `process.exitCode = 1`.
|
|
37
|
+
*/
|
|
38
|
+
export function cmdDropSession(id: string): void {
|
|
39
|
+
if (id.length === 0 || id.length > 128 || !/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
40
|
+
fail(`invalid session id: ${id}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
|
|
44
|
+
|
|
45
|
+
const handle = acquireLock('drop-session');
|
|
46
|
+
if (handle === null) process.exit(0);
|
|
47
|
+
try {
|
|
48
|
+
const repoProjects = join(REPO_HOME, 'shared', 'projects');
|
|
49
|
+
if (!existsSync(repoProjects)) {
|
|
50
|
+
throw new NomadFatal(`no staged session matches ${id}`);
|
|
51
|
+
}
|
|
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.
|
|
55
|
+
const matches: string[] = [];
|
|
56
|
+
for (const logical of readdirSync(repoProjects)) {
|
|
57
|
+
const candidate = join(repoProjects, logical, `${id}.jsonl`);
|
|
58
|
+
if (existsSync(candidate)) {
|
|
59
|
+
matches.push(candidate);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (matches.length === 0) {
|
|
63
|
+
throw new NomadFatal(`no staged session matches ${id}`);
|
|
64
|
+
}
|
|
65
|
+
for (const m of matches) {
|
|
66
|
+
const rel = relative(REPO_HOME, m);
|
|
67
|
+
// Pitfall 7: skip files that are not in the index at all (the
|
|
68
|
+
// load-bearing guard for the idempotent second-run case, where the
|
|
69
|
+
// first drop already removed the entry from the index but left the
|
|
70
|
+
// working tree file in place).
|
|
71
|
+
if (!isInIndex(rel)) {
|
|
72
|
+
log(`dropped ${rel} (already absent from index)`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
if (isTrackedInHead(rel)) {
|
|
77
|
+
execFileSync('git', ['restore', '--staged', '--worktree', '--', rel], {
|
|
78
|
+
cwd: REPO_HOME,
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
execFileSync('git', ['rm', '--cached', '-f', '--', rel], {
|
|
83
|
+
cwd: REPO_HOME,
|
|
84
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// Convert raw execFileSync failures (non-zero git exit, EACCES on
|
|
89
|
+
// .git/index, EPERM, etc.) into NomadFatal so the outer catch can
|
|
90
|
+
// emit a clean `✗ ...` line instead of letting the ExecException
|
|
91
|
+
// bubble past nomad.ts's NomadFatal-only dispatcher.
|
|
92
|
+
// The `?? err.message` fallback only fires when git fails without
|
|
93
|
+
// producing stderr (spawn-level error before the process emits
|
|
94
|
+
// anything), which `cmdPush`'s gitleaks probe already rules out
|
|
95
|
+
// for the typical install path. Excluded from coverage.
|
|
96
|
+
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
|
97
|
+
/* c8 ignore next */
|
|
98
|
+
const detail = e.stderr?.toString().trim() ?? e.message;
|
|
99
|
+
throw new NomadFatal(`git failed to unstage ${rel}: ${detail}`);
|
|
100
|
+
}
|
|
101
|
+
log(`dropped ${rel}`);
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Defensive escape hatch: only fires if a non-NomadFatal error escapes
|
|
105
|
+
// the try block. All execFileSync mutation failures are wrapped in
|
|
106
|
+
// NomadFatal above, file-system helpers swallow their own errors, and
|
|
107
|
+
// readdirSync only throws under a race we do not handle. Excluded from
|
|
108
|
+
// coverage rather than contorting tests to fake an impossible state.
|
|
109
|
+
/* c8 ignore next 3 */
|
|
110
|
+
if (!(err instanceof NomadFatal)) {
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
// All NomadFatal paths surfaced here are exit-1 conditions (no staged
|
|
114
|
+
// session matches the id, git mutation failed, etc.); the red `✗`
|
|
115
|
+
// glyph carries the severity uniformly.
|
|
116
|
+
fail(err.message);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
} finally {
|
|
119
|
+
releaseLock(handle);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Is `rel` (repo-relative path) present in the HEAD tree? Wraps
|
|
125
|
+
* `git cat-file -e HEAD:<rel>`: exit 0 means tracked in HEAD,
|
|
126
|
+
* non-zero means either no HEAD exists yet (empty repo) or the path is
|
|
127
|
+
* only in the index (newly-staged-not-in-HEAD). `git ls-files
|
|
128
|
+
* --error-unmatch` is NOT a HEAD-presence check; it matches anything in
|
|
129
|
+
* the index too, which would misclassify newly-staged paths.
|
|
130
|
+
*
|
|
131
|
+
* The catch deliberately collapses three cases to `false`: (a) HEAD has
|
|
132
|
+
* no commit yet (fresh `git init`), (b) HEAD is unresolvable / corrupt
|
|
133
|
+
* (e.g., `.git/refs/heads/main` was deleted manually), and (c) the
|
|
134
|
+
* specific path simply does not exist in a valid HEAD. Git produces the
|
|
135
|
+
* same exit 128 and the same stderr (`fatal: invalid object name 'HEAD'`)
|
|
136
|
+
* for (a) and (b), so a probe-based distinction would require additional
|
|
137
|
+
* git-plumbing reads (`rev-parse --verify HEAD`, `.git/refs/heads/`
|
|
138
|
+
* inspection) that are brittle and break the empty-repo path every
|
|
139
|
+
* existing test runs through. The downstream `git rm --cached -f` is
|
|
140
|
+
* idempotent and produces the user-intended unstage outcome regardless
|
|
141
|
+
* of which case fired, so the collapsed return is intentional. Repo
|
|
142
|
+
* health belongs to `nomad doctor`, not drop-session.
|
|
143
|
+
*/
|
|
144
|
+
function isTrackedInHead(rel: string): boolean {
|
|
145
|
+
try {
|
|
146
|
+
execFileSync('git', ['cat-file', '-e', `HEAD:${rel}`], {
|
|
147
|
+
cwd: REPO_HOME,
|
|
148
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
149
|
+
});
|
|
150
|
+
return true;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Is `rel` present in the index at all? Wraps `git ls-files -- <rel>` and
|
|
158
|
+
* checks for non-empty stdout. Required for the Pitfall 7 idempotency
|
|
159
|
+
* guard: a second invocation on the same id finds the file on disk (per
|
|
160
|
+
* `existsSync`) but absent from the index, and must NOT call `git rm
|
|
161
|
+
* --cached` on it (which would fail with exit 128).
|
|
162
|
+
*/
|
|
163
|
+
function isInIndex(rel: string): boolean {
|
|
164
|
+
try {
|
|
165
|
+
const out = execFileSync('git', ['ls-files', '--', rel], {
|
|
166
|
+
cwd: REPO_HOME,
|
|
167
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
168
|
+
});
|
|
169
|
+
return out.toString().trim() !== '';
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { HOME, HOST, REPO_HOME } from './config.ts';
|
|
5
|
+
import { applySharedLinks, regenerateSettings } from './links.ts';
|
|
6
|
+
import { computePreview } from './preview.ts';
|
|
7
|
+
import { remapPull } from './remap.ts';
|
|
8
|
+
import { emitSummary } from './summary.ts';
|
|
9
|
+
// prettier-ignore
|
|
10
|
+
import { acquireLock, die, fail, freshBackupTs, gitOrFatal, log, NomadFatal, releaseLock } from './utils.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `nomad pull` command. Acquires the push/pull lock, takes a backup
|
|
14
|
+
* timestamp, runs `git pull --rebase --autostash` in `REPO_HOME`, then
|
|
15
|
+
* applies the three side-effecting sync steps in order:
|
|
16
|
+
* 1. `applySharedLinks` (symlink shared/* into ~/.claude/)
|
|
17
|
+
* 2. `regenerateSettings` (deep-merge base + host-override into settings.json)
|
|
18
|
+
* 3. `remapPull` (copy repo-side session transcripts into host-encoded dirs)
|
|
19
|
+
*
|
|
20
|
+
* `opts.dryRun` (default `false`): when `true`, the lock IS still acquired
|
|
21
|
+
* and `git pull --rebase` still runs (so concurrent invocations cannot race
|
|
22
|
+
* and so the user sees the same network round-trip behavior they would on a
|
|
23
|
+
* real pull). Then `computePreview` runs in place of the three mutating
|
|
24
|
+
* steps. The per-run backup directory under
|
|
25
|
+
* `~/.cache/claude-nomad/backup/<ts>/` is intentionally NOT created (no
|
|
26
|
+
* backups are written under dryRun and an empty dir would pollute the cache).
|
|
27
|
+
*
|
|
28
|
+
* Any `NomadFatal` thrown along the way is caught here so the `finally` block
|
|
29
|
+
* releases the lock before exit (a raw `process.exit()` would skip `finally`
|
|
30
|
+
* and leak the lock, see `NomadFatal` JSDoc). Non-fatal errors rethrow.
|
|
31
|
+
*/
|
|
32
|
+
export function cmdPull(opts: { dryRun?: boolean } = {}): void {
|
|
33
|
+
const dryRun = opts.dryRun === true;
|
|
34
|
+
if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
|
|
35
|
+
// Fire the init-hint FATAL BEFORE acquireLock so an
|
|
36
|
+
// unscaffolded repo never creates a lock file. Keyed off the same signal
|
|
37
|
+
// regenerateSettings uses (shared/settings.base.json), so the two entry
|
|
38
|
+
// points share one phrasing instead of diverging on edits.
|
|
39
|
+
if (!existsSync(join(REPO_HOME, 'shared', 'settings.base.json'))) {
|
|
40
|
+
die("repo not initialized; run 'nomad init' to scaffold");
|
|
41
|
+
}
|
|
42
|
+
const handle = acquireLock('pull');
|
|
43
|
+
if (handle === null) process.exit(0);
|
|
44
|
+
try {
|
|
45
|
+
// Collision-resistant ts: nowTimestamp() is second-resolution, so two
|
|
46
|
+
// pulls in the same wall-clock second would share `ts` and the second's
|
|
47
|
+
// backupBeforeWrite calls (cpSync force:false) would silently no-op.
|
|
48
|
+
const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
|
|
49
|
+
const ts = freshBackupTs(backupBase);
|
|
50
|
+
if (!dryRun) {
|
|
51
|
+
// Fail-fast: create backup root BEFORE any mutation. If mkdir fails
|
|
52
|
+
// (out of disk, permission denied), die() throws (NomadFatal) and the
|
|
53
|
+
// outer catch logs + sets exitCode, then finally releases the lock.
|
|
54
|
+
// Skipped under dryRun: no backups are written, and an empty
|
|
55
|
+
// backup-root dir would pollute the cache.
|
|
56
|
+
const backupRoot = join(backupBase, ts);
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(backupRoot, { recursive: true });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
die(`could not create backup dir: ${(err as Error).message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
log(`pulling on host=${HOST} (backup=${ts}${dryRun ? '; dry-run' : ''})`);
|
|
64
|
+
gitOrFatal(['pull', '--rebase', '--autostash'], 'git pull --rebase', REPO_HOME);
|
|
65
|
+
if (dryRun) {
|
|
66
|
+
const previewResult = computePreview(ts);
|
|
67
|
+
log('dry-run complete; no mutation');
|
|
68
|
+
emitSummary('pull', previewResult.unmapped);
|
|
69
|
+
} else {
|
|
70
|
+
applySharedLinks(ts);
|
|
71
|
+
regenerateSettings(ts);
|
|
72
|
+
const remapResult = remapPull(ts);
|
|
73
|
+
log('pull complete');
|
|
74
|
+
emitSummary('pull', remapResult.unmapped);
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// Catch fatal errors here so the finally block runs and releases the
|
|
78
|
+
// lock. Throwing through process.exit() would skip finally.
|
|
79
|
+
if (err instanceof NomadFatal) {
|
|
80
|
+
fail(err.message);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
} else {
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
releaseLock(handle);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// prettier-ignore
|
|
5
|
+
import { HOME, HOST, NEVER_SYNC, PUSH_ALLOWED_STATIC, REPO_HOME, type PathMap } from './config.ts';
|
|
6
|
+
import { findGitlinks, probeGitleaks, rebaseBeforePush } from './push-checks.ts';
|
|
7
|
+
import { runGitleaksScan } from './push-gitleaks.ts';
|
|
8
|
+
import { remapPush } from './remap.ts';
|
|
9
|
+
import { emitSummary } from './summary.ts';
|
|
10
|
+
// prettier-ignore
|
|
11
|
+
import { acquireLock, die, fail, freshBackupTs, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, readJson, releaseLock } from './utils.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Match `path` against an entry in the push allow-list. Exact match for
|
|
15
|
+
* non-`/`-terminated entries; prefix match for `/`-terminated entries; and
|
|
16
|
+
* a special case for `hosts/`: only `hosts/<name>.json` (single-level,
|
|
17
|
+
* `.json` extension) is allowed, so arbitrary credentials like
|
|
18
|
+
* `hosts/dell-wsl.key` are rejected even though they share the prefix.
|
|
19
|
+
*/
|
|
20
|
+
function isAllowed(path: string, allowed: readonly string[]): boolean {
|
|
21
|
+
for (const entry of allowed) {
|
|
22
|
+
if (path === entry) return true;
|
|
23
|
+
if (entry === 'hosts/') {
|
|
24
|
+
if (/^hosts\/[^/]+\.json$/.test(path)) return true;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (entry.endsWith('/') && path.startsWith(entry)) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when any path segment matches a `NEVER_SYNC` entry (hard-block list). */
|
|
33
|
+
function isNeverSync(path: string): boolean {
|
|
34
|
+
for (const segment of path.split('/')) {
|
|
35
|
+
if (NEVER_SYNC.has(segment)) return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse `git status --porcelain=v1 -z` (NUL-delimited) output into a flat
|
|
42
|
+
* list of paths. Handles rename (`R`) and copy (`C`) records, which span
|
|
43
|
+
* two NUL fields (`XY new\0old\0`): both halves are returned so the
|
|
44
|
+
* allow-list can reject either side. `-z` avoids the quoting that LF
|
|
45
|
+
* porcelain applies to paths containing spaces or specials, which would
|
|
46
|
+
* otherwise cause parser misclassification.
|
|
47
|
+
*/
|
|
48
|
+
export function parsePorcelainZ(statusPorcelain: string): string[] {
|
|
49
|
+
const records = statusPorcelain.split('\0');
|
|
50
|
+
const paths: string[] = [];
|
|
51
|
+
for (let i = 0; i < records.length; i++) {
|
|
52
|
+
const rec = records[i];
|
|
53
|
+
if (rec === undefined || rec === '') continue;
|
|
54
|
+
// Each record starts with "XY " (2 status chars + 1 space). The path is
|
|
55
|
+
// everything after byte 3. For R/C the NEXT record holds the old path.
|
|
56
|
+
if (rec.length < 4) continue;
|
|
57
|
+
const xy = rec.slice(0, 2);
|
|
58
|
+
const newPath = rec.slice(3);
|
|
59
|
+
paths.push(newPath);
|
|
60
|
+
// Check BOTH XY positions: X is the index status, Y is the working-tree
|
|
61
|
+
// status. Either can carry R (rename) or C (copy), and the old-path record
|
|
62
|
+
// follows the new-path record in -z porcelain regardless of which column
|
|
63
|
+
// detected the rename. Missing the Y-column case (e.g. ` R`) would skip
|
|
64
|
+
// the consume and let the next iteration misread the old path as a new
|
|
65
|
+
// record, smuggling unallowed sources past the allow-list.
|
|
66
|
+
if (/[RC]/.test(xy)) {
|
|
67
|
+
const oldPath = records[i + 1];
|
|
68
|
+
if (oldPath !== undefined && oldPath !== '') paths.push(oldPath);
|
|
69
|
+
i++; // consume the paired old-path record
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return paths;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reject any staged path that is not on the push allow-list or that matches a
|
|
77
|
+
* `NEVER_SYNC` entry. Builds the runtime allow-list by combining
|
|
78
|
+
* `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
|
|
79
|
+
* in `path-map.json`. Logs every violation as a FATAL line so the user sees
|
|
80
|
+
* the full set (not just the first), then throws `NomadFatal` to unwind the
|
|
81
|
+
* caller's try/finally and release the push lock.
|
|
82
|
+
*/
|
|
83
|
+
export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
|
|
84
|
+
const allowed = [
|
|
85
|
+
...PUSH_ALLOWED_STATIC,
|
|
86
|
+
...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
|
|
87
|
+
];
|
|
88
|
+
const neverSyncHits: string[] = [];
|
|
89
|
+
const violations: string[] = [];
|
|
90
|
+
for (const path of parsePorcelainZ(statusPorcelain)) {
|
|
91
|
+
if (isNeverSync(path)) {
|
|
92
|
+
neverSyncHits.push(path);
|
|
93
|
+
} else if (!isAllowed(path, allowed)) {
|
|
94
|
+
violations.push(path);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (neverSyncHits.length === 0 && violations.length === 0) return;
|
|
98
|
+
for (const p of neverSyncHits) {
|
|
99
|
+
fail(`${p} is in NEVER_SYNC and must never be pushed`);
|
|
100
|
+
}
|
|
101
|
+
for (const p of violations) {
|
|
102
|
+
fail(`to sync ${p}, add to PUSH_ALLOWED in src/config.ts`);
|
|
103
|
+
}
|
|
104
|
+
throw new NomadFatal('push allow-list violations');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `nomad push` command. Acquires the lock, runs the four pre-push safety
|
|
109
|
+
* checks in the order from CONTEXT.md, stages, and pushes:
|
|
110
|
+
* 1. `probeGitleaks` (fail fast if the secret scanner isn't on PATH)
|
|
111
|
+
* 2. `rebaseBeforePush` (surface remote conflicts against committed state,
|
|
112
|
+
* not against in-flight `remapPush` copies)
|
|
113
|
+
* 3. `remapPush` (copy host-encoded session dirs into shared logical names)
|
|
114
|
+
* 4. `findGitlinks` walk of `shared/` (refuse to push nested .git entries;
|
|
115
|
+
* runs AFTER `remapPush` so it catches .git dirs copied in from the host)
|
|
116
|
+
* 5. allow-list enforcement on the resulting `git status` (refuse any path
|
|
117
|
+
* not on `PUSH_ALLOWED_STATIC` or matching `NEVER_SYNC`)
|
|
118
|
+
* 6. `git add -A` -> `runGitleaksScan` on staged tree -> `git commit` -> `git push`
|
|
119
|
+
*
|
|
120
|
+
* The gitleaks scan runs AFTER staging so it sees what would actually be
|
|
121
|
+
* pushed, but BEFORE commit so a detection unwinds cleanly without leaving a
|
|
122
|
+
* commit to amend or revert. Any `NomadFatal` is caught here so `finally`
|
|
123
|
+
* releases the lock.
|
|
124
|
+
*
|
|
125
|
+
* `opts.dryRun` (default `false`): when `true`, the network round-trip
|
|
126
|
+
* (`rebaseBeforePush`) still runs so users see what a real push would see,
|
|
127
|
+
* but `remapPush` runs with `dryRun: true` (no session copies into shared/),
|
|
128
|
+
* and the `git add` / `runGitleaksScan` / `git commit` / `git push` quartet
|
|
129
|
+
* is skipped. The allow-list check still classifies the existing `git
|
|
130
|
+
* status` so a pre-existing violation surfaces before the user thinks
|
|
131
|
+
* everything is fine. Mirrors `cmdPull`'s `dryRun` contract.
|
|
132
|
+
*/
|
|
133
|
+
export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
134
|
+
const dryRun = opts.dryRun === true;
|
|
135
|
+
if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
|
|
136
|
+
const handle = acquireLock('push');
|
|
137
|
+
if (handle === null) process.exit(0);
|
|
138
|
+
try {
|
|
139
|
+
log(dryRun ? `pushing on host=${HOST} (dry-run)` : `pushing on host=${HOST}`);
|
|
140
|
+
// Probe at top of flow: fail fast if gitleaks is missing, before any mutation.
|
|
141
|
+
probeGitleaks();
|
|
142
|
+
// Rebase BEFORE any local mutation: surfaces remote conflicts against the
|
|
143
|
+
// user's committed state, not against in-flight remapPush copies. Runs
|
|
144
|
+
// under dryRun too so the network round-trip mirrors a real push.
|
|
145
|
+
rebaseBeforePush();
|
|
146
|
+
// Collision-resistant ts for remapPush's pre-copy snapshot of repo-side state.
|
|
147
|
+
const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
|
|
148
|
+
const ts = freshBackupTs(backupBase);
|
|
149
|
+
// remapPush runs BEFORE the empty-status check: it produces the diffs status
|
|
150
|
+
// observes, so swapping the order would short-circuit before anything is staged.
|
|
151
|
+
const remapResult = remapPush(ts, { dryRun });
|
|
152
|
+
// Gitlink walk of shared/ AFTER remapPush so it inspects the post-copy tree.
|
|
153
|
+
// A nested .git copied in from a host's encoded session dir would slip past a
|
|
154
|
+
// pre-remap scan and reach the remote via the shared/projects/<logical>/ prefix.
|
|
155
|
+
// Per-hit FATAL on stderr plus a summarizing throw, mirroring enforceAllowList.
|
|
156
|
+
const sharedDir = join(REPO_HOME, 'shared');
|
|
157
|
+
const gitlinks = findGitlinks(sharedDir);
|
|
158
|
+
if (gitlinks.length > 0) {
|
|
159
|
+
for (const p of gitlinks) {
|
|
160
|
+
const rel = relative(REPO_HOME, p);
|
|
161
|
+
fail(
|
|
162
|
+
`gitlink: ${rel} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
throw new NomadFatal(
|
|
166
|
+
`gitlink trap: ${gitlinks.length} nested .git ${gitlinks.length === 1 ? 'entry' : 'entries'} in shared/; remove before retry`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
// Routed through the shell-free, untrimmed helper because `sh` would .trim()
|
|
170
|
+
// the leading status-space and shift parsePorcelainZ's offsets.
|
|
171
|
+
const status = gitStatusPorcelainZ(REPO_HOME);
|
|
172
|
+
if (!status) {
|
|
173
|
+
log('nothing to commit');
|
|
174
|
+
emitSummary('push', remapResult.unmapped, remapResult.collisions);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
178
|
+
if (!existsSync(mapPath)) die('path-map.json missing, cannot enforce push allow-list');
|
|
179
|
+
// Route a malformed path-map.json through NomadFatal so finally releases the lock.
|
|
180
|
+
let map: PathMap;
|
|
181
|
+
try {
|
|
182
|
+
map = readJson<PathMap>(mapPath);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
throw new NomadFatal(`could not parse path-map.json: ${(err as Error).message}`);
|
|
185
|
+
}
|
|
186
|
+
enforceAllowList(status, map);
|
|
187
|
+
if (dryRun) {
|
|
188
|
+
// Skip the staging quartet so no commit lands and nothing is pushed.
|
|
189
|
+
// The user has already seen probeGitleaks pass, the rebase result, the
|
|
190
|
+
// remap preview, the gitlink scan, and the allow-list classification.
|
|
191
|
+
log('push: dry-run; skipping git add, gitleaks scan, commit, and push');
|
|
192
|
+
emitSummary('push', remapResult.unmapped, remapResult.collisions);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
|
|
196
|
+
gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
|
|
197
|
+
// Gitleaks scan AFTER staging (sees what would push), BEFORE commit (no cleanup
|
|
198
|
+
// needed on detection). The empty-status early return above guarantees the
|
|
199
|
+
// index is non-empty here.
|
|
200
|
+
runGitleaksScan();
|
|
201
|
+
gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
|
|
202
|
+
gitOrFatal(['push'], 'git push', REPO_HOME);
|
|
203
|
+
log('push complete');
|
|
204
|
+
emitSummary('push', remapResult.unmapped, remapResult.collisions);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err instanceof NomadFatal) {
|
|
207
|
+
fail(err.message);
|
|
208
|
+
process.exitCode = 1;
|
|
209
|
+
} else {
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
} finally {
|
|
213
|
+
releaseLock(handle);
|
|
214
|
+
}
|
|
215
|
+
}
|