claude-nomad 0.24.0 → 0.25.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 +34 -0
- package/README.md +30 -28
- package/package.json +1 -1
- package/src/commands.doctor.gitleaks-version.ts +132 -0
- package/src/commands.doctor.mirror-actions.ts +83 -0
- package/src/commands.doctor.ts +8 -3
- package/src/commands.drop-session.ts +60 -19
- package/src/commands.push.ts +23 -11
- package/src/commands.update.ts +27 -2
- package/src/config.ts +23 -10
- package/src/extras-sync.ts +30 -1
- package/src/push-gitleaks.ts +16 -14
- package/src/update.fork-extras.ts +101 -0
- package/src/utils.ts +22 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.25.0](https://github.com/funkadelic/claude-nomad/compare/v0.24.0...v0.25.0) (2026-05-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **doctor:** add gitleaks version and mirror-Actions drift checks ([#125](https://github.com/funkadelic/claude-nomad/issues/125)) ([8a16e0c](https://github.com/funkadelic/claude-nomad/commit/8a16e0c274bb7b961c6fd2df9912874c572023ee))
|
|
9
|
+
* **extras:** support a single root file as an extras entry ([#132](https://github.com/funkadelic/claude-nomad/issues/132)) ([6303b62](https://github.com/funkadelic/claude-nomad/commit/6303b62de84406da6a7fa2b7eb7af28a0473d0aa))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
* **drop-session:** cascade unstage into subagent transcript directory ([#120](https://github.com/funkadelic/claude-nomad/issues/120)) ([7f10d51](https://github.com/funkadelic/claude-nomad/commit/7f10d5135a7eaa9b6e74fb1aa863506a7b914a09))
|
|
15
|
+
* **push,update:** handle untracked extras in allow-list and fork merge ([#122](https://github.com/funkadelic/claude-nomad/issues/122)) ([e710a48](https://github.com/funkadelic/claude-nomad/commit/e710a48973a7c4e7479a9671d5891458a182dac7))
|
|
16
|
+
* **update:** skip push prompt when fork merge is a no-op ([#123](https://github.com/funkadelic/claude-nomad/issues/123)) ([77fdb20](https://github.com/funkadelic/claude-nomad/commit/77fdb20244a096e44a1fecd0a283e3401a78972d))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
* pin workflow actions to SHAs and drop persisted CI credentials ([#130](https://github.com/funkadelic/claude-nomad/issues/130)) ([b60109c](https://github.com/funkadelic/claude-nomad/commit/b60109c0cbb5dff1b13c4155cc12c3d98ce1b141))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
|
|
26
|
+
* add CONTRIBUTING, PR template, and SECURITY policy ([#131](https://github.com/funkadelic/claude-nomad/issues/131)) ([774eb58](https://github.com/funkadelic/claude-nomad/commit/774eb58a36af032f7d1cd109d59f313c4ff6affa))
|
|
27
|
+
* lead README with a benefit-first pitch ([#133](https://github.com/funkadelic/claude-nomad/issues/133)) ([289e299](https://github.com/funkadelic/claude-nomad/commit/289e2994fd7dffb10818fe02aad64b193ad800b6))
|
|
28
|
+
* **readme:** foreground secret-safety in the intro ([#126](https://github.com/funkadelic/claude-nomad/issues/126)) ([e661e9f](https://github.com/funkadelic/claude-nomad/commit/e661e9f02247fc30630fd90d5facc65aa7b4f2a0))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Dependencies
|
|
32
|
+
|
|
33
|
+
* bump SonarSource/sonarqube-scan-action from 8.0.0 to 8.1.0 ([#127](https://github.com/funkadelic/claude-nomad/issues/127)) ([603f20d](https://github.com/funkadelic/claude-nomad/commit/603f20d9a22528c8df19d328c91a02012690836c))
|
|
34
|
+
* bump the dev-dependencies group across 1 directory with 2 updates ([#128](https://github.com/funkadelic/claude-nomad/issues/128)) ([50b1b87](https://github.com/funkadelic/claude-nomad/commit/50b1b87b7288eed60280843504e6ccc2598f10b0))
|
|
35
|
+
* bump tsx from 4.22.2 to 4.22.3 in the prod-dependencies group ([#129](https://github.com/funkadelic/claude-nomad/issues/129)) ([a7ada00](https://github.com/funkadelic/claude-nomad/commit/a7ada000ec2d3dbee132c7b61dc207012e53c1c3))
|
|
36
|
+
|
|
3
37
|
## [0.24.0](https://github.com/funkadelic/claude-nomad/compare/v0.23.0...v0.24.0) (2026-05-24)
|
|
4
38
|
|
|
5
39
|
|
package/README.md
CHANGED
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**Your entire Claude Code setup, on every machine. History included, secrets excluded.**
|
|
10
10
|
|
|
11
|
-
claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on
|
|
11
|
+
Open Claude Code on a second machine and it is a blank slate: none of your custom agents, slash commands, tuned settings, or past conversations. claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything is there, conversations included.
|
|
12
12
|
|
|
13
|
-
**
|
|
13
|
+
- **Resume your sessions on any machine.** Start a conversation on your desktop and pick it up on your laptop. claude-nomad remaps the file paths Claude Code embeds in every transcript, so your history follows you instead of getting stranded on the box where it started.
|
|
14
|
+
- **Private by default.** Your `~/.claude/` also holds OAuth tokens, MCP credentials, and the full text of every conversation. Every push is secret-scanned before it leaves your machine, credentials and ephemeral state never sync, and `nomad init` disables CI on your private mirror by default, so transcripts can't leak through Actions logs.
|
|
15
|
+
- **One setup, every machine.** Your agents, skills, slash commands, and settings live in one place and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on top instead of clobbering your shared defaults.
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history survives different file paths and your secrets never ride along.
|
|
16
18
|
|
|
17
|
-
|
|
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
|
+
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. [Get started in three steps.](#quickstart)
|
|
20
20
|
|
|
21
21
|
## Table of contents
|
|
22
22
|
|
|
@@ -114,7 +114,7 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
114
114
|
│ ├── commands/
|
|
115
115
|
│ ├── rules/
|
|
116
116
|
│ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
|
|
117
|
-
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, .env
|
|
117
|
+
│ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, *.pem, id_rsa, id_ed25519, .env, .env.*
|
|
118
118
|
│ ├── projects/ # session transcripts under logical names
|
|
119
119
|
│ └── extras/ # opt-in per-project content (materializes when path-map.json declares extras)
|
|
120
120
|
├── hosts/
|
|
@@ -127,14 +127,14 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
127
127
|
|
|
128
128
|
## What gets synced vs. not
|
|
129
129
|
|
|
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`
|
|
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.
|
|
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, or a single root file like `CLAUDE.md`, whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts` | Opt-in via the `extras` field in `path-map.json`. Mirrored to/from `shared/extras/<logical>/<name>` (directory subtree or single file). 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. |
|
|
138
138
|
|
|
139
139
|
> [!NOTE]
|
|
140
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.
|
|
@@ -145,7 +145,7 @@ For the rationale behind these choices, see [What does NOT sync (deliberate trad
|
|
|
145
145
|
|
|
146
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.
|
|
147
147
|
|
|
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:
|
|
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 (or a single root file) at its root:
|
|
149
149
|
|
|
150
150
|
```json
|
|
151
151
|
{
|
|
@@ -157,7 +157,7 @@ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-pa
|
|
|
157
157
|
}
|
|
158
158
|
},
|
|
159
159
|
"extras": {
|
|
160
|
-
"ha-acwd": [".planning"]
|
|
160
|
+
"ha-acwd": [".planning", "CLAUDE.md"]
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
```
|
|
@@ -169,7 +169,7 @@ Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull`
|
|
|
169
169
|
|
|
170
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).
|
|
171
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
|
|
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 or root-file names (e.g. `.planning`, `CLAUDE.md`) 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 content at `<localRoot>/<name>` (a directory subtree or a single file) is copied to `shared/extras/<logical>/<name>` and inherits 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
173
|
|
|
174
174
|
## Per-host overrides
|
|
175
175
|
|
|
@@ -206,7 +206,7 @@ Read these before adopting so you opt in with eyes open.
|
|
|
206
206
|
- **Manual push/pull.** No file watcher. Shell hooks recommended.
|
|
207
207
|
- **OAuth doesn't sync.** You'll log in once per host. Intentional.
|
|
208
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.
|
|
209
|
+
- **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected. Names (a directory or a single root file) 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`.
|
|
210
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.
|
|
211
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.
|
|
212
212
|
|
|
@@ -334,9 +334,9 @@ nomad update
|
|
|
334
334
|
`nomad update` (see `cmdUpdate` in `src/commands.update.ts`) detects which layout your `~/claude-nomad/` uses and does the right thing:
|
|
335
335
|
|
|
336
336
|
- **vanilla** (`origin` points at the public repo): `git pull --ff-only origin main`.
|
|
337
|
-
- **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt.
|
|
337
|
+
- **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, then (before merging) commit any whitelisted `shared/extras/` content that is still untracked locally so an overlap with upstream becomes a normal file merge instead of an untracked-overwrite abort, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt. When the merge is a no-op (HEAD unchanged, nothing new to push) the prompt is skipped entirely and `nomad update` logs `already in sync with origin/main`.
|
|
338
338
|
|
|
339
|
-
Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead (e.g. a `-dev` build between releases), and silent on network failures.
|
|
339
|
+
Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, commits the regenerated `package-lock.json` (fork topology) if the reinstall changed it, then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead (e.g. a `-dev` build between releases), and silent on network failures.
|
|
340
340
|
|
|
341
341
|
Common cases:
|
|
342
342
|
|
|
@@ -369,15 +369,17 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
|
|
|
369
369
|
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
370
370
|
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
371
371
|
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
|
|
372
|
-
| `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`
|
|
372
|
+
| `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` and the sibling `shared/projects/*/<id>/` subagent directory from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` and `<id>/` tree are preserved. See [Recovery flows](#recovery-flows). |
|
|
373
373
|
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
374
|
-
| `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check.
|
|
374
|
+
| `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
|
|
375
375
|
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
|
|
376
376
|
| `nomad doctor --check-shared` | Read-only gitleaks preflight: stages the session transcripts a `push` would publish into a temp tree and scans them, failing (`✗`, exit 1) per affected session with rotate-and-scrub guidance. Skips with a `⚠︎` when gitleaks is not on PATH. See [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl). |
|
|
377
377
|
| `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
|
|
378
378
|
|
|
379
379
|
The version-check emits ``⚠︎ version: <local> -> <latest> (run `nomad update`)`` when the local install is behind the latest upstream release, and `✓ version: <local> (latest)` when current. It silently skips on network failures.
|
|
380
380
|
|
|
381
|
+
Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so a patch-only difference stays `✓`), and is silent when gitleaks is not on PATH. The mirror-Actions line (carrying a `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false` remediation hint) fires when origin is a private GitHub mirror that is gh-authed with Actions re-enabled, complementing the auto-disable that runs on `nomad init` (see [Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub origin, `gh` unauthed, public repo, or Actions already off).
|
|
382
|
+
|
|
381
383
|
Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summary:` line. The status glyph (`✓` green / `⚠︎` yellow / `✗` red / `ℹ︎` dim) carries the severity, mirroring `nomad doctor`'s left-gutter format:
|
|
382
384
|
|
|
383
385
|
```text
|
|
@@ -392,7 +394,7 @@ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summa
|
|
|
392
394
|
|
|
393
395
|
### `nomad drop-session <id>`
|
|
394
396
|
|
|
395
|
-
Surgically unstages every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl`
|
|
397
|
+
Surgically unstages every `shared/projects/*/<id>.jsonl` plus the sibling `shared/projects/*/<id>/` subagent directory (whose nested transcripts are keyed by the same session id) from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` and the local `<id>/` tree are never touched.
|
|
396
398
|
|
|
397
399
|
```bash
|
|
398
400
|
nomad drop-session <id>
|
|
@@ -400,14 +402,14 @@ nomad drop-session <id>
|
|
|
400
402
|
|
|
401
403
|
Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash, extra arg) exits 1 with a `usage:` line.
|
|
402
404
|
|
|
403
|
-
For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies the entry as tracked-in-HEAD vs newly-staged and unstages it via `git restore --staged --worktree --` or `git rm --cached -f --` respectively. Idempotent: a second run on the same id sees no matching staged
|
|
405
|
+
For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies the entry as tracked-in-HEAD vs newly-staged and unstages it via `git restore --staged --worktree --` or `git rm --cached -f --` respectively. The `<id>/` subagent directory is expanded into its staged entries via `git ls-files -z` so every nested transcript flows through the same per-entry classification; a session that has only a subagent directory (no flat `<id>.jsonl`) is still droppable. Idempotent: a second run on the same id sees no matching staged entries and exits 0.
|
|
404
406
|
|
|
405
407
|
Exit codes:
|
|
406
408
|
|
|
407
409
|
- `0` on any drop, including an idempotent re-run.
|
|
408
|
-
- `1` with `✗ no staged session matches <id>` on stderr when
|
|
410
|
+
- `1` with `✗ no staged session matches <id>` on stderr when neither a `shared/projects/*/<id>.jsonl` nor a `shared/projects/*/<id>/` directory with staged entries matches.
|
|
409
411
|
|
|
410
|
-
What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file. The local
|
|
412
|
+
What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrub the local files separately.
|
|
411
413
|
|
|
412
414
|
### Recovery flow: gitleaks FATAL on a session JSONL
|
|
413
415
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { green, okGlyph, warnGlyph, yellow } from './color.ts';
|
|
6
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
7
|
+
import { GITLEAKS_PINNED_VERSION, REPO_HOME } from './config.ts';
|
|
8
|
+
import type { SpawnSyncFn } from './gh-actions.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Soft gitleaks version-drift check appended to the Version section of
|
|
12
|
+
* `nomad doctor`. Parses `gitleaks version` stdout and compares its
|
|
13
|
+
* major.minor against `GITLEAKS_PINNED_VERSION` (the value CI installs),
|
|
14
|
+
* emitting one of:
|
|
15
|
+
* - `✓ gitleaks: X.Y.Z (matches pinned A.B)` when major.minor agree
|
|
16
|
+
* - `⚠︎ gitleaks: <local> -> <pin> (CI pins this; local drift may change scan results)`
|
|
17
|
+
* when major.minor diverge
|
|
18
|
+
* Only major.minor is compared: a patch-only difference is treated as OK,
|
|
19
|
+
* because gitleaks rule/allowlist behavior tracks the minor line, not the
|
|
20
|
+
* patch. Every failure path (gitleaks absent, subprocess error, unparseable
|
|
21
|
+
* or two-segment output) is a SILENT skip; this module never sets
|
|
22
|
+
* `process.exitCode` and never writes to stderr, mirroring the sibling
|
|
23
|
+
* release-version and node-engine checks.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Strict three-segment matcher capturing major and minor. Anchored on both
|
|
27
|
+
* ends so a two-segment string like `8.30` does not parse (feeding such a
|
|
28
|
+
* value to a triple-segment comparator would be undecidable). */
|
|
29
|
+
const SEMVER_MAJOR_MINOR = /^(\d+)\.(\d+)\.\d+$/;
|
|
30
|
+
|
|
31
|
+
/** Hard cap on the `gitleaks version` subprocess (matching the gh-actions
|
|
32
|
+
* primitives' `GH_TIMEOUT_MS` convention) so a wedged binary cannot hang the
|
|
33
|
+
* synchronous doctor run; the timeout throws and is swallowed as a silent skip. */
|
|
34
|
+
const GITLEAKS_TIMEOUT_MS = 5_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Capture the `[major, minor]` pair from a strict `X.Y.Z` semver string.
|
|
38
|
+
* Returns `null` when the input does not match a three-segment semver (e.g. a
|
|
39
|
+
* two-segment `8.30`, or non-numeric noise), which the caller treats as a
|
|
40
|
+
* silent skip.
|
|
41
|
+
*
|
|
42
|
+
* @param value - Candidate version string (already trimmed).
|
|
43
|
+
* @returns A `[major, minor]` tuple of bare numeric strings, or `null`.
|
|
44
|
+
*/
|
|
45
|
+
function majorMinorOf(value: string): [string, string] | null {
|
|
46
|
+
const m = SEMVER_MAJOR_MINOR.exec(value);
|
|
47
|
+
return m === null ? null : [m[1], m[2]];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run `gitleaks version` via the injected runner and return the trimmed
|
|
52
|
+
* stdout, or `null` on any throw (missing binary, subprocess failure). Mirrors
|
|
53
|
+
* the `probeGitleaks` invocation form in `push-checks.ts`: argv-array
|
|
54
|
+
* `execFileSync` (no shell), piped stdio, and a conditional
|
|
55
|
+
* `--config <REPO_HOME>/.gitleaks.toml` when that allowlist exists at call
|
|
56
|
+
* time, plus a `GITLEAKS_TIMEOUT_MS` cap so a wedged binary cannot hang the
|
|
57
|
+
* synchronous doctor run. Swallowing the error here is what makes both the
|
|
58
|
+
* absent-gitleaks case and a timeout a silent skip rather than a doctor failure.
|
|
59
|
+
*
|
|
60
|
+
* @param run - Injectable subprocess runner; defaults to `execFileSync`.
|
|
61
|
+
* @param tomlExists - Injectable allowlist-file existence check; defaults to
|
|
62
|
+
* `existsSync`. Injected in tests so the `--config` branch is exercised
|
|
63
|
+
* independent of the host filesystem (REPO_HOME varies per host and in CI).
|
|
64
|
+
* @returns The trimmed `gitleaks version` output, or `null` on any failure.
|
|
65
|
+
*/
|
|
66
|
+
function readGitleaksVersion(
|
|
67
|
+
run: SpawnSyncFn,
|
|
68
|
+
tomlExists: (path: string) => boolean,
|
|
69
|
+
): string | null {
|
|
70
|
+
const tomlPath = join(REPO_HOME, '.gitleaks.toml');
|
|
71
|
+
const args: string[] = ['version'];
|
|
72
|
+
if (tomlExists(tomlPath)) args.push('--config', tomlPath);
|
|
73
|
+
try {
|
|
74
|
+
return run('gitleaks', args, {
|
|
75
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
76
|
+
timeout: GITLEAKS_TIMEOUT_MS,
|
|
77
|
+
})
|
|
78
|
+
.toString()
|
|
79
|
+
.trim();
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Emit a single, non-fatal gitleaks version-drift diagnostic for
|
|
87
|
+
* `nomad doctor` by comparing the host's `gitleaks version` major.minor to
|
|
88
|
+
* `GITLEAKS_PINNED_VERSION`.
|
|
89
|
+
*
|
|
90
|
+
* Logs one of:
|
|
91
|
+
* - `✓ gitleaks: X.Y.Z (matches pinned A.B)` when the major.minor agree
|
|
92
|
+
* (including a patch-only difference from the pin)
|
|
93
|
+
* - `⚠︎ gitleaks: <local> -> <pin> (...)` when the major.minor diverge
|
|
94
|
+
*
|
|
95
|
+
* A missing gitleaks binary, a subprocess error, or output that does not match
|
|
96
|
+
* a strict `X.Y.Z` semver results in no output and does not change
|
|
97
|
+
* `process.exitCode`.
|
|
98
|
+
*
|
|
99
|
+
* @param section - The Version section to append the diagnostic line to.
|
|
100
|
+
* @param run - Injectable subprocess runner; defaults to `execFileSync`.
|
|
101
|
+
* @param tomlExists - Injectable allowlist-file existence check; defaults to
|
|
102
|
+
* `existsSync`. Mirrors the `run` seam so tests cover the `--config` branch
|
|
103
|
+
* deterministically.
|
|
104
|
+
*/
|
|
105
|
+
export function reportGitleaksVersionCheck(
|
|
106
|
+
section: DoctorSection,
|
|
107
|
+
run: SpawnSyncFn = execFileSync,
|
|
108
|
+
tomlExists: (path: string) => boolean = existsSync,
|
|
109
|
+
): void {
|
|
110
|
+
const raw = readGitleaksVersion(run, tomlExists);
|
|
111
|
+
if (raw === null) return;
|
|
112
|
+
const local = majorMinorOf(raw);
|
|
113
|
+
if (local === null) return;
|
|
114
|
+
const pin = majorMinorOf(GITLEAKS_PINNED_VERSION);
|
|
115
|
+
// Defensive: GITLEAKS_PINNED_VERSION is a hardcoded strict semver, so this
|
|
116
|
+
// never fires in practice; skip silently rather than risk a false WARN if a
|
|
117
|
+
// future edit ever malforms the constant.
|
|
118
|
+
/* c8 ignore next */
|
|
119
|
+
if (pin === null) return;
|
|
120
|
+
// Compare major.minor ONLY (D-02). Inline numeric compare on the captured
|
|
121
|
+
// segments; do NOT feed a two-segment string to compareSemver (its
|
|
122
|
+
// triple-segment contract returns an undecidable 0).
|
|
123
|
+
const sameMajorMinor = local[0] === pin[0] && local[1] === pin[1];
|
|
124
|
+
if (sameMajorMinor) {
|
|
125
|
+
addItem(section, `${green(okGlyph)} gitleaks: ${raw} (matches pinned ${pin[0]}.${pin[1]})`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
addItem(
|
|
129
|
+
section,
|
|
130
|
+
`${yellow(warnGlyph)} gitleaks: ${raw} -> ${GITLEAKS_PINNED_VERSION} (CI pins this; local drift may change scan results)`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { warnGlyph, yellow } from './color.ts';
|
|
4
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
5
|
+
import { REPO_HOME } from './config.ts';
|
|
6
|
+
import {
|
|
7
|
+
ghAuthStatus,
|
|
8
|
+
isActionsEnabled,
|
|
9
|
+
isRepoPrivate,
|
|
10
|
+
parseGitHubRemote,
|
|
11
|
+
readOriginRemote,
|
|
12
|
+
type SpawnSyncFn,
|
|
13
|
+
} from './gh-actions.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drift check appended to the Repository section of `nomad doctor`. WARNs (never
|
|
17
|
+
* FAILs, never sets `process.exitCode`) when the origin remote is a private
|
|
18
|
+
* GitHub mirror that is gh-authed with Actions re-enabled, the quiet failure
|
|
19
|
+
* mode where Actions get turned back on after `nomad init` auto-disabled them
|
|
20
|
+
* (via the GitHub web UI or a stray `gh` call) and the mirror starts firing its
|
|
21
|
+
* workflows on every push again.
|
|
22
|
+
*
|
|
23
|
+
* Reuses the five `gh-actions.ts` primitives unchanged (no new gh wrapper) and
|
|
24
|
+
* clones the `cmdInit` auto-disable gate ORDER, but strips every tip-log and
|
|
25
|
+
* the `disableActions` call: doctor is read-only and SILENT on every miss where
|
|
26
|
+
* init is chatty. The gate chain returns with no output when any of these hold:
|
|
27
|
+
* gate 1 - the origin remote cannot be read (not a git repo / no origin)
|
|
28
|
+
* gate 2 - the origin is not a GitHub URL (`parseGitHubRemote` null)
|
|
29
|
+
* gate 3 - `gh` is not installed or not authed
|
|
30
|
+
* gate 4 - the repo is public, or the privacy probe throws
|
|
31
|
+
* gate 5 - Actions are already disabled, or the Actions probe throws
|
|
32
|
+
* Only when all five gates pass does it emit the single yellow WARN line with a
|
|
33
|
+
* `gh api -X PUT ... -F enabled=false` remediation hint (the exact shape
|
|
34
|
+
* `disableActions` would run). The three gh probes (gates 3-5) carry the shared
|
|
35
|
+
* `GH_TIMEOUT_MS` internally; gates 1-2 are local (a `git remote get-url` config
|
|
36
|
+
* read and a regex parse), so no network and no timeout needed. No new doctor
|
|
37
|
+
* section; no opt-out flag.
|
|
38
|
+
*
|
|
39
|
+
* @param section - The Repository section to append the WARN line to.
|
|
40
|
+
* @param run - Injectable subprocess runner; defaults to `execFileSync`.
|
|
41
|
+
*/
|
|
42
|
+
export function reportMirrorActions(section: DoctorSection, run: SpawnSyncFn = execFileSync): void {
|
|
43
|
+
// Gate 1: origin remote. Throws on no remote / non-repo -> silent skip.
|
|
44
|
+
let remote: string;
|
|
45
|
+
try {
|
|
46
|
+
remote = readOriginRemote(REPO_HOME, run);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Gate 2: GitHub remote. Non-GitHub URL parses to null -> silent skip.
|
|
52
|
+
const ref = parseGitHubRemote(remote);
|
|
53
|
+
if (ref === null) return;
|
|
54
|
+
|
|
55
|
+
// Gate 3: gh available and authed. Doctor stays silent on both miss reasons
|
|
56
|
+
// (init prints a tip here; doctor does not, per the read-only contract).
|
|
57
|
+
if (ghAuthStatus(run) !== null) return;
|
|
58
|
+
|
|
59
|
+
// Gate 4: private mirror. A public repo, or a probe that throws, is a skip.
|
|
60
|
+
let isPrivate: boolean;
|
|
61
|
+
try {
|
|
62
|
+
isPrivate = isRepoPrivate(ref, run);
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!isPrivate) return;
|
|
67
|
+
|
|
68
|
+
// Gate 5: Actions enabled. Already-disabled, or a probe that throws, is a skip.
|
|
69
|
+
let enabled: boolean;
|
|
70
|
+
try {
|
|
71
|
+
enabled = isActionsEnabled(ref, run);
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!enabled) return;
|
|
76
|
+
|
|
77
|
+
// All gates passed: the private mirror has Actions re-enabled. Emit the
|
|
78
|
+
// single yellow WARN with the exact disable command as the remediation hint.
|
|
79
|
+
addItem(
|
|
80
|
+
section,
|
|
81
|
+
`${yellow(warnGlyph)} mirror Actions: enabled on private mirror ${ref.owner}/${ref.repo} (re-disable with 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false')`,
|
|
82
|
+
);
|
|
83
|
+
}
|
package/src/commands.doctor.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
import { reportCheckShared } from './commands.doctor.check-shared.ts';
|
|
16
16
|
import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
|
|
17
17
|
import { renderDoctor, section } from './commands.doctor.format.ts';
|
|
18
|
+
import { reportGitleaksVersionCheck } from './commands.doctor.gitleaks-version.ts';
|
|
19
|
+
import { reportMirrorActions } from './commands.doctor.mirror-actions.ts';
|
|
18
20
|
import { reportVersionCheck } from './commands.doctor.version.ts';
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -55,15 +57,18 @@ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
|
|
|
55
57
|
reportGitlinks(repository);
|
|
56
58
|
reportRemote(repository);
|
|
57
59
|
reportRebaseClean(repository);
|
|
60
|
+
reportMirrorActions(repository);
|
|
58
61
|
|
|
59
62
|
const version = section('Version');
|
|
60
63
|
reportVersionCheck(version);
|
|
61
64
|
reportNodeEngineCheck(version);
|
|
65
|
+
reportGitleaksVersionCheck(version);
|
|
62
66
|
|
|
63
67
|
const sharedScan = section('Shared scan');
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// probes
|
|
68
|
+
// Reuse the Repository-section readiness probe so reportCheckShared does not
|
|
69
|
+
// re-spawn gitleaks for its own readiness on a --check-shared run; it still
|
|
70
|
+
// probes standalone when called without a prior result. (The Version-section
|
|
71
|
+
// drift check above spawns `gitleaks version` separately, by design.)
|
|
67
72
|
if (opts.checkShared === true) reportCheckShared(sharedScan, gitleaksReady);
|
|
68
73
|
|
|
69
74
|
renderDoctor([version, host, links, settings, pathMap, neverSync, repository, sharedScan]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { join, relative } from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { REPO_HOME } from './config.ts';
|
|
@@ -7,27 +7,32 @@ import { acquireLock, die, fail, log, NomadFatal, releaseLock } from './utils.ts
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Surgical removal of a contaminated session from the staged tree of
|
|
10
|
-
* `~/claude-nomad/`.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* `~/claude-nomad/`. For each `shared/projects/<logical>/` child this
|
|
11
|
+
* matches both the flat `<id>.jsonl` AND the sibling subagent directory
|
|
12
|
+
* `<id>/` (whose nested transcripts are keyed by the same session id),
|
|
13
|
+
* classifies each staged entry via `git ls-files --error-unmatch`, and
|
|
14
|
+
* unstages with the appropriate primitive:
|
|
13
15
|
*
|
|
14
16
|
* - tracked-in-HEAD -> `git restore --staged --worktree -- <rel>`
|
|
15
17
|
* - newly-staged -> `git rm --cached -f -- <rel>`
|
|
16
18
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* The directory tree is expanded into its staged entries via
|
|
20
|
+
* `git ls-files -z -- <dir-rel>` so every nested file flows through the
|
|
21
|
+
* same per-entry classification loop as the flat jsonl; this closes the
|
|
22
|
+
* leak where a "dropped" session still shipped its subagent transcripts.
|
|
23
|
+
*
|
|
24
|
+
* Idempotent: entries not in the index are skipped silently. Exits 0 on
|
|
25
|
+
* any drop (including an idempotent re-run); exits 1 with `✗ no staged
|
|
26
|
+
* session matches <id>` only when neither a flat `<id>.jsonl` nor a
|
|
27
|
+
* `<id>/` directory with staged entries exists anywhere in the tree.
|
|
22
28
|
*
|
|
23
29
|
* Defense-in-depth: the id is validated against the same allowlist regex
|
|
24
30
|
* used in `src/resume.ts` before any path composition. argv-array form
|
|
25
31
|
* for every git invocation.
|
|
26
32
|
*
|
|
27
|
-
* NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* responsibility.
|
|
33
|
+
* NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl` or the local
|
|
34
|
+
* `<id>/` tree; the local copies are preserved so they race-safely
|
|
35
|
+
* coexist with active Claude Code writers.
|
|
31
36
|
*
|
|
32
37
|
* @param id Session id (filename minus `.jsonl`). Must match `[A-Za-z0-9_-]+`
|
|
33
38
|
* with length 1..128.
|
|
@@ -49,21 +54,32 @@ export function cmdDropSession(id: string): void {
|
|
|
49
54
|
if (!existsSync(repoProjects)) {
|
|
50
55
|
throw new NomadFatal(`no staged session matches ${id}`);
|
|
51
56
|
}
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
57
|
+
// For each `shared/projects/<logical>/` child, match the flat
|
|
58
|
+
// `<id>.jsonl` plus the sibling subagent directory `<id>/`. The
|
|
59
|
+
// directory is expanded into its staged entries so every nested file
|
|
60
|
+
// flows through the same per-entry unstage loop as the flat jsonl.
|
|
55
61
|
const matches: string[] = [];
|
|
56
62
|
for (const logical of readdirSync(repoProjects)) {
|
|
57
63
|
const candidate = join(repoProjects, logical, `${id}.jsonl`);
|
|
58
64
|
if (existsSync(candidate)) {
|
|
59
|
-
matches.push(candidate);
|
|
65
|
+
matches.push(relative(REPO_HOME, candidate));
|
|
66
|
+
}
|
|
67
|
+
const dir = join(repoProjects, logical, id);
|
|
68
|
+
if (existsSync(dir) && statSync(dir).isDirectory()) {
|
|
69
|
+
const dirRel = relative(REPO_HOME, dir);
|
|
70
|
+
const staged = expandStagedDir(dirRel);
|
|
71
|
+
// A dir present on disk but absent from the index is an already-dropped
|
|
72
|
+
// rerun: push the dir path itself so the per-entry isInIndex() guard
|
|
73
|
+
// logs it as "already absent" rather than letting an empty match set
|
|
74
|
+
// escalate to the no-match fatal (idempotency for dir-only sessions).
|
|
75
|
+
if (staged.length > 0) matches.push(...staged);
|
|
76
|
+
else matches.push(dirRel);
|
|
60
77
|
}
|
|
61
78
|
}
|
|
62
79
|
if (matches.length === 0) {
|
|
63
80
|
throw new NomadFatal(`no staged session matches ${id}`);
|
|
64
81
|
}
|
|
65
|
-
for (const
|
|
66
|
-
const rel = relative(REPO_HOME, m);
|
|
82
|
+
for (const rel of matches) {
|
|
67
83
|
// Pitfall 7: skip files that are not in the index at all (the
|
|
68
84
|
// load-bearing guard for the idempotent second-run case, where the
|
|
69
85
|
// first drop already removed the entry from the index but left the
|
|
@@ -120,6 +136,31 @@ export function cmdDropSession(id: string): void {
|
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Expand a repo-relative directory into its staged entries via
|
|
141
|
+
* `git ls-files -z -- <dirRel>` (argv-array form, NUL-split for path
|
|
142
|
+
* safety). Returns repo-relative POSIX paths for every staged file under
|
|
143
|
+
* the directory, or an empty array when none are staged or `git` fails
|
|
144
|
+
* (missing/corrupt index); the caller then falls through to the existing
|
|
145
|
+
* per-entry idempotency guard rather than escalating to a FATAL.
|
|
146
|
+
*
|
|
147
|
+
* @param dirRel Repo-relative directory path (`shared/projects/<logical>/<id>`).
|
|
148
|
+
*/
|
|
149
|
+
function expandStagedDir(dirRel: string): string[] {
|
|
150
|
+
try {
|
|
151
|
+
const out = execFileSync('git', ['ls-files', '-z', '--', dirRel], {
|
|
152
|
+
cwd: REPO_HOME,
|
|
153
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
154
|
+
});
|
|
155
|
+
return out
|
|
156
|
+
.toString()
|
|
157
|
+
.split('\0')
|
|
158
|
+
.filter((p) => p !== '');
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
123
164
|
/**
|
|
124
165
|
* Is `rel` (repo-relative path) present in the HEAD tree? Wraps
|
|
125
166
|
* `git cat-file -e HEAD:<rel>`: exit 0 means tracked in HEAD,
|
package/src/commands.push.ts
CHANGED
|
@@ -85,22 +85,28 @@ export function parsePorcelainZ(statusPorcelain: string): string[] {
|
|
|
85
85
|
* Reject any staged path that is not on the push allow-list or that matches a
|
|
86
86
|
* `NEVER_SYNC` entry. Builds the runtime allow-list by combining
|
|
87
87
|
* `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
|
|
88
|
-
* in `path-map.json` AND
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
88
|
+
* in `path-map.json` AND, per (logical, whitelisted name) pair in
|
|
89
|
+
* `map.extras ?? {}`, an exact `shared/extras/<logical>/<name>` entry plus a
|
|
90
|
+
* `shared/extras/<logical>/<name>/` prefix entry (Pitfall 4 closed:
|
|
91
|
+
* data-driven, no hand-rolled bypass). The exact entry permits the declared
|
|
92
|
+
* name when it is a single root file (e.g. `CLAUDE.md`); the prefix entry
|
|
93
|
+
* permits the declared name's subtree when it is a directory. Neither widens
|
|
94
|
+
* to a logical-only prefix, so an arbitrary sibling file under the same
|
|
95
|
+
* logical stays rejected. The name filter (`SUPPORTED_EXTRAS`) is the same one
|
|
96
|
+
* `remapExtrasPush` honors, so manually staged content under a non-whitelisted
|
|
97
|
+
* name surfaces as a FATAL instead of riding through. Logs every violation as
|
|
98
|
+
* a FATAL line so the user sees the full set (not just the first), then throws
|
|
99
|
+
* `NomadFatal` to unwind the caller's try/finally and release the push lock.
|
|
96
100
|
*/
|
|
97
101
|
export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
|
|
98
102
|
const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
99
103
|
const allowed = [
|
|
100
104
|
...PUSH_ALLOWED_STATIC,
|
|
101
105
|
...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
|
|
102
|
-
...Object.entries(map.extras ?? {}).flatMap(([l,
|
|
103
|
-
|
|
106
|
+
...Object.entries(map.extras ?? {}).flatMap(([l, names]) =>
|
|
107
|
+
names
|
|
108
|
+
.filter((n) => extrasWhitelist.includes(n))
|
|
109
|
+
.flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`]),
|
|
104
110
|
),
|
|
105
111
|
];
|
|
106
112
|
const neverSyncHits: string[] = [];
|
|
@@ -193,7 +199,13 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
|
|
|
193
199
|
}
|
|
194
200
|
// Routed through the shell-free, untrimmed helper because `sh` would .trim()
|
|
195
201
|
// the leading status-space and shift parsePorcelainZ's offsets.
|
|
196
|
-
|
|
202
|
+
// `untrackedAll` (issue #111): the allow-list runs on this snapshot BEFORE
|
|
203
|
+
// `git add -A`. Without it, a fresh host whose entire `shared/extras/`
|
|
204
|
+
// subtree is untracked yields a single collapsed `?? shared/extras/`
|
|
205
|
+
// record that the `shared/extras/<logical>/<dirname>/` child prefix cannot
|
|
206
|
+
// match, so the first extras push is rejected. Expanding to per-file paths
|
|
207
|
+
// lets the existing allow-list accept them while keeping the gate order.
|
|
208
|
+
const status = gitStatusPorcelainZ(REPO_HOME, { untrackedAll: true });
|
|
197
209
|
if (!status) {
|
|
198
210
|
log('nothing to commit');
|
|
199
211
|
// Combine session-unmapped and extras-unmapped into one user-visible
|
package/src/commands.update.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { closeSync, existsSync, openSync, readSync } from 'node:fs';
|
|
|
3
3
|
|
|
4
4
|
import { cmdDoctor } from './commands.doctor.ts';
|
|
5
5
|
import { REPO_HOME } from './config.ts';
|
|
6
|
+
import { commitRegeneratedLockfile, precommitForkExtras } from './update.fork-extras.ts';
|
|
6
7
|
import { loadTopology } from './update.topology.ts';
|
|
7
8
|
import { die, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, warn } from './utils.ts';
|
|
8
9
|
|
|
@@ -277,12 +278,21 @@ function tryAutoResolveMergeConflict(opts: CmdUpdateOpts): boolean {
|
|
|
277
278
|
* pushes when the answer is `y` or `yes` (case-insensitive). Non-affirmative
|
|
278
279
|
* answers skip the push and log a "run later" hint.
|
|
279
280
|
*
|
|
281
|
+
* When the merge (and any extras precommit) leaves `HEAD` unchanged from
|
|
282
|
+
* `beforeSha`, there is nothing new to push: the function logs a one-line
|
|
283
|
+
* "already in sync" and returns without pushing or prompting (issue #66). An
|
|
284
|
+
* auto-resolved conflict always advances `HEAD` via its merge commit, so that
|
|
285
|
+
* path is never mistaken for a no-op.
|
|
286
|
+
*
|
|
280
287
|
* @param opts - Update options; respected fields are:
|
|
281
288
|
* - `dryRun`: when true, log actions instead of executing them
|
|
282
289
|
* - `pushOrigin`: when true, push to `origin/main` without prompting
|
|
283
290
|
* - `prompt`: optional prompt function used for interactive confirmation
|
|
291
|
+
* @param beforeSha - `HEAD` SHA captured before the fork update began; the
|
|
292
|
+
* post-merge `HEAD` is compared against it to detect a no-op. When omitted
|
|
293
|
+
* (dry-run preview) the no-op short-circuit is skipped.
|
|
284
294
|
*/
|
|
285
|
-
function runFork(opts: CmdUpdateOpts): boolean {
|
|
295
|
+
function runFork(opts: CmdUpdateOpts, beforeSha?: string): boolean {
|
|
286
296
|
const promptFn = opts.prompt ?? defaultPrompt;
|
|
287
297
|
if (opts.dryRun === true) {
|
|
288
298
|
log('DRY-RUN: would run `git fetch upstream`');
|
|
@@ -295,6 +305,10 @@ function runFork(opts: CmdUpdateOpts): boolean {
|
|
|
295
305
|
return false;
|
|
296
306
|
}
|
|
297
307
|
gitOrFatal(['fetch', 'upstream'], 'git fetch upstream', REPO_HOME);
|
|
308
|
+
// Pre-commit whitelisted extras (issue #112): otherwise untracked
|
|
309
|
+
// shared/extras/ content that upstream also adds makes the merge abort
|
|
310
|
+
// pre-merge with no UU state, so the lone-lockfile auto-resolve never fires.
|
|
311
|
+
precommitForkExtras();
|
|
298
312
|
let autoResolved = false;
|
|
299
313
|
try {
|
|
300
314
|
gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
|
|
@@ -302,6 +316,13 @@ function runFork(opts: CmdUpdateOpts): boolean {
|
|
|
302
316
|
if (!tryAutoResolveMergeConflict(opts)) throw err;
|
|
303
317
|
autoResolved = true;
|
|
304
318
|
}
|
|
319
|
+
// No-op merge (and no extras precommit): HEAD never moved, so there is
|
|
320
|
+
// nothing new to push. A `beforeSha` of undefined (dry-run never reaches
|
|
321
|
+
// here) can never equal a real SHA, so the comparison is self-guarding.
|
|
322
|
+
if (headSha() === beforeSha) {
|
|
323
|
+
log('already in sync with origin/main, nothing to push');
|
|
324
|
+
return autoResolved;
|
|
325
|
+
}
|
|
305
326
|
if (opts.pushOrigin === true) {
|
|
306
327
|
gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
|
|
307
328
|
return autoResolved;
|
|
@@ -376,8 +397,12 @@ export function cmdUpdate(opts: CmdUpdateOpts = {}): void {
|
|
|
376
397
|
}
|
|
377
398
|
|
|
378
399
|
const beforeSha = headSha();
|
|
379
|
-
const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts);
|
|
400
|
+
const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts, beforeSha);
|
|
380
401
|
|
|
381
402
|
if (!installAlreadyRan) reinstallIfNeeded(beforeSha);
|
|
403
|
+
// Secondary item of issue #112: a post-merge `npm install` that regenerated
|
|
404
|
+
// package-lock.json leaves uncommitted drift the trailing doctor flags.
|
|
405
|
+
// Commit just the lockfile (fork topology only) so the repo is clean.
|
|
406
|
+
if (topology === 'fork') commitRegeneratedLockfile();
|
|
382
407
|
cmdDoctor();
|
|
383
408
|
}
|
package/src/config.ts
CHANGED
|
@@ -36,6 +36,17 @@ export const REPO_HOME = process.env.NOMAD_REPO || resolve(HOME, 'claude-nomad')
|
|
|
36
36
|
*/
|
|
37
37
|
export const UPSTREAM_REPO_SLUG = 'funkadelic/claude-nomad';
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Pinned gitleaks version. Single source of truth for the gitleaks pin used by
|
|
41
|
+
* `nomad doctor`'s version-drift check (`reportGitleaksVersionCheck`), which
|
|
42
|
+
* WARNs when the host's installed gitleaks major.minor diverges from this
|
|
43
|
+
* value. Mirrors the `GITLEAKS_VERSION` env in both `.github/workflows/tests.yml`
|
|
44
|
+
* and `.github/workflows/gitleaks.yml`; `config.gitleaks-pin.test.ts` asserts
|
|
45
|
+
* all three stay in lockstep so a CI bump that misses this constant (or vice
|
|
46
|
+
* versa) fails the suite. Bump here and in both workflow YAMLs together.
|
|
47
|
+
*/
|
|
48
|
+
export const GITLEAKS_PINNED_VERSION = '8.30.1';
|
|
49
|
+
|
|
39
50
|
/**
|
|
40
51
|
* Resolved host identity used to pick `hosts/<HOST>.json` and key entries in
|
|
41
52
|
* `path-map.json`. Reads `NOMAD_HOST` first, falls back to `hostname()`, then
|
|
@@ -58,16 +69,18 @@ export const SHARED_LINKS = [
|
|
|
58
69
|
] as const;
|
|
59
70
|
|
|
60
71
|
/**
|
|
61
|
-
* Whitelist of
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
72
|
+
* Whitelist of names allowed in `path-map.json`'s top-level `extras` field.
|
|
73
|
+
* Each entry is either a directory name (e.g. `.planning`) OR a single
|
|
74
|
+
* root-level file name (e.g. `CLAUDE.md`); both are validated the same way
|
|
75
|
+
* and copied verbatim under `shared/extras/<logical>/<name>`. Gates the
|
|
76
|
+
* named-extras opt-in mechanism: only entries appearing in this list are
|
|
77
|
+
* eligible for sync. Widening to include `.notes`, `.scratch`, `AGENTS.md`,
|
|
78
|
+
* etc. is a one-line edit here with no schema migration required (the field
|
|
79
|
+
* is additive on the consumer side). Mirrors `SHARED_LINKS` in shape and
|
|
67
80
|
* intent: a short, append-only `as const` tuple that downstream callers
|
|
68
81
|
* narrow against.
|
|
69
82
|
*/
|
|
70
|
-
export const SUPPORTED_EXTRAS = ['.planning'] as const;
|
|
83
|
+
export const SUPPORTED_EXTRAS = ['.planning', 'CLAUDE.md'] as const;
|
|
71
84
|
|
|
72
85
|
/**
|
|
73
86
|
* Path segments that must never cross the sync boundary in either direction.
|
|
@@ -159,9 +172,9 @@ export const PUSH_ALLOWED_STATIC = [
|
|
|
159
172
|
* the literal string `'TBD'` as a placeholder while a host has not yet cloned
|
|
160
173
|
* the project; `remapPull` / `remapPush` skip `'TBD'` entries.
|
|
161
174
|
*
|
|
162
|
-
* Optional `extras` field (additive, top-level): opt-in per-project
|
|
163
|
-
* named
|
|
164
|
-
*
|
|
175
|
+
* Optional `extras` field (additive, top-level): opt-in per-project sync of
|
|
176
|
+
* named content. Keyed by the same logical project name used in `projects`;
|
|
177
|
+
* values are arrays of directory or root-file names validated by downstream
|
|
165
178
|
* consumers against `SUPPORTED_EXTRAS`. Absence of the field is equivalent
|
|
166
179
|
* to no extras for any project; legacy `path-map.json` files without an
|
|
167
180
|
* `extras` block continue to work unchanged (no migration required).
|
package/src/extras-sync.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
3
|
import { isAbsolute, join, normalize } from 'node:path';
|
|
4
4
|
|
|
5
|
-
import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS } from './config.ts';
|
|
5
|
+
import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
|
|
6
6
|
// prettier-ignore
|
|
7
7
|
import { backupExtrasWrite, backupRepoWrite, encodePath, log, NomadFatal, readPathMap, warn } from './utils.ts';
|
|
8
8
|
|
|
@@ -61,6 +61,35 @@ export function copyExtras(src: string, dst: string): void {
|
|
|
61
61
|
cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Repo-relative `shared/extras/<logical>/<dirname>` paths for every
|
|
66
|
+
* (logical, whitelisted dirname) pair declared in `map.extras`. This is the
|
|
67
|
+
* same prefix set the push allow-list permits (minus the trailing slash, so
|
|
68
|
+
* the values are usable directly as `git add` arguments). Used by the fork
|
|
69
|
+
* update path (issue #112) to pre-commit overlapping extras before
|
|
70
|
+
* `git merge upstream/main`, turning an untracked-overwrite abort into a
|
|
71
|
+
* tracked-file merge. Non-whitelisted dirnames are filtered out so manually
|
|
72
|
+
* staged content under a non-supported dirname is never auto-committed.
|
|
73
|
+
* Logical names are validated for path-traversal safety first, matching the
|
|
74
|
+
* `remapExtras*` contract.
|
|
75
|
+
*
|
|
76
|
+
* @param map - Parsed `path-map.json`. A missing `extras` key yields `[]`.
|
|
77
|
+
* @returns Sorted, de-duplicated repo-relative extras paths (no trailing slash).
|
|
78
|
+
*/
|
|
79
|
+
export function whitelistedExtrasPaths(map: PathMap): string[] {
|
|
80
|
+
const extrasMap = map.extras ?? {};
|
|
81
|
+
const whitelist: readonly string[] = SUPPORTED_EXTRAS;
|
|
82
|
+
const paths = new Set<string>();
|
|
83
|
+
for (const [logical, dirnames] of Object.entries(extrasMap)) {
|
|
84
|
+
assertSafeLogical(logical);
|
|
85
|
+
for (const dirname of dirnames) {
|
|
86
|
+
if (!whitelist.includes(dirname)) continue;
|
|
87
|
+
paths.add(`shared/extras/${logical}/${dirname}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...paths].sort((a, b) => a.localeCompare(b));
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
/**
|
|
65
94
|
* Push: copy whitelisted extras directories under each project's localRoot
|
|
66
95
|
* into the repo at `shared/extras/<logical>/<dirname>/`. Returns
|
package/src/push-gitleaks.ts
CHANGED
|
@@ -93,7 +93,10 @@ export function partitionFindings(findings: Finding[]): {
|
|
|
93
93
|
* transcript(s).` header, one block per affected session with a
|
|
94
94
|
* `Recover with: nomad drop-session <id>` line, an optional `Also found:`
|
|
95
95
|
* block for non-session paths, and a trailing `After recovery, re-run
|
|
96
|
-
* nomad push.` line.
|
|
96
|
+
* nomad push.` line. The header carries a single clarifying note that the
|
|
97
|
+
* drop also clears any sibling subagent transcript directory for the
|
|
98
|
+
* session, since those nested paths route to the `other` bucket and are
|
|
99
|
+
* not listed per-session. Pure.
|
|
97
100
|
*/
|
|
98
101
|
export function buildSessionAwareFatal(
|
|
99
102
|
bySession: Map<string, Map<string, number>>,
|
|
@@ -101,24 +104,23 @@ export function buildSessionAwareFatal(
|
|
|
101
104
|
): string {
|
|
102
105
|
if (bySession.size === 0) return LEGACY_FATAL;
|
|
103
106
|
const lines: string[] = [];
|
|
104
|
-
lines.push(
|
|
107
|
+
lines.push(
|
|
108
|
+
`gitleaks detected secrets in ${bySession.size} session transcript(s).`,
|
|
109
|
+
"nomad drop-session also clears each session's sibling subagent transcript directory.",
|
|
110
|
+
);
|
|
105
111
|
for (const [sid, counts] of bySession) {
|
|
106
112
|
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}`);
|
|
113
|
+
lines.push('', `Session ${sid}:`, ` ${summary}`, ` Recover with: nomad drop-session ${sid}`);
|
|
111
114
|
}
|
|
112
115
|
if (other.length > 0) {
|
|
113
|
-
lines.push(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
lines.push(
|
|
117
|
+
'',
|
|
118
|
+
'Also found:',
|
|
119
|
+
...other.map((f) => ` ${f.File} ${f.RuleID}`),
|
|
120
|
+
' Review with: git diff --cached, then unstage manually.',
|
|
121
|
+
);
|
|
119
122
|
}
|
|
120
|
-
lines.push('');
|
|
121
|
-
lines.push('After recovery, re-run nomad push.');
|
|
123
|
+
lines.push('', 'After recovery, re-run nomad push.');
|
|
122
124
|
return lines.join('\n');
|
|
123
125
|
}
|
|
124
126
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { REPO_HOME } from './config.ts';
|
|
6
|
+
import { whitelistedExtrasPaths } from './extras-sync.ts';
|
|
7
|
+
import { gitOrFatal, log, readPathMap } from './utils.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pre-commit whitelisted extras before a fork merge so an untracked-overwrite
|
|
11
|
+
* abort becomes a tracked-file merge (issue #112).
|
|
12
|
+
*
|
|
13
|
+
* When a fork host has untracked `shared/extras/<logical>/<dirname>/` content
|
|
14
|
+
* that `upstream/main` also introduces, `git merge upstream/main` aborts
|
|
15
|
+
* before creating any merge state ("untracked working tree files would be
|
|
16
|
+
* overwritten by merge"). No `UU` is recorded, so the lone-lockfile
|
|
17
|
+
* auto-resolve never fires and the merge surfaces as an opaque failure.
|
|
18
|
+
* Staging alone is insufficient (git still refuses to overwrite staged-but-
|
|
19
|
+
* uncommitted local changes); the overlap must be a committed tracked path so
|
|
20
|
+
* the merge engine treats it as a content merge. After this commit, identical
|
|
21
|
+
* extras merge cleanly (the normal sync case, leaving the lone `UU
|
|
22
|
+
* package-lock.json` the existing auto-resolve handles) and divergent extras
|
|
23
|
+
* surface a real, resolvable conflict instead of the abort.
|
|
24
|
+
*
|
|
25
|
+
* Scoped strictly to the whitelisted `shared/extras/` paths declared in
|
|
26
|
+
* `path-map.json`; never a blanket `git add -A`. No-op when there is no
|
|
27
|
+
* `path-map.json`, no declared extras, or none of the declared extras paths
|
|
28
|
+
* exist on disk, and when staging produces no index change (nothing dirty).
|
|
29
|
+
*/
|
|
30
|
+
export function precommitForkExtras(): void {
|
|
31
|
+
const mapPath = join(REPO_HOME, 'path-map.json');
|
|
32
|
+
if (!existsSync(mapPath)) return;
|
|
33
|
+
const map = readPathMap(mapPath);
|
|
34
|
+
const candidates = whitelistedExtrasPaths(map).filter((p) => existsSync(join(REPO_HOME, p)));
|
|
35
|
+
if (candidates.length === 0) return;
|
|
36
|
+
|
|
37
|
+
gitOrFatal(['add', '--', ...candidates], 'git add extras', REPO_HOME);
|
|
38
|
+
// Only commit when staging actually changed the index. The probe and commit
|
|
39
|
+
// are BOTH path-scoped to the extras candidates so an unrelated staged change
|
|
40
|
+
// present before update neither flips the dirty probe nor rides along in the
|
|
41
|
+
// commit. `git diff --cached --quiet -- <candidates>` exits 0 when those paths
|
|
42
|
+
// match HEAD (nothing to commit), 1 when they differ; avoids an empty-commit
|
|
43
|
+
// failure when the extras were already tracked and unmodified.
|
|
44
|
+
let dirty = false;
|
|
45
|
+
try {
|
|
46
|
+
execFileSync('git', ['diff', '--cached', '--quiet', '--', ...candidates], {
|
|
47
|
+
cwd: REPO_HOME,
|
|
48
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
49
|
+
});
|
|
50
|
+
} catch {
|
|
51
|
+
dirty = true;
|
|
52
|
+
}
|
|
53
|
+
if (!dirty) return;
|
|
54
|
+
gitOrFatal(
|
|
55
|
+
['commit', '-m', 'chore: stage local extras before upstream merge', '--', ...candidates],
|
|
56
|
+
'git commit extras',
|
|
57
|
+
REPO_HOME,
|
|
58
|
+
);
|
|
59
|
+
log(`staged local extras before merge: ${candidates.join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* After a successful fork merge, commit a `package-lock.json` that `npm
|
|
64
|
+
* install` regenerated and left uncommitted (secondary item of issue #112).
|
|
65
|
+
*
|
|
66
|
+
* The post-merge reinstall (`reinstallIfNeeded`) can rewrite the lockfile
|
|
67
|
+
* when the merge changed dependencies, leaving working-tree drift that the
|
|
68
|
+
* trailing `nomad doctor` reports as "uncommitted changes". This stages and
|
|
69
|
+
* commits ONLY `package-lock.json` so the repo is clean after update. No-op
|
|
70
|
+
* when the lockfile is absent or unchanged (the `git diff --quiet` probe
|
|
71
|
+
* exits 0). Tightly scoped: never touches any other path.
|
|
72
|
+
*/
|
|
73
|
+
export function commitRegeneratedLockfile(): void {
|
|
74
|
+
const lockfile = join(REPO_HOME, 'package-lock.json');
|
|
75
|
+
if (!existsSync(lockfile)) return;
|
|
76
|
+
// `git diff --quiet -- package-lock.json` exits 0 when the working tree
|
|
77
|
+
// matches HEAD (no drift to commit), 1 when it differs.
|
|
78
|
+
let drifted = false;
|
|
79
|
+
try {
|
|
80
|
+
execFileSync('git', ['diff', '--quiet', '--', 'package-lock.json'], {
|
|
81
|
+
cwd: REPO_HOME,
|
|
82
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
drifted = true;
|
|
86
|
+
}
|
|
87
|
+
if (!drifted) return;
|
|
88
|
+
gitOrFatal(['add', '--', 'package-lock.json'], 'git add package-lock.json', REPO_HOME);
|
|
89
|
+
gitOrFatal(
|
|
90
|
+
[
|
|
91
|
+
'commit',
|
|
92
|
+
'-m',
|
|
93
|
+
'chore: commit regenerated package-lock.json after update',
|
|
94
|
+
'--',
|
|
95
|
+
'package-lock.json',
|
|
96
|
+
],
|
|
97
|
+
'git commit package-lock.json',
|
|
98
|
+
REPO_HOME,
|
|
99
|
+
);
|
|
100
|
+
log('committed regenerated package-lock.json after update');
|
|
101
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -88,12 +88,32 @@ export const die = (msg: string): never => {
|
|
|
88
88
|
* and the first record's leading space is part of the format (e.g.
|
|
89
89
|
* `" M path\0"` for unstaged-modified). Going through `sh` would strip that
|
|
90
90
|
* space and shift the fixed-offset parse in `parsePorcelainZ`.
|
|
91
|
+
*
|
|
92
|
+
* `opts.untrackedAll` (default `false`): when `true`, passes
|
|
93
|
+
* `--untracked-files=all` so git emits one record per untracked file instead
|
|
94
|
+
* of collapsing a fully-untracked subtree to its highest all-untracked parent
|
|
95
|
+
* directory (`?? shared/extras/`). The push allow-list (issue #111) needs the
|
|
96
|
+
* per-file paths so its `shared/extras/<logical>/<dirname>/` child prefix
|
|
97
|
+
* matches; the working-tree-clean checks in `cmdUpdate` and `cmdDoctor` only
|
|
98
|
+
* care whether output is empty, so they keep the cheaper default-collapse
|
|
99
|
+
* behavior. Opt-in rather than a global default so those consumers do not
|
|
100
|
+
* pay for the deeper walk.
|
|
101
|
+
*
|
|
102
|
+
* @param cwd - Working directory for the git invocation; defaults to the process cwd.
|
|
103
|
+
* @param opts - Reader options. `untrackedAll` expands collapsed untracked directory records into per-file paths.
|
|
104
|
+
* @returns The raw NUL-delimited porcelain v1 output as a string.
|
|
91
105
|
*/
|
|
92
|
-
export const gitStatusPorcelainZ = (
|
|
93
|
-
|
|
106
|
+
export const gitStatusPorcelainZ = (
|
|
107
|
+
cwd?: string,
|
|
108
|
+
opts: { untrackedAll?: boolean } = {},
|
|
109
|
+
): string => {
|
|
110
|
+
const args = ['status', '--porcelain=v1', '-z'];
|
|
111
|
+
if (opts.untrackedAll === true) args.push('--untracked-files=all');
|
|
112
|
+
return execFileSync('git', args, {
|
|
94
113
|
cwd,
|
|
95
114
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
115
|
}).toString();
|
|
116
|
+
};
|
|
97
117
|
|
|
98
118
|
/**
|
|
99
119
|
* Run `git <args>` in `cwd`, forwarding stderr and converting non-zero exits
|