claude-nomad 0.26.2 → 0.27.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 +7 -0
- package/README.md +80 -12
- package/package.json +1 -1
- package/src/commands.doctor.format.ts +2 -81
- package/src/commands.pull.ts +69 -13
- package/src/commands.push.sections.ts +171 -0
- package/src/commands.push.ts +136 -71
- package/src/extras-sync.core.ts +96 -0
- package/src/extras-sync.remap.ts +138 -0
- package/src/extras-sync.ts +22 -168
- package/src/links.ts +14 -3
- package/src/output-tree.ts +91 -0
- package/src/push-leak-verdict.ts +154 -0
- package/src/push-preview.ts +160 -0
- package/src/remap.ts +46 -27
- package/src/summary.ts +75 -27
package/src/commands.push.ts
CHANGED
|
@@ -1,18 +1,93 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { HOME, HOST, REPO_HOME } from './config.ts';
|
|
4
|
+
import { HOME, HOST, type PathMap, REPO_HOME } from './config.ts';
|
|
5
5
|
import { enforceAllowList } from './commands.push.allowlist.ts';
|
|
6
|
+
import { type PushState, renderNoScanTree, renderPushTree } from './commands.push.sections.ts';
|
|
6
7
|
import { remapExtrasPush } from './extras-sync.ts';
|
|
8
|
+
import { scanPushVerdict } from './push-leak-verdict.ts';
|
|
7
9
|
import { findGitlinks, probeGitleaks, rebaseBeforePush } from './push-checks.ts';
|
|
8
|
-
import {
|
|
10
|
+
import { previewPushLeaks } from './push-preview.ts';
|
|
9
11
|
import { remapPush } from './remap.ts';
|
|
10
|
-
import { emitSummary } from './summary.ts';
|
|
11
12
|
import { die, fail, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal } from './utils.ts';
|
|
12
13
|
import { freshBackupTs } from './utils.fs.ts';
|
|
13
14
|
import { readPathMap } from './utils.json.ts';
|
|
14
15
|
import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Walk `shared/` for nested `.git` entries copied in from a host's encoded
|
|
19
|
+
* session dir. A gitlink would otherwise push as a submodule via the
|
|
20
|
+
* `shared/projects/<logical>/` prefix. Emits a per-hit FATAL line on stderr and
|
|
21
|
+
* throws a summarizing `NomadFatal` (caught by `cmdPush` so the lock releases).
|
|
22
|
+
* Runs AFTER `remapPush` so it inspects the post-copy tree.
|
|
23
|
+
*/
|
|
24
|
+
function guardGitlinks(): void {
|
|
25
|
+
const gitlinks = findGitlinks(join(REPO_HOME, 'shared'));
|
|
26
|
+
if (gitlinks.length === 0) return;
|
|
27
|
+
for (const p of gitlinks) {
|
|
28
|
+
const rel = relative(REPO_HOME, p);
|
|
29
|
+
fail(`gitlink: ${rel} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`);
|
|
30
|
+
}
|
|
31
|
+
const noun = gitlinks.length === 1 ? 'entry' : 'entries';
|
|
32
|
+
throw new NomadFatal(
|
|
33
|
+
`gitlink trap: ${gitlinks.length} nested .git ${noun} in shared/; remove before retry`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The staged-tree leak gate + commit/push for the REAL push path. Runs
|
|
39
|
+
* `scanPushVerdict` AFTER `git add -A` (sees what would push) but BEFORE commit
|
|
40
|
+
* (a detection unwinds cleanly with no commit to revert). On a leak it renders
|
|
41
|
+
* the tree (with the ✗ Leak scan row + Summary) so the tree precedes the
|
|
42
|
+
* recovery block, then throws the recovery body as a `NomadFatal` (the catch
|
|
43
|
+
* prints it and sets a non-zero exit). On a clean scan it commits, pushes, and
|
|
44
|
+
* renders the tree with the `✓ no leaks` row.
|
|
45
|
+
*
|
|
46
|
+
* @param st - The collected push state for the final tree render.
|
|
47
|
+
*/
|
|
48
|
+
function commitAndPush(st: PushState): void {
|
|
49
|
+
// gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
|
|
50
|
+
gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
|
|
51
|
+
const verdict = scanPushVerdict();
|
|
52
|
+
if (verdict.leak) {
|
|
53
|
+
renderPushTree(st, verdict);
|
|
54
|
+
// Every `leak: true` branch of scanPushVerdict sets a non-null recovery
|
|
55
|
+
// body, so the `?? fallback` is defensively unreachable (excluded from
|
|
56
|
+
// coverage rather than contorting a test to fake an impossible state).
|
|
57
|
+
/* c8 ignore next */
|
|
58
|
+
throw new NomadFatal(verdict.recovery ?? 'gitleaks detected secrets');
|
|
59
|
+
}
|
|
60
|
+
gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
|
|
61
|
+
gitOrFatal(['push'], 'git push', REPO_HOME);
|
|
62
|
+
renderPushTree(st, verdict);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render the dry-run leak-scan tree. With `map === null` (a dry-run with no
|
|
67
|
+
* `path-map.json`) there is nothing to stage, so it renders the no-scan tree
|
|
68
|
+
* with the `noMapHint` row and returns. Otherwise it runs `previewPushLeaks`
|
|
69
|
+
* (which stages its OWN temp
|
|
70
|
+
* tree from the map, independent of `REPO_HOME` status, and sets
|
|
71
|
+
* `process.exitCode = 1` on findings), renders the push tree with the verdict
|
|
72
|
+
* row in the Leak scan section, and prints the recovery body BELOW the tree via
|
|
73
|
+
* `fail` (stderr) when one is present.
|
|
74
|
+
*
|
|
75
|
+
* Extracted from `cmdPush` so the command body and this helper each stay under
|
|
76
|
+
* the sonarjs cognitive-complexity threshold.
|
|
77
|
+
*
|
|
78
|
+
* @param st - The collected push state for the tree render.
|
|
79
|
+
* @param map - The parsed path-map, or `null` when a dry-run has no map.
|
|
80
|
+
*/
|
|
81
|
+
function runDryRunPreview(st: PushState, map: PathMap | null): void {
|
|
82
|
+
if (map === null) {
|
|
83
|
+
renderNoScanTree(st, { noMapHint: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const verdict = previewPushLeaks(map);
|
|
87
|
+
renderPushTree(st, verdict);
|
|
88
|
+
if (verdict.recovery !== null) fail(verdict.recovery);
|
|
89
|
+
}
|
|
90
|
+
|
|
16
91
|
/**
|
|
17
92
|
* `nomad push` command. Acquires the lock, runs the four pre-push safety
|
|
18
93
|
* checks in the order from CONTEXT.md, stages, and pushes:
|
|
@@ -26,20 +101,48 @@ import { acquireLock, releaseLock } from './utils.lockfile.ts';
|
|
|
26
101
|
* 5. `findGitlinks` walk of `shared/` (refuse to push nested .git entries)
|
|
27
102
|
* 6. allow-list enforcement on the resulting `git status` (runtime
|
|
28
103
|
* `shared/extras/<logical>/` prefix per declared logical added)
|
|
29
|
-
* 7. `git add -A` -> `
|
|
104
|
+
* 7. `git add -A` -> `scanPushVerdict` on staged tree -> `git commit` -> `git push`
|
|
105
|
+
*
|
|
106
|
+
* Output is a doctor-style grouped tree: a `push on host=...` header, then
|
|
107
|
+
* `Sessions` / `Extras` / `Leak scan` / `Summary` sections rendered with
|
|
108
|
+
* `├`/`└` connectors. Pushed sessions and extras list as `✓` rows; the
|
|
109
|
+
* per-project "not in path-map" skips collapse to one `ℹ︎` count row. The Leak
|
|
110
|
+
* scan section shows `✓ no leaks` on a clean scan; on a leak it shows a `✗`
|
|
111
|
+
* one-line verdict row and the full `buildSessionAwareFatal` recovery block
|
|
112
|
+
* still prints BELOW the rendered tree.
|
|
113
|
+
*
|
|
114
|
+
* The WET-path Summary row (including the warn `⚠︎` case) renders to STDOUT as
|
|
115
|
+
* part of the grouped tree via `renderTree`, not to stderr via `warn` as in the
|
|
116
|
+
* pre-tree behavior. The dry-run preview likewise renders via `renderTree`
|
|
117
|
+
* (push has no dry-run `emitSummary` path; `cmdPull`'s dry-run does, see its
|
|
118
|
+
* JSDoc for the intentional wet-stdout/dry-pull-stderr stream split).
|
|
30
119
|
*
|
|
31
120
|
* The gitleaks scan runs AFTER staging so it sees what would actually be
|
|
32
121
|
* pushed, but BEFORE commit so a detection unwinds cleanly without leaving a
|
|
33
122
|
* commit to amend or revert. Any `NomadFatal` is caught here so `finally`
|
|
34
|
-
* releases the lock
|
|
123
|
+
* releases the lock; a real-push leak re-raises the recovery body as a
|
|
124
|
+
* `NomadFatal` AFTER the tree renders so the recovery block follows the tree.
|
|
35
125
|
*
|
|
36
126
|
* `opts.dryRun` (default `false`): when `true`, the network round-trip
|
|
37
127
|
* (`rebaseBeforePush`) still runs so users see what a real push would see,
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
128
|
+
* and `remapPush` / `remapExtrasPush` run with `dryRun: true` (no copies
|
|
129
|
+
* into `shared/`). The `git add` / `git commit` / `git push` steps are
|
|
130
|
+
* skipped. Instead, `previewPushLeaks` runs a READ-ONLY gitleaks leak
|
|
131
|
+
* preview against a temp copy of the would-be-staged sessions AND extras
|
|
132
|
+
* (no `REPO_HOME/shared` mutation), returning a structured verdict whose
|
|
133
|
+
* `verdictRow` lands in the Leak scan section and whose `recovery` (if any)
|
|
134
|
+
* prints below the tree; `process.exitCode = 1` is set on findings.
|
|
135
|
+
*
|
|
136
|
+
* The dry-run preview runs REGARDLESS of `REPO_HOME` `git status`: in dry-run
|
|
137
|
+
* nothing is copied into `shared/`, so an empty status is the normal case for
|
|
138
|
+
* the headline target (a clean repo with new mapped sessions). `previewPushLeaks`
|
|
139
|
+
* stages its own temp tree from the path-map, so the empty-status
|
|
140
|
+
* `'nothing to commit'` early return is REAL-PUSH-ONLY. A dry-run with NO
|
|
141
|
+
* path-map renders the no-scan tree and returns without dying (a real push with
|
|
142
|
+
* a non-empty status and no map still dies on the allow-list check). The
|
|
143
|
+
* allow-list still classifies a non-empty `git status` (dry or wet) so a
|
|
144
|
+
* pre-existing violation surfaces; an empty status has nothing to classify.
|
|
145
|
+
* Mirrors `cmdPull`'s `dryRun` contract.
|
|
43
146
|
*/
|
|
44
147
|
export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
45
148
|
const dryRun = opts.dryRun === true;
|
|
@@ -47,7 +150,7 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
|
47
150
|
const handle = acquireLock('push');
|
|
48
151
|
if (handle === null) process.exit(0);
|
|
49
152
|
try {
|
|
50
|
-
log(dryRun ? `
|
|
153
|
+
console.log(dryRun ? `push on host=${HOST} (dry-run)` : `push on host=${HOST}`);
|
|
51
154
|
// Probe at top of flow: fail fast if gitleaks is missing, before any mutation.
|
|
52
155
|
probeGitleaks();
|
|
53
156
|
// Rebase BEFORE any local mutation: surfaces remote conflicts against the
|
|
@@ -59,29 +162,14 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
|
59
162
|
const ts = freshBackupTs(backupBase);
|
|
60
163
|
// remapPush runs BEFORE the empty-status check: it produces the diffs status
|
|
61
164
|
// observes, so swapping the order would short-circuit before anything is staged.
|
|
62
|
-
const
|
|
165
|
+
const remap = remapPush(ts, { dryRun });
|
|
63
166
|
// remapExtrasPush lands between remapPush and findGitlinks so the
|
|
64
167
|
// produced `shared/extras/<logical>/<dirname>/` paths are visible to
|
|
65
168
|
// both the gitlink walk and the downstream allow-list classification.
|
|
66
169
|
// dryRun is forwarded so a preview push reports the same skipped count.
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// pre-remap scan and reach the remote via the shared/projects/<logical>/ prefix.
|
|
71
|
-
// Per-hit FATAL on stderr plus a summarizing throw, mirroring enforceAllowList.
|
|
72
|
-
const sharedDir = join(REPO_HOME, 'shared');
|
|
73
|
-
const gitlinks = findGitlinks(sharedDir);
|
|
74
|
-
if (gitlinks.length > 0) {
|
|
75
|
-
for (const p of gitlinks) {
|
|
76
|
-
const rel = relative(REPO_HOME, p);
|
|
77
|
-
fail(
|
|
78
|
-
`gitlink: ${rel} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
throw new NomadFatal(
|
|
82
|
-
`gitlink trap: ${gitlinks.length} nested .git ${gitlinks.length === 1 ? 'entry' : 'entries'} in shared/; remove before retry`,
|
|
83
|
-
);
|
|
84
|
-
}
|
|
170
|
+
const extras = remapExtrasPush(ts, { dryRun });
|
|
171
|
+
const st: PushState = { dryRun, remap, extras };
|
|
172
|
+
guardGitlinks();
|
|
85
173
|
// Routed through the shell-free, untrimmed helper because `sh` would .trim()
|
|
86
174
|
// the leading status-space and shift parsePorcelainZ's offsets.
|
|
87
175
|
// `untrackedAll` (issue #111): the allow-list runs on this snapshot BEFORE
|
|
@@ -91,53 +179,30 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
|
91
179
|
// match, so the first extras push is rejected. Expanding to per-file paths
|
|
92
180
|
// lets the existing allow-list accept them while keeping the gate order.
|
|
93
181
|
const status = gitStatusPorcelainZ(REPO_HOME, { untrackedAll: true });
|
|
94
|
-
|
|
182
|
+
// REAL-PUSH-ONLY early return: a dry-run copies nothing into shared/, so an
|
|
183
|
+
// empty status is the normal headline case (clean repo, new mapped
|
|
184
|
+
// sessions) and must still reach the dry-run preview below.
|
|
185
|
+
if (!dryRun && !status) {
|
|
95
186
|
log('nothing to commit');
|
|
96
|
-
|
|
97
|
-
// count; both mean "couldn't sync this for the host". extras-skipped
|
|
98
|
-
// (non-whitelisted dirname) stays separate because it signals config
|
|
99
|
-
// misuse, not a host-config gap.
|
|
100
|
-
emitSummary(
|
|
101
|
-
'push',
|
|
102
|
-
remapResult.unmapped + extrasResult.unmapped,
|
|
103
|
-
remapResult.collisions,
|
|
104
|
-
extrasResult.skipped,
|
|
105
|
-
);
|
|
187
|
+
renderNoScanTree(st);
|
|
106
188
|
return;
|
|
107
189
|
}
|
|
108
190
|
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
109
|
-
|
|
191
|
+
// A dry-run with no map cannot enforce nor scan: render the no-scan tree and
|
|
192
|
+
// return without dying. A real push with a non-empty status still dies.
|
|
193
|
+
if (!existsSync(mapPath)) {
|
|
194
|
+
if (dryRun) return runDryRunPreview(st, null);
|
|
195
|
+
die('path-map.json missing, cannot enforce push allow-list');
|
|
196
|
+
}
|
|
110
197
|
// readPathMap routes parse failures through NomadFatal so finally releases the lock.
|
|
111
198
|
const map = readPathMap(mapPath);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
'push',
|
|
120
|
-
remapResult.unmapped + extrasResult.unmapped,
|
|
121
|
-
remapResult.collisions,
|
|
122
|
-
extrasResult.skipped,
|
|
123
|
-
);
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
// gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
|
|
127
|
-
gitOrFatal(['add', '-A'], 'git add', REPO_HOME);
|
|
128
|
-
// Gitleaks scan AFTER staging (sees what would push), BEFORE commit (no cleanup
|
|
129
|
-
// needed on detection). The empty-status early return above guarantees the
|
|
130
|
-
// index is non-empty here.
|
|
131
|
-
runGitleaksScan();
|
|
132
|
-
gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
|
|
133
|
-
gitOrFatal(['push'], 'git push', REPO_HOME);
|
|
134
|
-
log('push complete');
|
|
135
|
-
emitSummary(
|
|
136
|
-
'push',
|
|
137
|
-
remapResult.unmapped + extrasResult.unmapped,
|
|
138
|
-
remapResult.collisions,
|
|
139
|
-
extrasResult.skipped,
|
|
140
|
-
);
|
|
199
|
+
// Classify only a non-empty status; an empty status (dry-run on a clean
|
|
200
|
+
// repo) has nothing to gate.
|
|
201
|
+
if (status) enforceAllowList(status, map);
|
|
202
|
+
// dryRun skips git add / commit / push: run the read-only leak preview,
|
|
203
|
+
// which prints any recovery below the rendered tree.
|
|
204
|
+
if (dryRun) return runDryRunPreview(st, map);
|
|
205
|
+
commitAndPush(st);
|
|
141
206
|
} catch (err) {
|
|
142
207
|
if (err instanceof NomadFatal) {
|
|
143
208
|
fail(err.message);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { cpSync, existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { HOST, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
|
|
5
|
+
import { assertSafeLocalRoot, assertSafeLogical } from './extras-sync.guards.ts';
|
|
6
|
+
import { log } from './utils.ts';
|
|
7
|
+
import { readPathMap } from './utils.json.ts';
|
|
8
|
+
|
|
9
|
+
/** Parsed `path-map.json` plus its validated `extras` block. */
|
|
10
|
+
export type ValidatedExtras = { map: PathMap; extrasMap: Record<string, string[]> };
|
|
11
|
+
|
|
12
|
+
/** Skip counts: `unmapped` per-project (no host path / `'TBD'`), `skipped` per-dirname (not whitelisted). */
|
|
13
|
+
export type ExtrasCounts = { unmapped: number; skipped: number };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load and validate `path-map.json` for an extras op, owning the guard order
|
|
17
|
+
* so the "FATAL before any filesystem mutation" contract holds for every
|
|
18
|
+
* caller. Returns the parsed map plus its `extras` block, or `null` on a clean
|
|
19
|
+
* early-exit (missing `path-map.json`, a missing repo extras dir when
|
|
20
|
+
* `requireRepoExtras`, or an empty/absent `extras` key). THE VALIDATION PASS
|
|
21
|
+
* runs here, up-front over the whole map (`assertSafeLogical` per logical,
|
|
22
|
+
* `assertSafeLocalRoot` per mapped non-`'TBD'` path) so a clean entry ahead of
|
|
23
|
+
* a poisoned one cannot let a `mkdirSync`/`cpSync` land before the FATAL fires.
|
|
24
|
+
*
|
|
25
|
+
* @param opts.requireRepoExtras - Also require `shared/extras/` (pull side).
|
|
26
|
+
* @param opts.missingMsg - `log()` line on the missing-prereq exit (omitted for
|
|
27
|
+
* the divergence check, which skips silently).
|
|
28
|
+
*/
|
|
29
|
+
export function loadValidatedExtras(opts: {
|
|
30
|
+
requireRepoExtras?: boolean;
|
|
31
|
+
missingMsg?: string;
|
|
32
|
+
}): ValidatedExtras | null {
|
|
33
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
34
|
+
const repoExtras = join(REPO_HOME, 'shared', 'extras');
|
|
35
|
+
if (!existsSync(mapPath) || (opts.requireRepoExtras === true && !existsSync(repoExtras))) {
|
|
36
|
+
if (opts.missingMsg !== undefined) log(opts.missingMsg);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const map = readPathMap(mapPath);
|
|
41
|
+
const extrasMap = map.extras ?? {};
|
|
42
|
+
if (Object.keys(extrasMap).length === 0) return null;
|
|
43
|
+
|
|
44
|
+
for (const logical of Object.keys(extrasMap)) {
|
|
45
|
+
assertSafeLogical(logical);
|
|
46
|
+
const localRoot = map.projects[logical]?.[HOST];
|
|
47
|
+
if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
|
|
48
|
+
}
|
|
49
|
+
return { map, extrasMap };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Yield every surviving `{ logical, localRoot, dirname }` extras target after
|
|
54
|
+
* the per-project and per-dirname skip filters, mutating `counts` as it goes
|
|
55
|
+
* (`unmapped++` for a project with no host path / `'TBD'`, then skip it;
|
|
56
|
+
* `skipped++` for a dirname outside `SUPPORTED_EXTRAS`). Shared by push, pull,
|
|
57
|
+
* and the divergence check so all three walk identical skip/count semantics;
|
|
58
|
+
* the caller builds src/dst from the yielded triple.
|
|
59
|
+
*
|
|
60
|
+
* @param v - validated path-map plus its extras block.
|
|
61
|
+
* @param counts - mutated in place as targets are skipped or yielded. Skips are
|
|
62
|
+
* counted silently (no per-skip log line); the caller's detail arrays and the
|
|
63
|
+
* collapsed count row carry that information to the tree renderer.
|
|
64
|
+
*/
|
|
65
|
+
export function* eachExtrasTarget(
|
|
66
|
+
v: ValidatedExtras,
|
|
67
|
+
counts: ExtrasCounts,
|
|
68
|
+
): Generator<{ logical: string; localRoot: string; dirname: string }> {
|
|
69
|
+
const whitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
70
|
+
for (const [logical, dirnames] of Object.entries(v.extrasMap)) {
|
|
71
|
+
const localRoot = v.map.projects[logical]?.[HOST];
|
|
72
|
+
if (!localRoot || localRoot === 'TBD') {
|
|
73
|
+
counts.unmapped++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (const dirname of dirnames) {
|
|
77
|
+
if (!whitelist.includes(dirname)) {
|
|
78
|
+
counts.skipped++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
yield { logical, localRoot, dirname };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Recursive mirror copy: `rmSync` then `cpSync` so dst-only entries are
|
|
88
|
+
* removed (true mirror, not just overwrite). Passes `verbatimSymlinks: true`
|
|
89
|
+
* to keep relative symlink targets unrewritten across hosts (Pitfall 1;
|
|
90
|
+
* nodejs/node issue 41693). Exported so the test file can call it directly;
|
|
91
|
+
* `remapExtrasPush` and `remapExtrasPull` are the primary public API.
|
|
92
|
+
*/
|
|
93
|
+
export function copyExtras(src: string, dst: string): void {
|
|
94
|
+
rmSync(dst, { recursive: true, force: true });
|
|
95
|
+
cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
|
|
96
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { REPO_HOME } from './config.ts';
|
|
5
|
+
import {
|
|
6
|
+
copyExtras,
|
|
7
|
+
eachExtrasTarget,
|
|
8
|
+
loadValidatedExtras,
|
|
9
|
+
type ExtrasCounts,
|
|
10
|
+
type ValidatedExtras,
|
|
11
|
+
} from './extras-sync.core.ts';
|
|
12
|
+
import { backupExtrasWrite, backupRepoWrite } from './utils.fs.ts';
|
|
13
|
+
|
|
14
|
+
/** Detail lists returned by an extras op: items copied (wet) and would-copy (dry). */
|
|
15
|
+
type ExtrasDetail = ExtrasCounts & { done: string[]; would: string[] };
|
|
16
|
+
|
|
17
|
+
/** One yielded extras target: a (logical, host localRoot, dirname) triple. */
|
|
18
|
+
type ExtrasTarget = { logical: string; localRoot: string; dirname: string };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shared copy loop for `remapExtrasPush` / `remapExtrasPull`. Walks every
|
|
22
|
+
* surviving extras target (counts mutated via `eachExtrasTarget`; skips are
|
|
23
|
+
* counted silently, no per-skip log line), resolves src/dst through the
|
|
24
|
+
* side-specific `paths(...)`, and either records the would-copy item under
|
|
25
|
+
* `dryRun` or backs up + copies and records the done item. Returns
|
|
26
|
+
* `{ unmapped, skipped, done, would }`; the public wrappers rename
|
|
27
|
+
* `done`/`would` to push/pull-specific field names. No per-item log lines: the
|
|
28
|
+
* detail arrays carry that information to the tree renderer.
|
|
29
|
+
*
|
|
30
|
+
* @param v - validated path-map plus its extras block.
|
|
31
|
+
* @param dryRun - when `true`, collect `would` without mutating.
|
|
32
|
+
* @param paths - resolves `{ src, dst }` for one target (side-specific).
|
|
33
|
+
* @param backup - snapshots the dst before clobber (side-specific).
|
|
34
|
+
* @returns the counts plus the done/would detail lists.
|
|
35
|
+
*/
|
|
36
|
+
function runExtrasOp(
|
|
37
|
+
v: ValidatedExtras,
|
|
38
|
+
dryRun: boolean,
|
|
39
|
+
paths: (t: ExtrasTarget) => { src: string; dst: string },
|
|
40
|
+
backup: (dst: string, localRoot: string) => void,
|
|
41
|
+
): ExtrasDetail {
|
|
42
|
+
const counts: ExtrasCounts = { unmapped: 0, skipped: 0 };
|
|
43
|
+
const done: string[] = [];
|
|
44
|
+
const would: string[] = [];
|
|
45
|
+
for (const t of eachExtrasTarget(v, counts)) {
|
|
46
|
+
const { src, dst } = paths(t);
|
|
47
|
+
if (!existsSync(src)) continue;
|
|
48
|
+
const item = `${t.logical}/${t.dirname}`;
|
|
49
|
+
if (dryRun) {
|
|
50
|
+
would.push(item);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
backup(dst, t.localRoot);
|
|
54
|
+
copyExtras(src, dst);
|
|
55
|
+
done.push(item);
|
|
56
|
+
}
|
|
57
|
+
return { ...counts, done, would };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Push: copy whitelisted extras directories under each project's localRoot
|
|
62
|
+
* into the repo at `shared/extras/<logical>/<dirname>/`. Returns
|
|
63
|
+
* `{ unmapped, skipped, pushed, wouldPush }` with intentionally asymmetric
|
|
64
|
+
* count granularity (see `eachExtrasTarget`): `unmapped` per-project, `skipped`
|
|
65
|
+
* per-dirname; both feed the summary row. `pushed` / `wouldPush` hold
|
|
66
|
+
* `<logical>/<dirname>` strings copied (wet) or that would copy under
|
|
67
|
+
* `opts.dryRun` so cmdPush can render a grouped tree. Skips are counted
|
|
68
|
+
* silently and per-item log lines are dropped; counts are unchanged. Legacy
|
|
69
|
+
* `path-map.json` without an `extras` key returns empty arrays and zero counts
|
|
70
|
+
* cleanly.
|
|
71
|
+
*
|
|
72
|
+
* @param ts - backup timestamp namespace.
|
|
73
|
+
* @param opts.dryRun - when `true`, collect `wouldPush` without mutating.
|
|
74
|
+
*/
|
|
75
|
+
export function remapExtrasPush(
|
|
76
|
+
ts: string,
|
|
77
|
+
opts: { dryRun?: boolean } = {},
|
|
78
|
+
): ExtrasCounts & { pushed: string[]; wouldPush: string[] } {
|
|
79
|
+
const dryRun = opts.dryRun === true;
|
|
80
|
+
const v = loadValidatedExtras({ missingMsg: 'no path-map.json; skipping extras push' });
|
|
81
|
+
if (v === null) return { unmapped: 0, skipped: 0, pushed: [], wouldPush: [] };
|
|
82
|
+
|
|
83
|
+
const repoExtras = join(REPO_HOME, 'shared', 'extras');
|
|
84
|
+
if (!dryRun) mkdirSync(repoExtras, { recursive: true });
|
|
85
|
+
|
|
86
|
+
const { unmapped, skipped, done, would } = runExtrasOp(
|
|
87
|
+
v,
|
|
88
|
+
dryRun,
|
|
89
|
+
({ localRoot, logical, dirname }) => ({
|
|
90
|
+
src: join(localRoot, dirname),
|
|
91
|
+
dst: join(repoExtras, logical, dirname),
|
|
92
|
+
}),
|
|
93
|
+
(dst) => backupRepoWrite(dst, ts, REPO_HOME),
|
|
94
|
+
);
|
|
95
|
+
return { unmapped, skipped, pushed: done, wouldPush: would };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pull: copy whitelisted extras from `shared/extras/<logical>/<dirname>/`
|
|
100
|
+
* back into each project's localRoot on this host. Returns
|
|
101
|
+
* `{ unmapped, skipped, pulled, wouldPull }` with the same asymmetric count
|
|
102
|
+
* granularity as `remapExtrasPush`; `pulled` / `wouldPull` hold
|
|
103
|
+
* `<logical>/<dirname>` strings for the grouped tree. Skips are counted
|
|
104
|
+
* silently and per-item log lines are dropped; counts are unchanged. Uses
|
|
105
|
+
* `backupExtrasWrite` (not `backupBeforeWrite`) because
|
|
106
|
+
* `<localRoot>/<dirname>` lives outside `CLAUDE_HOME` and the standard helper's
|
|
107
|
+
* relative-path guard would no-op and lose prior content. Legacy
|
|
108
|
+
* `path-map.json` without an `extras` key, or a missing `shared/extras/`, both
|
|
109
|
+
* produce a clean no-op.
|
|
110
|
+
*
|
|
111
|
+
* @param ts - backup timestamp namespace.
|
|
112
|
+
* @param opts.dryRun - when `true`, collect `wouldPull` without mutating.
|
|
113
|
+
*/
|
|
114
|
+
export function remapExtrasPull(
|
|
115
|
+
ts: string,
|
|
116
|
+
opts: { dryRun?: boolean } = {},
|
|
117
|
+
): ExtrasCounts & { pulled: string[]; wouldPull: string[] } {
|
|
118
|
+
const dryRun = opts.dryRun === true;
|
|
119
|
+
const v = loadValidatedExtras({
|
|
120
|
+
requireRepoExtras: true,
|
|
121
|
+
missingMsg: 'no path-map or repo extras dir; skipping extras remap',
|
|
122
|
+
});
|
|
123
|
+
if (v === null) return { unmapped: 0, skipped: 0, pulled: [], wouldPull: [] };
|
|
124
|
+
|
|
125
|
+
const repoExtras = join(REPO_HOME, 'shared', 'extras');
|
|
126
|
+
const { unmapped, skipped, done, would } = runExtrasOp(
|
|
127
|
+
v,
|
|
128
|
+
dryRun,
|
|
129
|
+
({ localRoot, logical, dirname }) => ({
|
|
130
|
+
src: join(repoExtras, logical, dirname),
|
|
131
|
+
dst: join(localRoot, dirname),
|
|
132
|
+
}),
|
|
133
|
+
// Snapshot the host-side dst BEFORE copyExtras clobbers it. Anchor on
|
|
134
|
+
// localRoot so the backup tree mirrors the project layout.
|
|
135
|
+
(dst, localRoot) => backupExtrasWrite(dst, ts, localRoot),
|
|
136
|
+
);
|
|
137
|
+
return { unmapped, skipped, pulled: done, wouldPull: would };
|
|
138
|
+
}
|