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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Reusable helpers for push-boundary safety: gitlink walker, gitleaks
3
+ * presence probe, and rebase-before-push.
4
+ *
5
+ * The staged gitleaks scan lives in `./push-gitleaks.ts` so the
6
+ * session-aware FATAL builder has its own module under the 200-line cap.
7
+ * `gitleaksInstallHint` stays here because both `probeGitleaks`
8
+ * (top-of-flow) and `runGitleaksScan` (mid-flow) need the platform-aware
9
+ * install scaffold on ENOENT.
10
+ *
11
+ * All execFileSync-backed helpers use argv-array form with
12
+ * `stdio: ['ignore', 'pipe', 'pipe']` (no shell). Same shape as
13
+ * `gitStatusPorcelainZ` in src/utils.ts so the audit surface is uniform.
14
+ *
15
+ * Used by `cmdPush` for refuse-on-hit safety and by `cmdDoctor` for
16
+ * read-only diagnostics (doctor only consumes `findGitlinks` and
17
+ * `probeGitleaks`).
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process';
21
+ import { existsSync, readdirSync, type Dirent } from 'node:fs';
22
+ import { homedir, platform } from 'node:os';
23
+ import { join } from 'node:path';
24
+
25
+ import { REPO_HOME } from './config.ts';
26
+ import { NomadFatal } from './utils.ts';
27
+
28
+ /**
29
+ * Platform-aware "gitleaks not on PATH" hint, mirroring the scaffold
30
+ * `install.sh` prints during onboarding:
31
+ * - macOS: `brew install gitleaks`.
32
+ * - Linux: numbered steps (download arch-matched tarball, extract to
33
+ * `~/.local/bin`, optional PATH note when the dir isn't on PATH).
34
+ * - Other: just the release-page link.
35
+ * Evaluated at call time so the PATH-on-rc check reflects the runtime
36
+ * env, not the value at module load.
37
+ */
38
+ export function gitleaksInstallHint(): string {
39
+ const head = 'gitleaks not on PATH (required for nomad push). Install:';
40
+ const plat = platform();
41
+ if (plat === 'darwin') {
42
+ return `${head}\n brew install gitleaks`;
43
+ }
44
+ if (plat === 'linux') {
45
+ const archMap: Record<string, string> = { x64: 'x64', arm64: 'arm64', arm: 'armv7' };
46
+ const arch = archMap[process.arch];
47
+ const lines = [
48
+ head,
49
+ arch
50
+ ? ` 1. Download the linux_${arch} tarball: https://github.com/gitleaks/gitleaks/releases`
51
+ : ` 1. Download the linux artifact for arch=${process.arch}: https://github.com/gitleaks/gitleaks/releases`,
52
+ ' 2. Install (replace TARBALL with the path to your download):',
53
+ ' mkdir -p ~/.local/bin',
54
+ ' tar -xzf TARBALL -C ~/.local/bin gitleaks',
55
+ ' chmod +x ~/.local/bin/gitleaks',
56
+ ' ~/.local/bin/gitleaks version # verify',
57
+ ];
58
+ const localBin = `${homedir()}/.local/bin`;
59
+ const paths = (process.env.PATH ?? '').split(':');
60
+ if (!paths.includes(localBin)) {
61
+ lines.push(
62
+ ' 3. ~/.local/bin is not on PATH; add to your shell rc:',
63
+ ' export PATH="$HOME/.local/bin:$PATH"',
64
+ );
65
+ }
66
+ return lines.join('\n');
67
+ }
68
+ return `${head}\n See https://github.com/gitleaks/gitleaks/releases`;
69
+ }
70
+
71
+ /**
72
+ * Recursively find every entry whose basename is `.git` under `dir`. Returns
73
+ * absolute paths. Used by `cmdPush` (refuse-on-hit) and `cmdDoctor`
74
+ * (read-only diagnostic). Callers feed `REPO_HOME/shared/` only; the tool's
75
+ * own repo .git at `~/claude-nomad/.git/` is outside the walk root.
76
+ *
77
+ * Does NOT follow symlinks. `Dirent.isDirectory()` returns `false` for
78
+ * `S_IFLNK` entries even when the link target is a directory, so the
79
+ * recursion naturally short-circuits at any symlink. This is the
80
+ * load-bearing fix for a known hazard: `readdirSync` in recursive mode
81
+ * follows self-referential symlink cycles up to libuv's internal cap
82
+ * (empirically verified on Node 22.16: 82 entries at depth 83 before the
83
+ * cap fired). The hand-rolled walker below is cycle-safe by construction.
84
+ *
85
+ * Tolerates permission errors silently (returns whatever was collected before
86
+ * the error). Reports both file gitlinks (submodule pointer) and directory
87
+ * gitlinks (real nested repo); both push as gitlinks and both break clone.
88
+ */
89
+ export function findGitlinks(dir: string): string[] {
90
+ const hits: string[] = [];
91
+ function walk(current: string): void {
92
+ let entries: Dirent[];
93
+ try {
94
+ entries = readdirSync(current, { withFileTypes: true });
95
+ } catch {
96
+ return;
97
+ }
98
+ for (const e of entries) {
99
+ const p = join(current, e.name);
100
+ if (e.name === '.git') {
101
+ hits.push(p);
102
+ continue;
103
+ }
104
+ if (e.isDirectory()) walk(p);
105
+ }
106
+ }
107
+ walk(dir);
108
+ return hits;
109
+ }
110
+
111
+ /**
112
+ * Probe for the gitleaks binary on PATH. Returns the trimmed `gitleaks
113
+ * version` stdout on success. Throws NomadFatal with the install hint on
114
+ * ENOENT; throws NomadFatal with the error message on any other failure.
115
+ * Used by `cmdPush` (top-of-flow probe) and `cmdDoctor` (read-only).
116
+ *
117
+ * Conditionally passes `--config <REPO_HOME>/.gitleaks.toml` when that file
118
+ * exists at call time. `gitleaks version` ignores the flag empirically on
119
+ * 8.30.1, so the wiring here is conservative: symmetric with
120
+ * `runGitleaksScan` and surfaces a malformed toml early if a future gitleaks
121
+ * version starts parsing the config on the `version` subcommand. When the
122
+ * toml is missing (e.g., fresh clone predating the allowlist) the flag is
123
+ * omitted entirely; behavior reverts silently to the default gitleaks ruleset.
124
+ */
125
+ export function probeGitleaks(): string {
126
+ const tomlPath = join(REPO_HOME, '.gitleaks.toml');
127
+ const args: string[] = ['version'];
128
+ if (existsSync(tomlPath)) args.push('--config', tomlPath);
129
+ try {
130
+ return execFileSync('gitleaks', args, { stdio: ['ignore', 'pipe', 'pipe'] })
131
+ .toString()
132
+ .trim();
133
+ } catch (err) {
134
+ const e = err as NodeJS.ErrnoException;
135
+ if (e.code === 'ENOENT') throw new NomadFatal(gitleaksInstallHint());
136
+ throw new NomadFatal(`gitleaks --version failed: ${e.message}`);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Run `git pull --rebase --autostash` in REPO_HOME before push.
142
+ * The `--autostash` absorbs dirty trees (in-progress path-map.json edits,
143
+ * host overrides) so users do not need to commit-or-stash first.
144
+ *
145
+ * On failure, forwards git's stderr so the user sees the actual reason
146
+ * (conflict, no-upstream, unreachable remote, auth failure, etc.), then
147
+ * throws NomadFatal.
148
+ *
149
+ * FATAL wording references `git rebase --continue` / `--abort` (not the
150
+ * stash list): when `--autostash` is in flight, the stashed work lives in
151
+ * `.git/rebase-merge/autostash` mid-conflict and is reapplied by
152
+ * `--continue` / `--abort` automatically. Pointing the user at the stash
153
+ * list would mislead them; the recovery commands are the actual fix.
154
+ *
155
+ * `cmdPull` may adopt the same helper in a future refactor.
156
+ */
157
+ export function rebaseBeforePush(): void {
158
+ try {
159
+ execFileSync('git', ['pull', '--rebase', '--autostash'], {
160
+ cwd: REPO_HOME,
161
+ stdio: ['ignore', 'pipe', 'pipe'],
162
+ });
163
+ } catch (err) {
164
+ const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
165
+ if (e.stderr) process.stderr.write(e.stderr);
166
+ throw new NomadFatal(
167
+ 'rebase failed; if a conflict was reported, resolve it in ~/claude-nomad/ and run "git rebase --continue" (or "git rebase --abort" to give up). Re-run nomad push after resolution.',
168
+ );
169
+ }
170
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Owns the staged gitleaks scan invoked at the end of `cmdPush`.
3
+ *
4
+ * Lives in its own module (split from `push-checks.ts`) so the
5
+ * session-aware FATAL builder (gitleaks JSON parser + per-session message
6
+ * composer) has a clean home while keeping every file under the 200-line
7
+ * cap. `findGitlinks`, `probeGitleaks`, `gitleaksInstallHint`, and
8
+ * `rebaseBeforePush` stay in `push-checks.ts`.
9
+ *
10
+ * `gitleaksInstallHint` is imported from `./push-checks.ts` because the
11
+ * ENOENT branch surfaces the same platform-aware install scaffold whether
12
+ * the missing binary is detected by `probeGitleaks` (top-of-flow) or by
13
+ * this scan (defense-in-depth mid-flow).
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ import { REPO_HOME } from './config.ts';
22
+ import { gitleaksInstallHint } from './push-checks.ts';
23
+ import { NomadFatal, nowTimestamp } from './utils.ts';
24
+
25
+ /**
26
+ * Subset of gitleaks 8.x JSON report fields the parser consumes. The
27
+ * report is an array of objects (one per finding) emitted to the
28
+ * `--report-path` file; on clean scans the array is empty.
29
+ */
30
+ export type Finding = {
31
+ RuleID: string;
32
+ File: string;
33
+ StartLine: number;
34
+ Match: string;
35
+ Fingerprint: string;
36
+ };
37
+
38
+ /**
39
+ * Captures the session id from a repo-relative POSIX path of the form
40
+ * `shared/projects/<logical>/<sid>.jsonl`. gitleaks emits forward-slash
41
+ * paths regardless of host OS, so the literal works cross-platform.
42
+ * Anchored at both ends + depth-4 segments by construction so deeper
43
+ * paths (e.g., `shared/projects/<logical>/subagents/<id>.jsonl`) fall
44
+ * through to the non-session `other` bucket.
45
+ */
46
+ const SESSION_PATH = /^shared\/projects\/[^/]+\/([^/]+)\.jsonl$/;
47
+
48
+ /**
49
+ * Legacy fallback FATAL emitted when no finding's File matches the session
50
+ * path pattern. Locked verbatim so existing tests covering the non-session
51
+ * path do not regress.
52
+ */
53
+ const LEGACY_FATAL =
54
+ 'gitleaks detected secrets; review staged changes with git diff --cached and unstage offending files before retry';
55
+
56
+ /**
57
+ * Group findings by extracted session id, counting per RuleID, with
58
+ * non-session paths routed to the `other` bucket. Pure: no side effects,
59
+ * no environment reads.
60
+ */
61
+ export function partitionFindings(findings: Finding[]): {
62
+ bySession: Map<string, Map<string, number>>;
63
+ other: Finding[];
64
+ } {
65
+ const bySession = new Map<string, Map<string, number>>();
66
+ const other: Finding[] = [];
67
+ for (const f of findings) {
68
+ const m = SESSION_PATH.exec(f.File);
69
+ if (m === null) {
70
+ other.push(f);
71
+ continue;
72
+ }
73
+ const sid = m[1];
74
+ // Defensive type narrowing: the regex guarantees group 1 is captured
75
+ // when m !== null, so this branch is unreachable at runtime. Excluded
76
+ // from coverage rather than contorting tests to fake an impossible state.
77
+ /* c8 ignore next */
78
+ if (sid === undefined) continue;
79
+ let counts = bySession.get(sid);
80
+ if (counts === undefined) {
81
+ counts = new Map<string, number>();
82
+ bySession.set(sid, counts);
83
+ }
84
+ counts.set(f.RuleID, (counts.get(f.RuleID) ?? 0) + 1);
85
+ }
86
+ return { bySession, other };
87
+ }
88
+
89
+ /**
90
+ * Build the FATAL message body. Returns the legacy fallback string when
91
+ * `bySession` is empty (no session matches); otherwise composes the
92
+ * multi-section message: `gitleaks detected secrets in N session
93
+ * transcript(s).` header, one block per affected session with a
94
+ * `Recover with: nomad drop-session <id>` line, an optional `Also found:`
95
+ * block for non-session paths, and a trailing `After recovery, re-run
96
+ * nomad push.` line. Pure.
97
+ */
98
+ export function buildSessionAwareFatal(
99
+ bySession: Map<string, Map<string, number>>,
100
+ other: Finding[],
101
+ ): string {
102
+ if (bySession.size === 0) return LEGACY_FATAL;
103
+ const lines: string[] = [];
104
+ lines.push(`gitleaks detected secrets in ${bySession.size} session transcript(s).`);
105
+ for (const [sid, counts] of bySession) {
106
+ const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
107
+ lines.push('');
108
+ lines.push(`Session ${sid}:`);
109
+ lines.push(` ${summary}`);
110
+ lines.push(` Recover with: nomad drop-session ${sid}`);
111
+ }
112
+ if (other.length > 0) {
113
+ lines.push('');
114
+ lines.push('Also found:');
115
+ for (const f of other) {
116
+ lines.push(` ${f.File} ${f.RuleID}`);
117
+ }
118
+ lines.push(' Review with: git diff --cached, then unstage manually.');
119
+ }
120
+ lines.push('');
121
+ lines.push('After recovery, re-run nomad push.');
122
+ return lines.join('\n');
123
+ }
124
+
125
+ /**
126
+ * Read and parse the gitleaks JSON report at `reportPath`. Returns the
127
+ * findings array on success, or `null` when the file is missing or the
128
+ * JSON is malformed. Defense-in-depth: an unreadable/invalid report on
129
+ * the failure path must NOT cascade into a parse-error stack trace; the
130
+ * caller falls back to the legacy FATAL string in that case.
131
+ */
132
+ function readGitleaksReport(reportPath: string): Finding[] | null {
133
+ try {
134
+ const raw = readFileSync(reportPath, 'utf8');
135
+ const parsed: unknown = JSON.parse(raw);
136
+ if (!Array.isArray(parsed)) return null;
137
+ return parsed as Finding[];
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Run gitleaks against the staged index, writing the JSON report to a
145
+ * collision-resistant path under `~/.cache/claude-nomad/`. On non-zero
146
+ * exit, forwards gitleaks' redacted stderr/stdout so the user sees which
147
+ * file is dirty, reads the JSON report, classifies findings into session
148
+ * vs non-session paths, and throws a session-aware NomadFatal whose
149
+ * message names every affected session id with a `nomad drop-session`
150
+ * recovery hint. Non-session-only findings fall back to the legacy FATAL
151
+ * string. The temp report file is removed via `finally` on both success
152
+ * and failure paths.
153
+ *
154
+ * Conditionally passes `--config <REPO_HOME>/.gitleaks.toml` when that file
155
+ * exists at call time. The allowlist suppresses
156
+ * structurally-distinguishable tool-output noise (Sonar issue keys,
157
+ * gitleaks fingerprints, npm audit JSON id-field hashes, coverage line-keys)
158
+ * without weakening real-secret detection. Missing toml = silent fallback
159
+ * to the default gitleaks ruleset (e.g., fresh clones pre-allowlist).
160
+ *
161
+ * ENOENT branch is defense-in-depth: the presence probe at the top of
162
+ * `cmdPush` should have caught a missing binary, but if `cmdPush` ever
163
+ * bypasses the probe (or the user uninstalls gitleaks mid-flow) the same
164
+ * install-hint FATAL fires here.
165
+ */
166
+ export function runGitleaksScan(): void {
167
+ const cacheDir = join(homedir(), '.cache', 'claude-nomad');
168
+ mkdirSync(cacheDir, { recursive: true });
169
+ // Disambiguate with pid so the lockfile invariant (one concurrent push
170
+ // per host) is enough to keep the report path unique. The prior
171
+ // freshBackupTs() call checked for a sibling directory named exactly
172
+ // <ts>, which never collides with the file `gitleaks-<ts>.json` being
173
+ // written.
174
+ const reportPath = join(cacheDir, `gitleaks-${nowTimestamp()}-${process.pid}.json`);
175
+ const tomlPath = join(REPO_HOME, '.gitleaks.toml');
176
+ const args: string[] = [
177
+ 'protect',
178
+ '--staged',
179
+ '--redact',
180
+ '-v',
181
+ '--report-format=json',
182
+ `--report-path=${reportPath}`,
183
+ ];
184
+ if (existsSync(tomlPath)) args.push('--config', tomlPath);
185
+ try {
186
+ execFileSync('gitleaks', args, {
187
+ cwd: REPO_HOME,
188
+ stdio: ['ignore', 'pipe', 'pipe'],
189
+ });
190
+ } catch (err) {
191
+ const e = err as NodeJS.ErrnoException & {
192
+ status?: number;
193
+ stderr?: Buffer;
194
+ stdout?: Buffer;
195
+ };
196
+ if (e.code === 'ENOENT') throw new NomadFatal(gitleaksInstallHint());
197
+ if (e.stderr) process.stderr.write(e.stderr);
198
+ if (e.stdout) process.stdout.write(e.stdout);
199
+ const findings = readGitleaksReport(reportPath);
200
+ if (findings === null) {
201
+ // gitleaks exited non-zero but no parseable JSON report exists at the
202
+ // expected path. Could be a scanner crash, a malformed report, a
203
+ // missing/locked file, or a non-finding runtime failure (gitleaks v8.x
204
+ // returns exit 1 for both "leaks found" and runtime errors). Tell the
205
+ // operator the scan itself failed so they do not chase a phantom
206
+ // `nomad drop-session` rabbit hole. The stderr/stdout already
207
+ // forwarded above carries the underlying gitleaks output.
208
+ throw new NomadFatal(
209
+ `gitleaks scan failed: no parseable JSON report at ${reportPath} (${e.message}). Review the gitleaks output above.`,
210
+ );
211
+ }
212
+ const { bySession, other } = partitionFindings(findings);
213
+ throw new NomadFatal(buildSessionAwareFatal(bySession, other));
214
+ } finally {
215
+ rmSync(reportPath, { force: true });
216
+ }
217
+ }
package/src/remap.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
2
+ import { join, relative, sep } from 'node:path';
3
+
4
+ import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
5
+ import { backupBeforeWrite, backupRepoWrite, encodePath, log, readJson } from './utils.ts';
6
+
7
+ /**
8
+ * Recursive mirror copy: removes `dst` first, then copies `src` into it.
9
+ * `cpSync(force:true)` overwrites matching files but does not delete
10
+ * dst-only entries; the upfront `rmSync` makes the operation a true mirror
11
+ * so `dst` reflects `src` exactly rather than accumulating stale files.
12
+ */
13
+ function copyDir(src: string, dst: string): void {
14
+ rmSync(dst, { recursive: true, force: true });
15
+ cpSync(src, dst, { recursive: true, force: true });
16
+ }
17
+
18
+ /**
19
+ * Push-side mirror copy: identical to copyDir except a depth-0 extension
20
+ * filter restricts to *.jsonl files only. Subdirectory contents (subagents,
21
+ * memory, tool-results, etc.) copy recursively with no further filtering.
22
+ * Stray .bak / .tmp / .swp / editor backups at the source root are skipped
23
+ * and produce one `ℹ︎ skip <rel>: extension not in allowlist` log
24
+ * line each. The filter must allow the source root explicitly (Pitfall 1:
25
+ * cpSync invokes the filter on src === src first, and a false return
26
+ * there would abort the whole copy). Used by remapPush only; remapPull
27
+ * keeps the unfiltered copyDir because the repo side is already curated
28
+ * by the push gate.
29
+ */
30
+ function copyDirJsonlOnly(src: string, dst: string): void {
31
+ rmSync(dst, { recursive: true, force: true });
32
+ cpSync(src, dst, {
33
+ recursive: true,
34
+ force: true,
35
+ filter: (srcPath) => {
36
+ const rel = relative(src, srcPath);
37
+ if (rel === '') return true;
38
+ if (rel.split(sep).length > 1) return true;
39
+ if (statSync(srcPath).isDirectory()) return true;
40
+ if (srcPath.endsWith('.jsonl')) return true;
41
+ log(`skip ${rel}: extension not in allowlist`);
42
+ return false;
43
+ },
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Pull: copy from repo's logical project names into local path-encoded dirs.
49
+ *
50
+ * Returns `{ unmapped: N }` where `N` counts path-map entries skipped for
51
+ * this host (either `'TBD'` placeholder or no entry for `HOST`). The count
52
+ * is consumed by `computePreview` and the future summary line.
53
+ *
54
+ * `opts.dryRun` (default `false`): when `true`, log `would overwrite:` lines
55
+ * instead of calling `backupBeforeWrite` + `copyDir`. The unmapped count is
56
+ * computed identically in both modes.
57
+ */
58
+ export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapped: number } {
59
+ const dryRun = opts.dryRun === true;
60
+ let unmapped = 0;
61
+ const mapPath = join(REPO_HOME, 'path-map.json');
62
+ const repoProjects = join(REPO_HOME, 'shared', 'projects');
63
+ if (!existsSync(mapPath) || !existsSync(repoProjects)) {
64
+ log('no path-map or repo projects dir; skipping session remap');
65
+ return { unmapped: 0 };
66
+ }
67
+
68
+ const map = readJson<PathMap>(mapPath);
69
+ const localProjects = join(CLAUDE_HOME, 'projects');
70
+ if (!dryRun) mkdirSync(localProjects, { recursive: true });
71
+
72
+ for (const [logical, hosts] of Object.entries(map.projects)) {
73
+ const localPath = hosts[HOST];
74
+ if (localPath === 'TBD') {
75
+ unmapped++;
76
+ log(`skip ${logical}: placeholder path for ${HOST}`);
77
+ continue;
78
+ }
79
+ if (!localPath) {
80
+ unmapped++;
81
+ log(`skip ${logical}: no path for ${HOST}`);
82
+ continue;
83
+ }
84
+ const src = join(repoProjects, logical);
85
+ if (!existsSync(src)) continue;
86
+ const dst = join(localProjects, encodePath(localPath));
87
+ if (dryRun) {
88
+ log(`would overwrite: ${dst} (from ${src})`);
89
+ continue;
90
+ }
91
+ // Snapshot prior encoded-path-dir state BEFORE copyDir overwrites it.
92
+ backupBeforeWrite(dst, ts);
93
+ copyDir(src, dst);
94
+ log(`pulled ${logical} -> ${encodePath(localPath)}`);
95
+ }
96
+ return { unmapped };
97
+ }
98
+
99
+ /**
100
+ * Push: copy local path-encoded dirs back to repo under logical names.
101
+ *
102
+ * Returns `{ unmapped: N, collisions: M }` where `unmapped` is the count of
103
+ * `~/.claude/projects/<dir>/` entries that have no path-map reverse-lookup
104
+ * for this host. `collisions` is reserved for a future slice's path-encoding
105
+ * collision detection and is always `0` here.
106
+ *
107
+ * `opts.dryRun` (default `false`): when `true`, log `would push:` lines
108
+ * instead of calling `backupRepoWrite` + `copyDir`. Counts are computed
109
+ * identically in both modes.
110
+ */
111
+ export function remapPush(
112
+ ts: string,
113
+ opts: { dryRun?: boolean } = {},
114
+ ): { unmapped: number; collisions: number } {
115
+ const dryRun = opts.dryRun === true;
116
+ let unmapped = 0;
117
+ const collisions = 0;
118
+ const mapPath = join(REPO_HOME, 'path-map.json');
119
+ if (!existsSync(mapPath)) {
120
+ log('no path-map.json; skipping session export');
121
+ return { unmapped: 0, collisions: 0 };
122
+ }
123
+
124
+ const map = readJson<PathMap>(mapPath);
125
+ const localProjects = join(CLAUDE_HOME, 'projects');
126
+ const repoProjects = join(REPO_HOME, 'shared', 'projects');
127
+ if (!dryRun) mkdirSync(repoProjects, { recursive: true });
128
+
129
+ const reverse = new Map<string, string>();
130
+ for (const [logical, hosts] of Object.entries(map.projects)) {
131
+ const p = hosts[HOST];
132
+ if (!p || p === 'TBD') continue;
133
+ reverse.set(encodePath(p), logical);
134
+ }
135
+
136
+ if (!existsSync(localProjects)) return { unmapped, collisions };
137
+ for (const dir of readdirSync(localProjects)) {
138
+ const logical = reverse.get(dir);
139
+ if (!logical) {
140
+ unmapped++;
141
+ log(`skip ${dir}: not in path-map for ${HOST}`);
142
+ continue;
143
+ }
144
+ const repoDst = join(repoProjects, logical);
145
+ if (dryRun) {
146
+ log(`would push: ${dir} -> ${logical}`);
147
+ continue;
148
+ }
149
+ // Snapshot repo-side destination before copyDir clobbers it. Git
150
+ // history exists only AFTER the commit step, so a corrupt or
151
+ // path-encoding-collided local dir would otherwise have no rollback
152
+ // path. Symmetric with remapPull's backupBeforeWrite on the local dst.
153
+ backupRepoWrite(repoDst, ts, REPO_HOME);
154
+ copyDirJsonlOnly(join(localProjects, dir), repoDst);
155
+ log(`pushed ${dir} -> ${logical}`);
156
+ }
157
+ return { unmapped, collisions };
158
+ }