claude-nomad 0.21.0 → 0.22.1
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 +20 -1
- package/CHANGELOG.md +14 -0
- package/README.md +23 -14
- package/package.json +1 -1
- package/src/commands.pull.ts +28 -10
- package/src/commands.push.ts +58 -19
- package/src/config.ts +23 -1
- package/src/extras-sync.ts +290 -0
- package/src/summary.ts +21 -13
- package/src/utils.ts +55 -1
package/.gitleaks.toml
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[extend]
|
|
7
7
|
useDefault = true
|
|
8
8
|
|
|
9
|
-
[
|
|
9
|
+
[[allowlists]]
|
|
10
10
|
description = "claude-nomad: structurally-distinguishable tool-output noise"
|
|
11
11
|
regexes = [
|
|
12
12
|
'''AY[A-Za-z0-9_-]{20,}''',
|
|
@@ -14,3 +14,22 @@ regexes = [
|
|
|
14
14
|
'''"id"\s*:\s*"[a-f0-9]{40,64}"''',
|
|
15
15
|
'''key=[a-f0-9]{8,} [\w./-]+\.\w+:\d+''',
|
|
16
16
|
]
|
|
17
|
+
|
|
18
|
+
# Path-scoped: the documented test-fixture github-pat literal accumulates
|
|
19
|
+
# in Claude Code session transcripts whenever a conversation touches the
|
|
20
|
+
# Pitfall 4 docs or this allowlist itself. Live sessions cannot be
|
|
21
|
+
# safely sed-scrubbed (sed -i renames out from under the running CLI's
|
|
22
|
+
# open file descriptor and silently drops post-rename writes), so the
|
|
23
|
+
# only sustainable false-positive handler is a narrow allowlist scoped
|
|
24
|
+
# to synced session paths. `condition = "AND"` requires BOTH the literal
|
|
25
|
+
# AND a `shared/projects/<logical>/.../*.jsonl` path; a real PAT in the
|
|
26
|
+
# same file (different 36-char body) still fires.
|
|
27
|
+
[[allowlists]]
|
|
28
|
+
description = "claude-nomad: documented test-fixture github-pat literal in synced session transcripts"
|
|
29
|
+
regexes = [
|
|
30
|
+
'''ghp_xJZbT3qfV2nLpKR8mYwH4dGtCsW9aE1uF6oA''',
|
|
31
|
+
]
|
|
32
|
+
paths = [
|
|
33
|
+
'''^shared/projects/[^/]+/.*\.jsonl$''',
|
|
34
|
+
]
|
|
35
|
+
condition = "AND"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.22.1](https://github.com/funkadelic/claude-nomad/compare/v0.22.0...v0.22.1) (2026-05-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
* **gitignore:** anchor .planning/ to source repo root ([#105](https://github.com/funkadelic/claude-nomad/issues/105)) ([50c403d](https://github.com/funkadelic/claude-nomad/commit/50c403d7223f79f30fa28b99ce6e5b2dcc350356))
|
|
9
|
+
|
|
10
|
+
## [0.22.0](https://github.com/funkadelic/claude-nomad/compare/v0.21.0...v0.22.0) (2026-05-23)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
* **extras-sync:** sync per-project .planning/ directories across hosts ([#103](https://github.com/funkadelic/claude-nomad/issues/103)) ([4563fe3](https://github.com/funkadelic/claude-nomad/commit/4563fe32a143dd9a2450baab8ec94a902800c2b3))
|
|
16
|
+
|
|
3
17
|
## [0.21.0](https://github.com/funkadelic/claude-nomad/compare/v0.20.0...v0.21.0) (2026-05-22)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -12,10 +12,11 @@ claude-nomad keeps all of it in sync through a private Git repo you control. `no
|
|
|
12
12
|
|
|
13
13
|
**Who this is for:** anyone running Claude Code on more than one machine. A laptop and a desktop, a Mac and a WSL box, a personal rig and a work machine, or any combination. If you've ever felt the friction of starting fresh on a second machine or copying files around by hand, this is for you.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Three things it does that ad-hoc dotfiles syncing can't:
|
|
16
16
|
|
|
17
17
|
- **Session history survives path differences.** The same project at `/Users/norm/code/foo` on your Mac and `/home/norm/foo` on Linux gets remapped automatically, so `claude --resume` finds your past conversations on whichever machine you're on.
|
|
18
18
|
- **Per-host settings via deep merge.** Shared defaults live in one file; machine-specific overrides (model choice, MCP server URLs, env vars, hooks) live in a per-host file. They're merged on every pull instead of overwriting each other.
|
|
19
|
+
- **Per-project content rides along, opt-in.** Whitelisted directories at a project's root (declared via `path-map.json`'s `extras` field) sync alongside session transcripts, so project-attached state like `.planning/` follows you across hosts. Off by default; projects without an `extras` entry behave exactly as before.
|
|
19
20
|
|
|
20
21
|
## Table of contents
|
|
21
22
|
|
|
@@ -114,7 +115,8 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
114
115
|
│ ├── rules/
|
|
115
116
|
│ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
|
|
116
117
|
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, .env
|
|
117
|
-
│
|
|
118
|
+
│ ├── projects/ # session transcripts under logical names
|
|
119
|
+
│ └── extras/ # opt-in per-project content (materializes when path-map.json declares extras)
|
|
118
120
|
├── hosts/
|
|
119
121
|
│ ├── <your-mac>.json # patches merged over settings.base.json
|
|
120
122
|
│ ├── <your-wsl-host>.json
|
|
@@ -125,13 +127,14 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
125
127
|
|
|
126
128
|
## What gets synced vs. not
|
|
127
129
|
|
|
128
|
-
| Category
|
|
129
|
-
|
|
|
130
|
-
| **Synced**
|
|
131
|
-
| **Generated**
|
|
132
|
-
| **Remapped**
|
|
133
|
-
| **
|
|
134
|
-
| **
|
|
130
|
+
| Category | Items | Behavior |
|
|
131
|
+
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
132
|
+
| **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
|
|
133
|
+
| **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
|
|
134
|
+
| **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
|
|
135
|
+
| **Per-project extras** | `<localRoot>/.planning/` and other directories whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts` | Opt-in via the `extras` field in `path-map.json`. Mirrored to/from `shared/extras/<logical>/<dirname>/`. Pre-pull divergence WARN flags local edits before they get overwritten. |
|
|
136
|
+
| **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
|
|
137
|
+
| **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Plugin payloads not synced. Claude Code re-downloads them on first use from the `enabledPlugins` list in the regenerated `settings.json`; no manual `claude plugins install ...` per host. |
|
|
135
138
|
|
|
136
139
|
> [!NOTE]
|
|
137
140
|
> Plugins that depend on host-specific state (external binaries, API keys in env, MCP server URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's own per-host config.
|
|
@@ -142,7 +145,7 @@ For the rationale behind these choices, see [What does NOT sync (deliberate trad
|
|
|
142
145
|
|
|
143
146
|
The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-path>/` where the encoded path is the absolute path with `/` replaced by `-`. So the same logical project ends up in different directories on each host.
|
|
144
147
|
|
|
145
|
-
`path-map.json` defines logical names and where the repo lives on each host:
|
|
148
|
+
`path-map.json` defines logical names and where the repo lives on each host. The optional `extras` block opts a project into syncing whitelisted directories at its root:
|
|
146
149
|
|
|
147
150
|
```json
|
|
148
151
|
{
|
|
@@ -152,6 +155,9 @@ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-pa
|
|
|
152
155
|
"<your-wsl-host>": "/home/you/code/ha-acwd",
|
|
153
156
|
"<your-nuc>": "TBD"
|
|
154
157
|
}
|
|
158
|
+
},
|
|
159
|
+
"extras": {
|
|
160
|
+
"ha-acwd": [".planning"]
|
|
155
161
|
}
|
|
156
162
|
}
|
|
157
163
|
```
|
|
@@ -163,6 +169,8 @@ Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull`
|
|
|
163
169
|
|
|
164
170
|
On `push`, sessions in `~/.claude/projects/-Users-you-code-ha-acwd/` get copied to `shared/projects/ha-acwd/`. On `pull` on another machine, they get copied to that host's encoded path. `claude --resume` then finds them (see [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs) for the cross-OS cwd-binding gotcha).
|
|
165
171
|
|
|
172
|
+
The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory names validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in directories at `<localRoot>/<dirname>/` are copied to `shared/extras/<logical>/<dirname>/` and inherit the staged-tree gitleaks scan. On `pull`, the reverse copy runs after `git pull --rebase`; just before it overwrites your working tree, a divergence check compares the incoming content against your local copy and emits a per-file WARN naming the diverging files. The existing local content is backed up to `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/<rel>/` before the pull copy lands (`<encoded-localRoot>` is the `localRoot` with `/` rewritten as `-`, so two opted-in projects with the same relative extras path do not collide in one backup run).
|
|
173
|
+
|
|
166
174
|
## Per-host overrides
|
|
167
175
|
|
|
168
176
|
`settings.base.json` holds portable defaults (model, permissions, plugins). `hosts/<NOMAD_HOST>.json` holds machine-specific patches. They're deep-merged on every pull (scalars override, objects merge recursively, arrays replace). Keys that used to be force-marked per-host because they embedded absolute paths (`statusLine.command`, `hooks`) can live in base if you write the commands with `$HOME` (e.g. `"command": "node \"$HOME/.claude/my-statusline.cjs\""`); Claude Code runs them through a shell so shell expansion applies. Reserve per-host files for truly machine-specific values (env, MCP URLs, host-only model overrides).
|
|
@@ -198,12 +206,13 @@ Read these before adopting so you opt in with eyes open.
|
|
|
198
206
|
- **Manual push/pull.** No file watcher. Shell hooks recommended.
|
|
199
207
|
- **OAuth doesn't sync.** You'll log in once per host. Intentional.
|
|
200
208
|
- **Only sessions in `path-map.json` are remapped.** Drive-by sessions on un-mapped paths are left alone.
|
|
209
|
+
- **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected. Dirnames outside `SUPPORTED_EXTRAS` are skipped with a `skip ... not in SUPPORTED_EXTRAS` log line so an unrecognized name cannot widen the sync surface. Unsafe path-map values (path-traversal in `logical` keys, non-absolute or unnormalized `localRoot` values) FATAL before any filesystem mutation via `assertSafeLogical` / `assertSafeLocalRoot` in `src/extras-sync.ts`.
|
|
201
210
|
- **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so the picker's `cd ... && claude --resume <id>` line fails on a different host. Use `nomad doctor --resume-cmd <id>` for a host-local equivalent (see [Cross-OS resume](#cross-os-resume)). The sidecar approach preserves transcript byte-equality.
|
|
202
211
|
- **Empty directories don't survive sync.** Git doesn't track empty dirs; `nomad doctor` reports them as `missing` (benign). Drop a `.gitkeep` to force materialization.
|
|
203
212
|
|
|
204
213
|
## Requirements
|
|
205
214
|
|
|
206
|
-
- Node.js 22.22.1 or newer (24 LTS recommended; the npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes
|
|
215
|
+
- Node.js 22.22.1 or newer (24 LTS recommended; the npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes - npm only blocks the install when `engine-strict=true` is configured)
|
|
207
216
|
- `tsx` (ships as a runtime dependency of the published package; no separate global install required)
|
|
208
217
|
- Git
|
|
209
218
|
- A **private** GitHub repo (or any Git remote you control)
|
|
@@ -354,10 +363,10 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
|
|
|
354
363
|
| `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
|
|
355
364
|
| `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
|
|
356
365
|
| `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
|
|
357
|
-
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths. FATAL if scaffold missing.
|
|
366
|
+
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing. |
|
|
358
367
|
| `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
|
|
359
368
|
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
360
|
-
| `nomad push` | Export local sessions to logical names, commit (`chore: sync from <NOMAD_HOST>`), push.
|
|
369
|
+
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
361
370
|
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
|
|
362
371
|
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows). |
|
|
363
372
|
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
@@ -435,7 +444,7 @@ The file extends the default gitleaks ruleset, so real high-entropy secrets like
|
|
|
435
444
|
[extend]
|
|
436
445
|
useDefault = true
|
|
437
446
|
|
|
438
|
-
[
|
|
447
|
+
[[allowlists]]
|
|
439
448
|
description = "claude-nomad: structurally-distinguishable tool-output noise"
|
|
440
449
|
regexes = [
|
|
441
450
|
'''AY[A-Za-z0-9_-]{20,}''',
|
package/package.json
CHANGED
package/src/commands.pull.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { HOME, HOST, REPO_HOME } from './config.ts';
|
|
5
|
+
import { divergenceCheckExtras, remapExtrasPull } from './extras-sync.ts';
|
|
5
6
|
import { applySharedLinks, regenerateSettings } from './links.ts';
|
|
6
7
|
import { computePreview } from './preview.ts';
|
|
7
8
|
import { remapPull } from './remap.ts';
|
|
@@ -12,18 +13,23 @@ import { acquireLock, die, fail, freshBackupTs, gitOrFatal, log, NomadFatal, rel
|
|
|
12
13
|
/**
|
|
13
14
|
* `nomad pull` command. Acquires the push/pull lock, takes a backup
|
|
14
15
|
* timestamp, runs `git pull --rebase --autostash` in `REPO_HOME`, then
|
|
15
|
-
* applies the
|
|
16
|
-
* 1. `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* applies the side-effecting sync steps in order:
|
|
17
|
+
* 1. `divergenceCheckExtras` (read-only WARN naming local files that
|
|
18
|
+
* diverge from origin; fires in BOTH wet and dry modes per D-08)
|
|
19
|
+
* 2. `applySharedLinks` (symlink shared/* into ~/.claude/)
|
|
20
|
+
* 3. `regenerateSettings` (deep-merge base + host-override into settings.json)
|
|
21
|
+
* 4. `remapPull` (copy repo-side session transcripts into host-encoded dirs)
|
|
22
|
+
* 5. `remapExtrasPull` (copy `shared/extras/<logical>/<dirname>/` back
|
|
23
|
+
* into each project's localRoot; SKIPPED under dryRun)
|
|
19
24
|
*
|
|
20
25
|
* `opts.dryRun` (default `false`): when `true`, the lock IS still acquired
|
|
21
26
|
* and `git pull --rebase` still runs (so concurrent invocations cannot race
|
|
22
|
-
* and
|
|
23
|
-
*
|
|
24
|
-
* steps. The per-run
|
|
25
|
-
* `~/.cache/claude-nomad/backup/<ts>/` is
|
|
26
|
-
* backups are written under dryRun and an
|
|
27
|
+
* and the user sees the same network round-trip as a real pull).
|
|
28
|
+
* `divergenceCheckExtras` still fires (read-only by design). Then
|
|
29
|
+
* `computePreview` runs in place of the four mutating steps. The per-run
|
|
30
|
+
* backup directory under `~/.cache/claude-nomad/backup/<ts>/` is
|
|
31
|
+
* intentionally NOT created (no backups are written under dryRun and an
|
|
32
|
+
* empty dir would pollute the cache).
|
|
27
33
|
*
|
|
28
34
|
* Any `NomadFatal` thrown along the way is caught here so the `finally` block
|
|
29
35
|
* releases the lock before exit (a raw `process.exit()` would skip `finally`
|
|
@@ -62,16 +68,28 @@ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
|
|
|
62
68
|
}
|
|
63
69
|
log(`pulling on host=${HOST} (backup=${ts}${dryRun ? '; dry-run' : ''})`);
|
|
64
70
|
gitOrFatal(['pull', '--rebase', '--autostash'], 'git pull --rebase', REPO_HOME);
|
|
71
|
+
// Read-only pre-pull check: fires in BOTH wet and dry modes (D-08).
|
|
72
|
+
// Runs AFTER the rebase (so origin content is fetched) and BEFORE any
|
|
73
|
+
// mutation (so local state is intact for byte-level comparison). The
|
|
74
|
+
// function itself silently skips when no `extras` key is declared.
|
|
75
|
+
divergenceCheckExtras(ts);
|
|
65
76
|
if (dryRun) {
|
|
66
77
|
const previewResult = computePreview(ts);
|
|
78
|
+
// dryRun deliberately omits remapExtrasPull to preserve the
|
|
79
|
+
// zero-mutation contract; users still see the divergence WARN above.
|
|
67
80
|
log('dry-run complete; no mutation');
|
|
68
81
|
emitSummary('pull', previewResult.unmapped);
|
|
69
82
|
} else {
|
|
70
83
|
applySharedLinks(ts);
|
|
71
84
|
regenerateSettings(ts);
|
|
72
85
|
const remapResult = remapPull(ts);
|
|
86
|
+
const extrasResult = remapExtrasPull(ts);
|
|
73
87
|
log('pull complete');
|
|
74
|
-
|
|
88
|
+
// Combine session-unmapped and extras-unmapped into one user-visible
|
|
89
|
+
// count; from the operator's perspective both mean "couldn't sync this
|
|
90
|
+
// for the host". extras-skipped (non-whitelisted dirname) stays
|
|
91
|
+
// separate because it signals config misuse, not a host-config gap.
|
|
92
|
+
emitSummary('pull', remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped);
|
|
75
93
|
}
|
|
76
94
|
} catch (err) {
|
|
77
95
|
// Catch fatal errors here so the finally block runs and releases the
|
package/src/commands.push.ts
CHANGED
|
@@ -2,13 +2,14 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
|
|
4
4
|
// prettier-ignore
|
|
5
|
-
import { HOME, HOST, NEVER_SYNC, PUSH_ALLOWED_STATIC, REPO_HOME, type PathMap } from './config.ts';
|
|
5
|
+
import { HOME, HOST, NEVER_SYNC, PUSH_ALLOWED_STATIC, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
|
|
6
|
+
import { remapExtrasPush } from './extras-sync.ts';
|
|
6
7
|
import { findGitlinks, probeGitleaks, rebaseBeforePush } from './push-checks.ts';
|
|
7
8
|
import { runGitleaksScan } from './push-gitleaks.ts';
|
|
8
9
|
import { remapPush } from './remap.ts';
|
|
9
10
|
import { emitSummary } from './summary.ts';
|
|
10
11
|
// prettier-ignore
|
|
11
|
-
import { acquireLock, die, fail, freshBackupTs, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal,
|
|
12
|
+
import { acquireLock, die, fail, freshBackupTs, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, readPathMap, releaseLock } from './utils.ts';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Match `path` against an entry in the push allow-list. Exact match for
|
|
@@ -29,8 +30,16 @@ function isAllowed(path: string, allowed: readonly string[]): boolean {
|
|
|
29
30
|
return false;
|
|
30
31
|
}
|
|
31
32
|
|
|
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
|
+
*/
|
|
33
41
|
function isNeverSync(path: string): boolean {
|
|
42
|
+
if (path.startsWith('shared/extras/')) return false;
|
|
34
43
|
for (const segment of path.split('/')) {
|
|
35
44
|
if (NEVER_SYNC.has(segment)) return true;
|
|
36
45
|
}
|
|
@@ -76,14 +85,23 @@ export function parsePorcelainZ(statusPorcelain: string): string[] {
|
|
|
76
85
|
* Reject any staged path that is not on the push allow-list or that matches a
|
|
77
86
|
* `NEVER_SYNC` entry. Builds the runtime allow-list by combining
|
|
78
87
|
* `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
|
|
79
|
-
* in `path-map.json
|
|
88
|
+
* in `path-map.json` AND one `shared/extras/<logical>/<dirname>/` prefix per
|
|
89
|
+
* (logical, whitelisted dirname) pair in `map.extras ?? {}` (Pitfall 4 closed:
|
|
90
|
+
* data-driven, no hand-rolled bypass). The dirname filter (`SUPPORTED_EXTRAS`)
|
|
91
|
+
* is the same one `remapExtrasPush` honors, so manually staged content under a
|
|
92
|
+
* non-whitelisted dirname surfaces as a FATAL instead of riding through on the
|
|
93
|
+
* logical-only prefix. Logs every violation as a FATAL line so the user sees
|
|
80
94
|
* the full set (not just the first), then throws `NomadFatal` to unwind the
|
|
81
95
|
* caller's try/finally and release the push lock.
|
|
82
96
|
*/
|
|
83
97
|
export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
|
|
98
|
+
const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
84
99
|
const allowed = [
|
|
85
100
|
...PUSH_ALLOWED_STATIC,
|
|
86
101
|
...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
|
|
102
|
+
...Object.entries(map.extras ?? {}).flatMap(([l, dirnames]) =>
|
|
103
|
+
dirnames.filter((d) => extrasWhitelist.includes(d)).map((d) => `shared/extras/${l}/${d}/`),
|
|
104
|
+
),
|
|
87
105
|
];
|
|
88
106
|
const neverSyncHits: string[] = [];
|
|
89
107
|
const violations: string[] = [];
|
|
@@ -111,11 +129,13 @@ export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
|
|
|
111
129
|
* 2. `rebaseBeforePush` (surface remote conflicts against committed state,
|
|
112
130
|
* not against in-flight `remapPush` copies)
|
|
113
131
|
* 3. `remapPush` (copy host-encoded session dirs into shared logical names)
|
|
114
|
-
* 4. `
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* 6.
|
|
132
|
+
* 4. `remapExtrasPush` (copy whitelisted per-project extras under
|
|
133
|
+
* `shared/extras/<logical>/<dirname>/`, between `remapPush` and the
|
|
134
|
+
* gitlink walk so produced paths reach both the walk and the allow-list)
|
|
135
|
+
* 5. `findGitlinks` walk of `shared/` (refuse to push nested .git entries)
|
|
136
|
+
* 6. allow-list enforcement on the resulting `git status` (runtime
|
|
137
|
+
* `shared/extras/<logical>/` prefix per declared logical added)
|
|
138
|
+
* 7. `git add -A` -> `runGitleaksScan` on staged tree -> `git commit` -> `git push`
|
|
119
139
|
*
|
|
120
140
|
* The gitleaks scan runs AFTER staging so it sees what would actually be
|
|
121
141
|
* pushed, but BEFORE commit so a detection unwinds cleanly without leaving a
|
|
@@ -149,6 +169,11 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
|
149
169
|
// remapPush runs BEFORE the empty-status check: it produces the diffs status
|
|
150
170
|
// observes, so swapping the order would short-circuit before anything is staged.
|
|
151
171
|
const remapResult = remapPush(ts, { dryRun });
|
|
172
|
+
// remapExtrasPush lands between remapPush and findGitlinks so the
|
|
173
|
+
// produced `shared/extras/<logical>/<dirname>/` paths are visible to
|
|
174
|
+
// both the gitlink walk and the downstream allow-list classification.
|
|
175
|
+
// dryRun is forwarded so a preview push reports the same skipped count.
|
|
176
|
+
const extrasResult = remapExtrasPush(ts, { dryRun });
|
|
152
177
|
// Gitlink walk of shared/ AFTER remapPush so it inspects the post-copy tree.
|
|
153
178
|
// A nested .git copied in from a host's encoded session dir would slip past a
|
|
154
179
|
// pre-remap scan and reach the remote via the shared/projects/<logical>/ prefix.
|
|
@@ -171,25 +196,34 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
|
171
196
|
const status = gitStatusPorcelainZ(REPO_HOME);
|
|
172
197
|
if (!status) {
|
|
173
198
|
log('nothing to commit');
|
|
174
|
-
|
|
199
|
+
// Combine session-unmapped and extras-unmapped into one user-visible
|
|
200
|
+
// count; both mean "couldn't sync this for the host". extras-skipped
|
|
201
|
+
// (non-whitelisted dirname) stays separate because it signals config
|
|
202
|
+
// misuse, not a host-config gap.
|
|
203
|
+
emitSummary(
|
|
204
|
+
'push',
|
|
205
|
+
remapResult.unmapped + extrasResult.unmapped,
|
|
206
|
+
remapResult.collisions,
|
|
207
|
+
extrasResult.skipped,
|
|
208
|
+
);
|
|
175
209
|
return;
|
|
176
210
|
}
|
|
177
211
|
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
178
212
|
if (!existsSync(mapPath)) die('path-map.json missing, cannot enforce push allow-list');
|
|
179
|
-
//
|
|
180
|
-
|
|
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
|
-
}
|
|
213
|
+
// readPathMap routes parse failures through NomadFatal so finally releases the lock.
|
|
214
|
+
const map = readPathMap(mapPath);
|
|
186
215
|
enforceAllowList(status, map);
|
|
187
216
|
if (dryRun) {
|
|
188
217
|
// Skip the staging quartet so no commit lands and nothing is pushed.
|
|
189
218
|
// The user has already seen probeGitleaks pass, the rebase result, the
|
|
190
219
|
// remap preview, the gitlink scan, and the allow-list classification.
|
|
191
220
|
log('push: dry-run; skipping git add, gitleaks scan, commit, and push');
|
|
192
|
-
emitSummary(
|
|
221
|
+
emitSummary(
|
|
222
|
+
'push',
|
|
223
|
+
remapResult.unmapped + extrasResult.unmapped,
|
|
224
|
+
remapResult.collisions,
|
|
225
|
+
extrasResult.skipped,
|
|
226
|
+
);
|
|
193
227
|
return;
|
|
194
228
|
}
|
|
195
229
|
// gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
|
|
@@ -201,7 +235,12 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
|
201
235
|
gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
|
|
202
236
|
gitOrFatal(['push'], 'git push', REPO_HOME);
|
|
203
237
|
log('push complete');
|
|
204
|
-
emitSummary(
|
|
238
|
+
emitSummary(
|
|
239
|
+
'push',
|
|
240
|
+
remapResult.unmapped + extrasResult.unmapped,
|
|
241
|
+
remapResult.collisions,
|
|
242
|
+
extrasResult.skipped,
|
|
243
|
+
);
|
|
205
244
|
} catch (err) {
|
|
206
245
|
if (err instanceof NomadFatal) {
|
|
207
246
|
fail(err.message);
|
package/src/config.ts
CHANGED
|
@@ -57,6 +57,18 @@ export const SHARED_LINKS = [
|
|
|
57
57
|
'my-statusline.cjs',
|
|
58
58
|
] as const;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Whitelist of directory names allowed in `path-map.json`'s top-level
|
|
62
|
+
* `extras` field. Gates the named-extras opt-in mechanism: only entries
|
|
63
|
+
* appearing in this list are eligible for sync. Initial set contains
|
|
64
|
+
* `.planning` only; widening to include `.notes`, `.scratch`, etc. is a
|
|
65
|
+
* one-line edit here with no schema migration required (the field is
|
|
66
|
+
* additive on the consumer side). Mirrors `SHARED_LINKS` in shape and
|
|
67
|
+
* intent: a short, append-only `as const` tuple that downstream callers
|
|
68
|
+
* narrow against.
|
|
69
|
+
*/
|
|
70
|
+
export const SUPPORTED_EXTRAS = ['.planning'] as const;
|
|
71
|
+
|
|
60
72
|
/**
|
|
61
73
|
* Path segments that must never cross the sync boundary in either direction.
|
|
62
74
|
* Defense-in-depth pair with `PUSH_ALLOWED_STATIC`: even if the allow-list
|
|
@@ -146,5 +158,15 @@ export const PUSH_ALLOWED_STATIC = [
|
|
|
146
158
|
* against `HOST`) to the absolute path the project lives at on that host. Use
|
|
147
159
|
* the literal string `'TBD'` as a placeholder while a host has not yet cloned
|
|
148
160
|
* the project; `remapPull` / `remapPush` skip `'TBD'` entries.
|
|
161
|
+
*
|
|
162
|
+
* Optional `extras` field (additive, top-level): opt-in per-project
|
|
163
|
+
* named-directory sync. Keyed by the same logical project name used in
|
|
164
|
+
* `projects`; values are arrays of directory names validated by downstream
|
|
165
|
+
* consumers against `SUPPORTED_EXTRAS`. Absence of the field is equivalent
|
|
166
|
+
* to no extras for any project; legacy `path-map.json` files without an
|
|
167
|
+
* `extras` block continue to work unchanged (no migration required).
|
|
149
168
|
*/
|
|
150
|
-
export type PathMap = {
|
|
169
|
+
export type PathMap = {
|
|
170
|
+
projects: Record<string, Record<string, string>>;
|
|
171
|
+
extras?: Record<string, string[]>;
|
|
172
|
+
};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, normalize } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS } from './config.ts';
|
|
6
|
+
// prettier-ignore
|
|
7
|
+
import { backupExtrasWrite, backupRepoWrite, encodePath, log, NomadFatal, readPathMap, warn } from './utils.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `logical` keys in `path-map.json` are project identifiers (e.g. `ha-acwd`,
|
|
11
|
+
* `foo`), never path fragments. A crafted key like `../escape` or `foo/bar`
|
|
12
|
+
* would escape `shared/extras/` via `join()` (which normalizes `..`) and land
|
|
13
|
+
* content somewhere unexpected on the filesystem. The push allow-list catches
|
|
14
|
+
* such commits at the `git add` boundary, but the filesystem mutation has
|
|
15
|
+
* already happened by then. This check fails fast before any write. The
|
|
16
|
+
* pattern matches what every reasonable project name looks like and rejects
|
|
17
|
+
* everything else; tighten only if a real project needs broader characters.
|
|
18
|
+
*/
|
|
19
|
+
const SAFE_LOGICAL = /^[A-Za-z0-9._-]+$/;
|
|
20
|
+
function assertSafeLogical(logical: string): void {
|
|
21
|
+
if (!SAFE_LOGICAL.test(logical) || logical === '.' || logical === '..') {
|
|
22
|
+
throw new NomadFatal(
|
|
23
|
+
`invalid logical name in path-map.json extras: ${JSON.stringify(logical)} (must match [A-Za-z0-9._-]+; no path separators or '..')`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reject `localRoot` values that contain unnormalized segments (`..`,
|
|
30
|
+
* redundant `/.`, trailing slashes that don't survive `normalize`). A
|
|
31
|
+
* poisoned `path-map.json` with `host: '/tmp/x/../escape'` would silently
|
|
32
|
+
* land writes at `/tmp/escape/.planning/` because `path.join` normalizes
|
|
33
|
+
* `..` before `cpSync` sees the destination. The user thinks they declared
|
|
34
|
+
* one path and got another. Requiring `localRoot === normalize(localRoot)`
|
|
35
|
+
* (and an absolute path on top) catches the obvious traversal trick and
|
|
36
|
+
* forces poisoned-map writes to surface as a FATAL before any filesystem
|
|
37
|
+
* mutation. Same defense-in-depth shape as `assertSafeLogical`.
|
|
38
|
+
*/
|
|
39
|
+
function assertSafeLocalRoot(localRoot: string, logical: string): void {
|
|
40
|
+
if (!isAbsolute(localRoot)) {
|
|
41
|
+
throw new NomadFatal(
|
|
42
|
+
`invalid localRoot for ${logical} in path-map.json: ${JSON.stringify(localRoot)} (must be absolute)`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (localRoot !== normalize(localRoot)) {
|
|
46
|
+
throw new NomadFatal(
|
|
47
|
+
`invalid localRoot for ${logical} in path-map.json: ${JSON.stringify(localRoot)} (must be already-normalized; no '..' or redundant segments)`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Recursive mirror copy: `rmSync` then `cpSync` so dst-only entries are
|
|
54
|
+
* removed (true mirror, not just overwrite). Passes `verbatimSymlinks: true`
|
|
55
|
+
* to keep relative symlink targets unrewritten across hosts (Pitfall 1;
|
|
56
|
+
* nodejs/node issue 41693). Exported so the test file can call it directly;
|
|
57
|
+
* `remapExtrasPush` and `remapExtrasPull` are the primary public API.
|
|
58
|
+
*/
|
|
59
|
+
export function copyExtras(src: string, dst: string): void {
|
|
60
|
+
rmSync(dst, { recursive: true, force: true });
|
|
61
|
+
cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Push: copy whitelisted extras directories under each project's localRoot
|
|
66
|
+
* into the repo at `shared/extras/<logical>/<dirname>/`. Returns
|
|
67
|
+
* `{ unmapped, skipped }` with intentionally asymmetric granularity:
|
|
68
|
+
* `unmapped` is per-project (one increment per `logical` with no host path,
|
|
69
|
+
* which short-circuits before its dirnames are visited) and `skipped` is
|
|
70
|
+
* per-dirname (one increment per non-whitelisted entry inside an otherwise
|
|
71
|
+
* mapped project). Both counts feed `emitSummary`; the asymmetry mirrors
|
|
72
|
+
* the underlying skip-loop control flow (outer per-logical, inner
|
|
73
|
+
* per-dirname) and matches what an operator wants to see in the summary
|
|
74
|
+
* line. `opts.dryRun` logs `would push extras:` lines without writing,
|
|
75
|
+
* with identical count semantics. Legacy `path-map.json` without an
|
|
76
|
+
* `extras` key returns `{ unmapped: 0, skipped: 0 }` cleanly.
|
|
77
|
+
*/
|
|
78
|
+
export function remapExtrasPush(
|
|
79
|
+
ts: string,
|
|
80
|
+
opts: { dryRun?: boolean } = {},
|
|
81
|
+
): { unmapped: number; skipped: number } {
|
|
82
|
+
const dryRun = opts.dryRun === true;
|
|
83
|
+
let unmapped = 0;
|
|
84
|
+
let skipped = 0;
|
|
85
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
86
|
+
if (!existsSync(mapPath)) {
|
|
87
|
+
log('no path-map.json; skipping extras push');
|
|
88
|
+
return { unmapped: 0, skipped: 0 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const map = readPathMap(mapPath);
|
|
92
|
+
const extrasMap = map.extras ?? {};
|
|
93
|
+
if (Object.keys(extrasMap).length === 0) return { unmapped: 0, skipped: 0 };
|
|
94
|
+
|
|
95
|
+
// Validation pass: FATAL on any poisoned logical or unnormalized
|
|
96
|
+
// localRoot before any filesystem mutation. Runs over the entire extras
|
|
97
|
+
// map up-front so the documented "FATAL before any filesystem mutation"
|
|
98
|
+
// contract holds even when a clean entry sits ahead of a poisoned one in
|
|
99
|
+
// the iteration order (otherwise `mkdirSync(shared/extras/)` and the
|
|
100
|
+
// first cpSync would already have landed before the FATAL fired).
|
|
101
|
+
for (const logical of Object.keys(extrasMap)) {
|
|
102
|
+
assertSafeLogical(logical);
|
|
103
|
+
const localRoot = map.projects[logical]?.[HOST];
|
|
104
|
+
if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const repoExtras = join(REPO_HOME, 'shared', 'extras');
|
|
108
|
+
if (!dryRun) mkdirSync(repoExtras, { recursive: true });
|
|
109
|
+
|
|
110
|
+
const whitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
111
|
+
|
|
112
|
+
for (const [logical, dirnames] of Object.entries(extrasMap)) {
|
|
113
|
+
const localRoot = map.projects[logical]?.[HOST];
|
|
114
|
+
if (!localRoot || localRoot === 'TBD') {
|
|
115
|
+
unmapped++;
|
|
116
|
+
log(`skip ${logical}: no path for ${HOST}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const dirname of dirnames) {
|
|
120
|
+
if (!whitelist.includes(dirname)) {
|
|
121
|
+
skipped++;
|
|
122
|
+
log(`skip ${dirname} for ${logical}: not in SUPPORTED_EXTRAS`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const src = join(localRoot, dirname);
|
|
126
|
+
if (!existsSync(src)) continue;
|
|
127
|
+
const dst = join(repoExtras, logical, dirname);
|
|
128
|
+
if (dryRun) {
|
|
129
|
+
log(`would push extras: ${src} -> ${dst}`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
backupRepoWrite(dst, ts, REPO_HOME);
|
|
133
|
+
copyExtras(src, dst);
|
|
134
|
+
log(`pushed extras ${logical}/${dirname} -> shared/extras/${logical}/${dirname}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { unmapped, skipped };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pull: copy whitelisted extras from `shared/extras/<logical>/<dirname>/`
|
|
142
|
+
* back into each project's localRoot on this host. Returns `{ unmapped,
|
|
143
|
+
* skipped }` with the same asymmetric granularity as `remapExtrasPush`:
|
|
144
|
+
* `unmapped` per-project, `skipped` per-dirname. `opts.dryRun` logs `would
|
|
145
|
+
* overwrite extras:` lines without writing. Uses `backupExtrasWrite` (not
|
|
146
|
+
* `backupBeforeWrite`) because `<localRoot>/<dirname>` lives outside
|
|
147
|
+
* `CLAUDE_HOME` and the standard helper's relative-path guard would no-op
|
|
148
|
+
* and lose prior content. Legacy `path-map.json` without an `extras` key,
|
|
149
|
+
* or a missing `shared/extras/`, both produce a clean no-op.
|
|
150
|
+
*/
|
|
151
|
+
export function remapExtrasPull(
|
|
152
|
+
ts: string,
|
|
153
|
+
opts: { dryRun?: boolean } = {},
|
|
154
|
+
): { unmapped: number; skipped: number } {
|
|
155
|
+
const dryRun = opts.dryRun === true;
|
|
156
|
+
let unmapped = 0;
|
|
157
|
+
let skipped = 0;
|
|
158
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
159
|
+
const repoExtras = join(REPO_HOME, 'shared', 'extras');
|
|
160
|
+
if (!existsSync(mapPath) || !existsSync(repoExtras)) {
|
|
161
|
+
log('no path-map or repo extras dir; skipping extras remap');
|
|
162
|
+
return { unmapped: 0, skipped: 0 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const map = readPathMap(mapPath);
|
|
166
|
+
const extrasMap = map.extras ?? {};
|
|
167
|
+
if (Object.keys(extrasMap).length === 0) return { unmapped: 0, skipped: 0 };
|
|
168
|
+
|
|
169
|
+
// Validation pass: FATAL on any poisoned logical or unnormalized
|
|
170
|
+
// localRoot before any host-side `backupExtrasWrite` or `copyExtras`
|
|
171
|
+
// runs. Symmetric with `remapExtrasPush`: a poisoned entry anywhere in
|
|
172
|
+
// the map must fail the whole pull up-front, otherwise a clean entry
|
|
173
|
+
// earlier in iteration order would already have clobbered the host
|
|
174
|
+
// before the FATAL fired (partial host-side mutation breaks the
|
|
175
|
+
// documented "fail before mutation" contract).
|
|
176
|
+
for (const logical of Object.keys(extrasMap)) {
|
|
177
|
+
assertSafeLogical(logical);
|
|
178
|
+
const localRoot = map.projects[logical]?.[HOST];
|
|
179
|
+
if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const whitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
183
|
+
|
|
184
|
+
for (const [logical, dirnames] of Object.entries(extrasMap)) {
|
|
185
|
+
const localRoot = map.projects[logical]?.[HOST];
|
|
186
|
+
if (!localRoot || localRoot === 'TBD') {
|
|
187
|
+
unmapped++;
|
|
188
|
+
log(`skip ${logical}: no path for ${HOST}`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (const dirname of dirnames) {
|
|
192
|
+
if (!whitelist.includes(dirname)) {
|
|
193
|
+
skipped++;
|
|
194
|
+
log(`skip ${dirname} for ${logical}: not in SUPPORTED_EXTRAS`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const src = join(repoExtras, logical, dirname);
|
|
198
|
+
if (!existsSync(src)) continue;
|
|
199
|
+
const dst = join(localRoot, dirname);
|
|
200
|
+
|
|
201
|
+
if (dryRun) {
|
|
202
|
+
log(`would overwrite extras: ${dst} (from ${src})`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Snapshot the host-side dst BEFORE copyExtras clobbers it. Anchor
|
|
206
|
+
// on localRoot so the backup tree mirrors the project layout.
|
|
207
|
+
backupExtrasWrite(dst, ts, localRoot);
|
|
208
|
+
copyExtras(src, dst);
|
|
209
|
+
log(`pulled extras ${logical}/${dirname} -> ${dst}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { unmapped, skipped };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* List files that differ between two paths via `git diff --no-index
|
|
217
|
+
* --name-only`. Exit 0 = identical, exit 1 = differences exist (not an
|
|
218
|
+
* error: read names from `e.stdout`). Other failures are surfaced via WARN
|
|
219
|
+
* so the operator can tell the difference between "no diff" (silent),
|
|
220
|
+
* "git not on PATH" (WARN), and other git failures (WARN) instead of all
|
|
221
|
+
* three paths collapsing to a silent empty list and defeating D-08's
|
|
222
|
+
* loud-doctor contract. Argv-array `execFileSync` (no shell) so paths
|
|
223
|
+
* cannot inject.
|
|
224
|
+
*/
|
|
225
|
+
function listDivergingFiles(a: string, b: string): string[] {
|
|
226
|
+
try {
|
|
227
|
+
const stdout = execFileSync('git', ['diff', '--no-index', '--name-only', a, b], {
|
|
228
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
229
|
+
}).toString();
|
|
230
|
+
return stdout.split('\n').filter((line) => line.length > 0);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
const e = err as NodeJS.ErrnoException & { status?: number; stdout?: Buffer };
|
|
233
|
+
if (e.status === 1 && e.stdout !== undefined) {
|
|
234
|
+
return e.stdout
|
|
235
|
+
.toString()
|
|
236
|
+
.split('\n')
|
|
237
|
+
.filter((line) => line.length > 0);
|
|
238
|
+
}
|
|
239
|
+
if (e.code === 'ENOENT') {
|
|
240
|
+
warn(`git not on PATH; divergence check skipped for ${a}`);
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
warn(`divergence check failed for ${a}: ${e.message ?? String(err)}`);
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Read-only pre-pull check: compare local `<localRoot>/<dirname>/` against
|
|
250
|
+
* the just-pulled `shared/extras/<logical>/<dirname>/` and emit a WARN per
|
|
251
|
+
* diverging file plus a count summary. Runs AFTER `git pull --rebase` and
|
|
252
|
+
* BEFORE `remapExtrasPull` (so local state is intact for comparison).
|
|
253
|
+
* Non-blocking per the inherited LWW model; the WARN message names the
|
|
254
|
+
* per-project `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/`
|
|
255
|
+
* path that `remapExtrasPull` will write to so users can recover the
|
|
256
|
+
* overwritten content. The `<encoded-localRoot>` namespace mirrors
|
|
257
|
+
* `backupExtrasWrite`'s layout so two opted-in projects with the same
|
|
258
|
+
* relative extras path do not collide. Silent skip on missing path-map, no
|
|
259
|
+
* `extras` key, missing or `'TBD'` host path, non-whitelisted dirname, or
|
|
260
|
+
* either side absent.
|
|
261
|
+
*/
|
|
262
|
+
export function divergenceCheckExtras(ts: string): void {
|
|
263
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
264
|
+
if (!existsSync(mapPath)) return;
|
|
265
|
+
|
|
266
|
+
const map = readPathMap(mapPath);
|
|
267
|
+
const extrasMap = map.extras ?? {};
|
|
268
|
+
const whitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
269
|
+
const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'extras');
|
|
270
|
+
for (const [logical, dirnames] of Object.entries(extrasMap)) {
|
|
271
|
+
assertSafeLogical(logical);
|
|
272
|
+
const localRoot = map.projects[logical]?.[HOST];
|
|
273
|
+
if (!localRoot || localRoot === 'TBD') continue;
|
|
274
|
+
assertSafeLocalRoot(localRoot, logical);
|
|
275
|
+
const projectBackupRoot = join(backupRoot, encodePath(localRoot));
|
|
276
|
+
for (const dirname of dirnames) {
|
|
277
|
+
if (!whitelist.includes(dirname)) continue;
|
|
278
|
+
const local = join(localRoot, dirname);
|
|
279
|
+
const repo = join(REPO_HOME, 'shared', 'extras', logical, dirname);
|
|
280
|
+
if (!existsSync(local) || !existsSync(repo)) continue;
|
|
281
|
+
const diff = listDivergingFiles(local, repo);
|
|
282
|
+
if (diff.length > 0) {
|
|
283
|
+
warn(
|
|
284
|
+
`local ${dirname} for ${logical} diverges from origin in ${diff.length} file(s); next remapExtrasPull will overwrite them (backups at ${projectBackupRoot}/)`,
|
|
285
|
+
);
|
|
286
|
+
for (const f of diff) warn(` ${f}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
package/src/summary.ts
CHANGED
|
@@ -4,40 +4,48 @@ import { ok, warn } from './utils.ts';
|
|
|
4
4
|
* Emit the single end-of-run summary line shared by cmdPull, cmdPush, and
|
|
5
5
|
* cmdDiff. Canonical phrasing:
|
|
6
6
|
* - `summary: clean` when nothing was unmapped (and, for push, no
|
|
7
|
-
* collisions). Always printed so users see a consistent
|
|
8
|
-
* and can spot when behavior changes.
|
|
7
|
+
* collisions or extras skipped). Always printed so users see a consistent
|
|
8
|
+
* terminator and can spot when behavior changes.
|
|
9
9
|
* - `summary: <N> unmapped on pull (run nomad doctor to list)`
|
|
10
|
+
* - `summary: <N> unmapped on pull, <X> extras skipped (run nomad doctor to list)`
|
|
10
11
|
* - `summary: <N> unmapped on diff (run nomad doctor to list)`
|
|
11
12
|
* - `summary: <N> unmapped on push, <M> collisions (run nomad doctor to list)`
|
|
13
|
+
* - `summary: <N> unmapped on push, <M> collisions, <X> extras skipped (run nomad doctor to list)`
|
|
12
14
|
*
|
|
13
15
|
* Clean outcomes go through `ok()` (green `✓` glyph, stdout) and unmapped /
|
|
14
|
-
* collision outcomes go through `warn()` (yellow `⚠︎` glyph,
|
|
15
|
-
* status glyph carries the success/warn semantics; users see e.g.
|
|
16
|
+
* collision / extras-skipped outcomes go through `warn()` (yellow `⚠︎` glyph,
|
|
17
|
+
* stderr). The status glyph carries the success/warn semantics; users see e.g.
|
|
16
18
|
* `✓ summary: clean` or `⚠︎ summary: 3 unmapped on pull (...)`. Note: clean
|
|
17
19
|
* still goes to stdout so it survives backgrounded shell-rc invocations
|
|
18
20
|
* like `nomad pull 2>/dev/null &`. `collisions` is meaningful only for
|
|
19
|
-
* `'push'`; for `'pull'` / `'diff'` it is ignored and defaults to 0.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* `'push'`; for `'pull'` / `'diff'` it is ignored and defaults to 0.
|
|
22
|
+
* `extrasSkipped` counts dirnames that the per-project whitelist
|
|
23
|
+
* (`SUPPORTED_EXTRAS`) declined to sync; surfaces from `remapExtrasPush`
|
|
24
|
+
* and `remapExtrasPull`. The fourth positional parameter defaults to 0 so
|
|
25
|
+
* legacy three-arg call sites continue to work unchanged (D-03 additive
|
|
26
|
+
* contract). This module is the SINGLE source of truth for the phrasing,
|
|
27
|
+
* eliminating drift risk across the three callers by construction.
|
|
22
28
|
*/
|
|
23
29
|
export function emitSummary(
|
|
24
30
|
verb: 'pull' | 'push' | 'diff',
|
|
25
31
|
unmapped: number,
|
|
26
32
|
collisions = 0,
|
|
33
|
+
extrasSkipped = 0,
|
|
27
34
|
): void {
|
|
28
35
|
if (verb === 'push') {
|
|
29
|
-
if (unmapped === 0 && collisions === 0) {
|
|
36
|
+
if (unmapped === 0 && collisions === 0 && extrasSkipped === 0) {
|
|
30
37
|
ok('summary: clean');
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
);
|
|
40
|
+
const base = `summary: ${unmapped} unmapped on push, ${collisions} collisions`;
|
|
41
|
+
const extras = extrasSkipped > 0 ? `, ${extrasSkipped} extras skipped` : '';
|
|
42
|
+
warn(`${base}${extras} (run nomad doctor to list)`);
|
|
36
43
|
return;
|
|
37
44
|
}
|
|
38
|
-
if (unmapped === 0) {
|
|
45
|
+
if (unmapped === 0 && extrasSkipped === 0) {
|
|
39
46
|
ok('summary: clean');
|
|
40
47
|
return;
|
|
41
48
|
}
|
|
42
|
-
|
|
49
|
+
const extras = extrasSkipped > 0 ? `, ${extrasSkipped} extras skipped` : '';
|
|
50
|
+
warn(`summary: ${unmapped} unmapped on ${verb}${extras} (run nomad doctor to list)`);
|
|
43
51
|
}
|
package/src/utils.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import { dirname, join, relative } from 'node:path';
|
|
18
18
|
|
|
19
19
|
import { dim, failGlyph, green, infoGlyph, okGlyph, red, warnGlyph, yellow } from './color.ts';
|
|
20
|
-
import { CLAUDE_HOME, HOME } from './config.ts';
|
|
20
|
+
import { CLAUDE_HOME, HOME, type PathMap } from './config.ts';
|
|
21
21
|
|
|
22
22
|
const LOCK_PATH = join(HOME, '.cache', 'claude-nomad', 'nomad.lock');
|
|
23
23
|
|
|
@@ -117,6 +117,29 @@ export function readJson<T>(path: string): T {
|
|
|
117
117
|
return data as T;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Read `path-map.json` and wrap failures as `NomadFatal` so callers route the
|
|
122
|
+
* failure through their `try/finally` lock-release path instead of exposing a
|
|
123
|
+
* raw `SyntaxError` (or `ENOENT`/`EACCES`) past `NomadFatal`-only catch
|
|
124
|
+
* blocks. Equivalent to the inline `try { readJson } catch { throw NomadFatal }`
|
|
125
|
+
* pattern in `cmdPush`; use this helper at every other read site so the
|
|
126
|
+
* lock-release contract holds uniformly across the pipeline.
|
|
127
|
+
*
|
|
128
|
+
* Error verb is conditioned on the cause so ops can distinguish parse
|
|
129
|
+
* failures (malformed JSON) from IO failures (permission denied, file
|
|
130
|
+
* removed mid-run) without scraping the wrapped message. Callers gate on
|
|
131
|
+
* `existsSync(mapPath)` first in the happy path, so an `ENOENT` here means
|
|
132
|
+
* a TOCTOU race rather than the expected absent-file case.
|
|
133
|
+
*/
|
|
134
|
+
export function readPathMap(mapPath: string): PathMap {
|
|
135
|
+
try {
|
|
136
|
+
return readJson<PathMap>(mapPath);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const verb = err instanceof SyntaxError ? 'parse' : 'read';
|
|
139
|
+
throw new NomadFatal(`could not ${verb} path-map.json: ${(err as Error).message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
/**
|
|
121
144
|
* Atomic write: temp + fsync + rename + parent-dir fsync. Survives
|
|
122
145
|
* interrupted pulls. Preserves the destination file's existing mode when it
|
|
@@ -242,6 +265,37 @@ export function backupRepoWrite(absPath: string, ts: string, repoHome: string):
|
|
|
242
265
|
cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
|
|
243
266
|
}
|
|
244
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Parallel of `backupBeforeWrite` and `backupRepoWrite`, scoped to an
|
|
270
|
+
* explicit `projectRoot` instead of `CLAUDE_HOME` or `REPO_HOME`. Used by
|
|
271
|
+
* `remapExtrasPull` to snapshot host-side extras content (e.g.
|
|
272
|
+
* `<localRoot>/.planning/`) before `copyExtras` clobbers it. The existing
|
|
273
|
+
* helpers cannot serve this case: their `relative(CLAUDE_HOME, absPath)` and
|
|
274
|
+
* `relative(repoHome, absPath)` guards return a `..`-prefixed string for any
|
|
275
|
+
* path outside their anchor and silently no-op, so a pull-side
|
|
276
|
+
* `<localRoot>/.planning/` would never be backed up.
|
|
277
|
+
*
|
|
278
|
+
* Backup root is `extras/`-prefixed inside the same `<ts>` dir so the
|
|
279
|
+
* snapshot is distinguishable from `CLAUDE_HOME` dumps (no prefix) and
|
|
280
|
+
* `repo/` dumps. Layout:
|
|
281
|
+
* `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-projectRoot>/<rel>/`
|
|
282
|
+
* where `<rel>` is `relative(projectRoot, absPath)` and
|
|
283
|
+
* `<encoded-projectRoot>` is `encodePath(projectRoot)`. The encoded prefix
|
|
284
|
+
* namespaces snapshots by project so two opted-in projects with the same
|
|
285
|
+
* relative extras path (e.g. both with `.planning/PLAN.md`) cannot collide
|
|
286
|
+
* inside the same `<ts>` directory (`cpSync` runs with `force: false`, so a
|
|
287
|
+
* collision would silently drop the second snapshot).
|
|
288
|
+
*/
|
|
289
|
+
export function backupExtrasWrite(absPath: string, ts: string, projectRoot: string): void {
|
|
290
|
+
if (!existsSync(absPath)) return;
|
|
291
|
+
const rel = relative(projectRoot, absPath);
|
|
292
|
+
if (rel.startsWith('..') || rel === '') return;
|
|
293
|
+
const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'extras');
|
|
294
|
+
const dst = join(backupRoot, encodePath(projectRoot), rel);
|
|
295
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
296
|
+
cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
|
|
297
|
+
}
|
|
298
|
+
|
|
245
299
|
/**
|
|
246
300
|
* Acquire the exclusive nomad lockfile so two pulls/pushes cannot mutate
|
|
247
301
|
* `~/.claude/` concurrently. Returns the handle on success, or `null` on
|