claude-nomad 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitleaks.toml +20 -0
- package/CHANGELOG.md +25 -0
- package/README.md +100 -4
- package/package.json +1 -1
- package/src/commands.adopt.ts +183 -0
- package/src/commands.doctor.checks.repo.ts +4 -1
- package/src/commands.drop-session.scrub-hint.ts +12 -10
- package/src/commands.push.recovery.actions.ts +165 -0
- package/src/commands.push.recovery.drop.ts +47 -0
- package/src/commands.push.recovery.redact.ts +113 -0
- package/src/commands.push.recovery.seams.ts +66 -0
- package/src/commands.push.recovery.ts +198 -0
- package/src/commands.push.ts +20 -18
- package/src/commands.redact.core.ts +94 -0
- package/src/commands.redact.ts +187 -0
- package/src/config.ts +1 -0
- package/src/nomad.dispatch.ts +57 -0
- package/src/nomad.help.ts +17 -0
- package/src/nomad.ts +40 -11
- package/src/push-gitleaks.scan.ts +72 -1
- package/src/push-gitleaks.ts +35 -6
- package/src/push-leak-verdict.ts +15 -3
- package/src/push-preview.ts +1 -1
- package/src/settings-keys.ts +1 -1
package/.gitleaks.toml
CHANGED
|
@@ -38,3 +38,23 @@ paths = [
|
|
|
38
38
|
'''^shared/projects/[^/]+/.*\.jsonl$''',
|
|
39
39
|
]
|
|
40
40
|
condition = "AND"
|
|
41
|
+
|
|
42
|
+
# Path-scoped: SonarCloud issue-listing tool output (`gh`/sonar API dumps of
|
|
43
|
+
# the form `key: <20-char id>` immediately followed by `rule: <lang>:S<n>`)
|
|
44
|
+
# lands in session transcripts during PR reviews. The issue key is an opaque
|
|
45
|
+
# 20-char base64url identifier, not a credential, but it is shaped like a
|
|
46
|
+
# generic API key and does not carry the `AY` prefix the noise allowlist
|
|
47
|
+
# above keys on. Anchoring on the surrounding `key:`/`rule:` structure (via
|
|
48
|
+
# regexTarget = "line") keeps this from whitelisting a bare token: a real API
|
|
49
|
+
# key is never followed by `\n rule: <lang>:S####`. `condition = "AND"` plus
|
|
50
|
+
# the session-jsonl path scope double-locks it to synced transcripts.
|
|
51
|
+
[[allowlists]]
|
|
52
|
+
description = "claude-nomad: SonarCloud issue-listing output (key: <id> / rule: <lang>:S<n>) in synced session transcripts"
|
|
53
|
+
regexTarget = "line"
|
|
54
|
+
regexes = [
|
|
55
|
+
'''key:\s*[A-Za-z0-9_-]{19,24}\\n\s*rule:\s*\w+:S\d+''',
|
|
56
|
+
]
|
|
57
|
+
paths = [
|
|
58
|
+
'''^shared/projects/[^/]+/.*\.jsonl$''',
|
|
59
|
+
]
|
|
60
|
+
condition = "AND"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.32.0](https://github.com/funkadelic/claude-nomad/compare/v0.31.0...v0.32.0) (2026-05-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **adopt:** add nomad adopt for pre-existing local dirs ([#185](https://github.com/funkadelic/claude-nomad/issues/185)) ([251d5b7](https://github.com/funkadelic/claude-nomad/commit/251d5b71569315cbcf6ae073e29faecb4dbe5aa2))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* **readme:** note synced skills carry shims, not the tool engine ([#183](https://github.com/funkadelic/claude-nomad/issues/183)) ([695ba02](https://github.com/funkadelic/claude-nomad/commit/695ba029a92f397a1469ca1cf5f782e0b5bcecac))
|
|
14
|
+
|
|
15
|
+
## [0.31.0](https://github.com/funkadelic/claude-nomad/compare/v0.30.0...v0.31.0) (2026-05-29)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
* **push:** interactive secret recovery on push and nomad redact ([#181](https://github.com/funkadelic/claude-nomad/issues/181)) ([4931e27](https://github.com/funkadelic/claude-nomad/commit/4931e27ba30998a02b123c71edc1315069c9181a))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
* **gitleaks:** allowlist SonarCloud issue-key tool-output noise in synced transcripts ([#179](https://github.com/funkadelic/claude-nomad/issues/179)) ([0f7d816](https://github.com/funkadelic/claude-nomad/commit/0f7d8161d4de3379cd6a4db00482f560c8b3f280))
|
|
26
|
+
* **settings-drift:** author PRs via app token and make regen idempotent ([#182](https://github.com/funkadelic/claude-nomad/issues/182)) ([062397c](https://github.com/funkadelic/claude-nomad/commit/062397c565926471eccc8e75f72d1ccf2e5cc8c0))
|
|
27
|
+
|
|
3
28
|
## [0.30.0](https://github.com/funkadelic/claude-nomad/compare/v0.29.1...v0.30.0) (2026-05-29)
|
|
4
29
|
|
|
5
30
|
|
package/README.md
CHANGED
|
@@ -60,7 +60,9 @@ box, a personal rig and a work machine. [Get started in three steps.](#quickstar
|
|
|
60
60
|
- [Commands](#commands)
|
|
61
61
|
- [Recovery flows](#recovery-flows)
|
|
62
62
|
- [`nomad drop-session <id>`](#nomad-drop-session-id)
|
|
63
|
+
- [`nomad redact <session-id>`](#nomad-redact-session-id)
|
|
63
64
|
- [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl)
|
|
65
|
+
- [Recovery flow: push-time interactive menu](#recovery-flow-push-time-interactive-menu)
|
|
64
66
|
- [`.gitleaks.toml` allowlist policy](#gitleakstoml-allowlist-policy)
|
|
65
67
|
- [Cross-OS resume](#cross-os-resume)
|
|
66
68
|
- [Run tests](#run-tests)
|
|
@@ -192,6 +194,17 @@ Pointers and specifics:
|
|
|
192
194
|
> URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's
|
|
193
195
|
> own per-host config.
|
|
194
196
|
|
|
197
|
+
<!-- prettier-ignore -->
|
|
198
|
+
> [!IMPORTANT]
|
|
199
|
+
> Syncing a tool's `skills/` or `commands/` files copies the command shims, not the engine behind
|
|
200
|
+
> them. If a tool keeps a binary or runtime outside `~/.claude/` (installed with `npm i -g`, a setup
|
|
201
|
+
> script, and so on), nomad does not carry that part, so the synced commands appear on a new host but
|
|
202
|
+
> fail until the tool itself is installed there. Install such tools once per host. For example, if you
|
|
203
|
+
> sync the GSD (`get-shit-done`) skills, run `npm i -g get-shit-done-cc` on each host, pinned to the
|
|
204
|
+
> version that matches your committed skills. Claude Code marketplace plugins (such as superpowers)
|
|
205
|
+
> are the exception: they are listed in `enabledPlugins`, synced via `settings.base.json`, and
|
|
206
|
+
> re-downloaded by Claude Code automatically, so they need no manual install.
|
|
207
|
+
|
|
195
208
|
For the rationale behind these choices, see
|
|
196
209
|
[What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs).
|
|
197
210
|
|
|
@@ -610,7 +623,13 @@ point under your npm prefix's `bin/`), then delete the alias line from your shel
|
|
|
610
623
|
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
|
611
624
|
| `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
|
|
612
625
|
| `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list) and a read-only gitleaks leak preview over a throwaway temp copy of the sessions and extras this host would stage; skip stage, commit, and push. Exits 1 if a leak is found in the preview. Nothing is written to the sync repo. |
|
|
626
|
+
| `nomad push --redact-all` | Redact all findings non-interactively (backup written first) without a TTY. Does not auto-Allow findings. After redaction re-stages and re-scans; aborts with the session-aware FATAL if any finding survives. Use this in scripts or when you are confident every finding is a real secret that should be scrubbed. See [Recovery flow: push-time interactive menu](#recovery-flow-push-time-interactive-menu). |
|
|
613
627
|
| `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). |
|
|
628
|
+
| `nomad adopt <name>` | Back up, then move a pre-existing `~/.claude/<name>` directory into `shared/<name>`, recreate the symlink so this host keeps working, and stage the result for push. `<name>` must already be listed in `SHARED_LINKS` or in the `sharedDirs` field of `path-map.json`; adopt is a mover, not a config editor, so it never writes `path-map.json` itself. |
|
|
629
|
+
| `nomad adopt <name> --dry-run` | Preview the planned backup, move, and `git add` without touching the filesystem or the git index. |
|
|
630
|
+
| `nomad redact <session-id>` | Rewrite the secret span in the local source transcript for a session, backed up to `~/.cache/claude-nomad/backup/`. Refuses to touch a session that was modified recently (potential active session). Safe to re-run. See [`nomad redact <session-id>`](#nomad-redact-session-id). |
|
|
631
|
+
| `nomad redact --rule <id>` | Limit redaction to findings of one gitleaks rule id only. |
|
|
632
|
+
| `nomad redact --dry-run` | Show what `nomad redact` would change without writing anything. |
|
|
614
633
|
| `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
|
|
615
634
|
| `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, a Hook targets check that fails (`✗`, exit 1) when `settings.json` references a hook command whose script under `~/.claude/` is missing on this host, plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
|
|
616
635
|
| `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
|
|
@@ -762,6 +781,38 @@ scrubbing (the exact path when `path-map.json` maps the project to the current h
|
|
|
762
781
|
`~/.claude/projects/` source, not the staged tree, so it keeps flagging the secret until the local
|
|
763
782
|
transcript is scrubbed.
|
|
764
783
|
|
|
784
|
+
### `nomad redact <session-id>`
|
|
785
|
+
|
|
786
|
+
Rewrites the secret span in the local source transcript at
|
|
787
|
+
`~/.claude/projects/<encoded>/<session-id>.jsonl` in place, replacing each flagged span with
|
|
788
|
+
`[REDACTED:<rule>]`. Before rewriting, the original transcript is backed up to
|
|
789
|
+
`~/.cache/claude-nomad/backup/<timestamp>/`.
|
|
790
|
+
|
|
791
|
+
```bash
|
|
792
|
+
$ nomad redact <session-id>
|
|
793
|
+
$ nomad redact <session-id> --rule github-pat # one rule only
|
|
794
|
+
$ nomad redact <session-id> --dry-run # preview without writing
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
What it does: rewrites the LOCAL source transcript (not just the staged copy). This is the durable
|
|
798
|
+
fix for a gitleaks finding: `nomad drop-session` only removes the staged copy, but `remapPush`
|
|
799
|
+
re-copies from local on the next push, so the secret resurfaces. Redacting the local source means
|
|
800
|
+
future pushes carry clean content.
|
|
801
|
+
|
|
802
|
+
What it does NOT do: rotate credentials. Always rotate the secret at its provider first.
|
|
803
|
+
|
|
804
|
+
Safety checks:
|
|
805
|
+
|
|
806
|
+
- A session whose transcript was modified within the last 5 minutes is treated as potentially active
|
|
807
|
+
(Claude Code may still be writing to it). `nomad redact` refuses to touch it and suggests
|
|
808
|
+
`nomad drop-session` or waiting for the session to end.
|
|
809
|
+
- Before every rewrite, a backup is written to `~/.cache/claude-nomad/backup/<timestamp>/`, so the
|
|
810
|
+
original content is recoverable.
|
|
811
|
+
- `--dry-run` prints the planned redactions and writes nothing.
|
|
812
|
+
|
|
813
|
+
This command is safe to re-run: if the span was already redacted (the replacement token is already
|
|
814
|
+
present), the content is unchanged.
|
|
815
|
+
|
|
765
816
|
### Recovery flow: gitleaks FATAL on a session JSONL
|
|
766
817
|
|
|
767
818
|
`nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you
|
|
@@ -792,10 +843,12 @@ Two branches from here:
|
|
|
792
843
|
contaminated copy from the current staged tree, but that alone is NOT durable: `remapPush` (in
|
|
793
844
|
`src/remap.ts`) does a full rm-and-copy mirror of your LOCAL transcripts into `shared/projects/`
|
|
794
845
|
on every push, so the next `nomad push` re-copies the un-scrubbed local file forward and
|
|
795
|
-
re-stages the same secret. The durable fix is to rotate AND scrub
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
846
|
+
re-stages the same secret. The durable fix is to rotate AND scrub the local transcript. The
|
|
847
|
+
easiest way: `nomad redact <sid-aaaa>` (see [`nomad redact`](#nomad-redact-session-id)), which
|
|
848
|
+
rewrites the secret span in place with a backup. Alternatively, remove the local transcript at
|
|
849
|
+
`~/.claude/projects/<encoded>/<sid-aaaa>.jsonl` (plus the sibling `<sid-aaaa>/` subagent
|
|
850
|
+
directory, if present). Do not leave the local file un-scrubbed and expect the staged-tree drop
|
|
851
|
+
to hold.
|
|
799
852
|
|
|
800
853
|
2. **False positive.** Add an allowlist regex to `.gitleaks.toml` at the repo root that matches the
|
|
801
854
|
noise pattern but not real-secret formats, commit it, then re-run `nomad push`. The new allowlist
|
|
@@ -804,6 +857,49 @@ Two branches from here:
|
|
|
804
857
|
`nomad drop-session` only acts on the staged tree of `~/claude-nomad/`. Active Claude Code sessions
|
|
805
858
|
writing to the local file are not disturbed.
|
|
806
859
|
|
|
860
|
+
### Recovery flow: push-time interactive menu
|
|
861
|
+
|
|
862
|
+
When `nomad push` detects a secret and the process is running on an interactive TTY, it presents a
|
|
863
|
+
per-finding menu instead of aborting immediately. Each finding is shown with its rule id, file, and
|
|
864
|
+
line number (the secret value is never printed: the scan uses `--redact`).
|
|
865
|
+
|
|
866
|
+
```text
|
|
867
|
+
Finding: github-pat in shared/projects/my-proj/abc123.jsonl line 42 (session: abc123)
|
|
868
|
+
[R]edact [A]llow [D]rop session [S]kip (default)
|
|
869
|
+
>
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
What the actions do:
|
|
873
|
+
|
|
874
|
+
- **Redact** rewrites the secret span in the LOCAL source transcript in place (same flow as
|
|
875
|
+
`nomad redact`), backs up first, then re-copies the file to the staged tree. Refuses if the
|
|
876
|
+
session was modified in the last 5 minutes (potential active session): choose Drop or Skip instead
|
|
877
|
+
and wait for the session to end.
|
|
878
|
+
- **Allow** appends the finding's fingerprint to `.gitleaksignore` at the repo root. Use this for
|
|
879
|
+
confirmed false positives. The fingerprint format (`file:rule:line`) is tied to the current line,
|
|
880
|
+
so if the content moves gitleaks re-prompts rather than silently suppressing a new hit.
|
|
881
|
+
- **Drop session** excludes this session from the current push by unstaging it from the repo's git
|
|
882
|
+
index (same as `nomad drop-session <id>`). The local `~/.claude/projects/.../` transcript is kept
|
|
883
|
+
intact and any running Claude session is not stopped. Not durable: the next push re-copies from
|
|
884
|
+
local unless you also redact or remove the local transcript.
|
|
885
|
+
- **Skip** (default on bare Enter) leaves the finding unresolved for now.
|
|
886
|
+
|
|
887
|
+
After you respond to every finding, the menu applies your choices. If any finding was Skipped, the
|
|
888
|
+
push aborts with the session-aware FATAL (same exit as a non-interactive push with findings). If all
|
|
889
|
+
findings were resolved, the staged tree is updated and re-scanned. A clean re-scan proceeds to
|
|
890
|
+
commit and push. If new findings appear after the first round of actions, the menu loops on the new
|
|
891
|
+
set.
|
|
892
|
+
|
|
893
|
+
On a non-TTY (CI, piped input, or scripted `nomad push`), the menu never appears and the push aborts
|
|
894
|
+
with the existing session-aware FATAL unchanged.
|
|
895
|
+
|
|
896
|
+
**Batch redact without a TTY:** `nomad push --redact-all` redacts every finding non-interactively
|
|
897
|
+
(backup written first) without prompting and without requiring a TTY. It does not auto-Allow. After
|
|
898
|
+
redaction the staged tree is re-scanned; any surviving finding aborts with the FATAL. Use this in
|
|
899
|
+
scripts or when every finding is a real secret that should be scrubbed. For a single session,
|
|
900
|
+
`nomad redact <session-id>` (see [`nomad redact`](#nomad-redact-session-id)) gives you per-session
|
|
901
|
+
control with `--rule` and `--dry-run` options.
|
|
902
|
+
|
|
807
903
|
### `.gitleaks.toml` allowlist policy
|
|
808
904
|
|
|
809
905
|
`gitleaks protect` runs against the staged tree on every `nomad push` and can flag
|
package/package.json
CHANGED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CLAUDE_HOME, HOME, REPO_HOME, SHARED_LINKS, type PathMap } from './config.ts';
|
|
5
|
+
import { isValidSharedDir } from './config.sharedDirs.guard.ts';
|
|
6
|
+
import { fail, gitOrFatal, log, NomadFatal } from './utils.ts';
|
|
7
|
+
import { backupBeforeWrite, ensureSymlink, freshBackupTs } from './utils.fs.ts';
|
|
8
|
+
import { readPathMap } from './utils.json.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Follow-up hint printed after a successful adopt. Exported so Plan 02's
|
|
12
|
+
* doctor hint can reuse the exact literal without duplicating the string.
|
|
13
|
+
*/
|
|
14
|
+
export const ADOPT_PUSH_HINT = 'run `nomad push` to share with other hosts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* lstat-based existence check that, unlike `existsSync`, does NOT follow
|
|
18
|
+
* symlinks: a dangling symlink at `p` returns true. Used for the clobber
|
|
19
|
+
* guard so an existing (even broken) `shared/<name>` link is refused rather
|
|
20
|
+
* than fed to `cpSync`, which would otherwise throw an opaque non-NomadFatal
|
|
21
|
+
* error on a dangling-symlink destination.
|
|
22
|
+
*
|
|
23
|
+
* @param p Absolute path to probe.
|
|
24
|
+
* @returns True when any entry (file, dir, or symlink) exists at `p`.
|
|
25
|
+
*/
|
|
26
|
+
function lexists(p: string): boolean {
|
|
27
|
+
try {
|
|
28
|
+
lstatSync(p);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read `path-map.json` if present; fall back to an empty map when absent.
|
|
37
|
+
* Adopt reads sharedDirs for membership only; it never writes path-map.json.
|
|
38
|
+
*
|
|
39
|
+
* @param repoHome Absolute path to the nomad repo root.
|
|
40
|
+
* @returns The parsed PathMap, or `{ projects: {} }` when path-map.json is absent.
|
|
41
|
+
*/
|
|
42
|
+
function readMapIfPresent(repoHome: string): PathMap {
|
|
43
|
+
const mapPath = join(repoHome, 'path-map.json');
|
|
44
|
+
return existsSync(mapPath) ? readPathMap(mapPath) : { projects: {} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Return true when `name` is an already-configured shared target: either a
|
|
49
|
+
* static `SHARED_LINKS` member or a `sharedDirs` entry declared in
|
|
50
|
+
* `path-map.json`. This is a read-only membership check; adopt never writes
|
|
51
|
+
* `path-map.json` (D-03).
|
|
52
|
+
*
|
|
53
|
+
* @param name Candidate name.
|
|
54
|
+
* @param map Parsed path-map (sharedDirs membership source).
|
|
55
|
+
* @returns True when name is a configured shared target.
|
|
56
|
+
*/
|
|
57
|
+
function isConfiguredTarget(name: string, map: PathMap): boolean {
|
|
58
|
+
return (
|
|
59
|
+
(SHARED_LINKS as readonly string[]).includes(name) || (map.sharedDirs?.includes(name) ?? false)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Return true when `name` is safe to adopt. Static `SHARED_LINKS` members
|
|
65
|
+
* are pre-approved and bypass `isValidSharedDir` (which rejects RESERVED_SHARED,
|
|
66
|
+
* overlapping with SHARED_LINKS). Candidate `sharedDirs` names must pass
|
|
67
|
+
* `isValidSharedDir` to prevent path injection (D-00a).
|
|
68
|
+
*
|
|
69
|
+
* @param name Candidate name from the CLI argument.
|
|
70
|
+
* @returns True when the name is safe for adopt processing.
|
|
71
|
+
*/
|
|
72
|
+
function isValidAdoptName(name: string): boolean {
|
|
73
|
+
if ((SHARED_LINKS as readonly string[]).includes(name)) return true;
|
|
74
|
+
return isValidSharedDir(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Perform the actual backup -> copy -> remove -> relink -> stage sequence
|
|
79
|
+
* once all preconditions have passed. Extracts the mutation block so the
|
|
80
|
+
* top-level function stays under the cognitive-complexity threshold.
|
|
81
|
+
*
|
|
82
|
+
* @param name The validated, configured, real-directory name to adopt.
|
|
83
|
+
* @param linkPath Absolute path of the source directory (`CLAUDE_HOME/<name>`).
|
|
84
|
+
* @param sharedTarget Absolute path of the destination (`REPO_HOME/shared/<name>`).
|
|
85
|
+
*/
|
|
86
|
+
function performAdoptMove(name: string, linkPath: string, sharedTarget: string): void {
|
|
87
|
+
const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
|
|
88
|
+
const ts = freshBackupTs(backupBase);
|
|
89
|
+
|
|
90
|
+
// D-00c: backup before any mutation
|
|
91
|
+
backupBeforeWrite(linkPath, ts);
|
|
92
|
+
|
|
93
|
+
// D-00e, V-07: copy fully into shared/ BEFORE removing the source so a
|
|
94
|
+
// mid-move crash cannot lose user content
|
|
95
|
+
cpSync(linkPath, sharedTarget, { recursive: true, force: true, preserveTimestamps: true });
|
|
96
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
97
|
+
|
|
98
|
+
// D-01: recreate the symlink immediately on this host
|
|
99
|
+
ensureSymlink(linkPath, sharedTarget);
|
|
100
|
+
|
|
101
|
+
// D-02: targeted stage of shared/<name> only; never git add -A
|
|
102
|
+
const rel = join('shared', name);
|
|
103
|
+
gitOrFatal(['add', '--', rel], `git add shared/${name}`, REPO_HOME);
|
|
104
|
+
|
|
105
|
+
log(`adopted ${name}; ${ADOPT_PUSH_HINT}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Bring a pre-existing `~/.claude/<name>` directory into the nomad shared set.
|
|
110
|
+
*
|
|
111
|
+
* Validates `name`, enforces the precondition matrix, then performs:
|
|
112
|
+
* backup -> copy-into-shared -> remove-source -> recreate-symlink ->
|
|
113
|
+
* targeted `git add` -> print follow-up hint. Stops there: no auto-commit,
|
|
114
|
+
* no push pipeline (D-02).
|
|
115
|
+
*
|
|
116
|
+
* Accepts only already-configured names: a static SHARED_LINKS member or a
|
|
117
|
+
* `sharedDirs` entry already declared in `path-map.json`. adopt is a mover,
|
|
118
|
+
* not a config editor; it never writes `path-map.json` (D-03).
|
|
119
|
+
*
|
|
120
|
+
* `--dry-run` reports the planned actions and performs zero filesystem or
|
|
121
|
+
* git changes (D-00d, V-08).
|
|
122
|
+
*
|
|
123
|
+
* @param name The `~/.claude/<name>` directory to adopt.
|
|
124
|
+
* @param opts.dryRun When true, log planned actions and return without mutation.
|
|
125
|
+
*/
|
|
126
|
+
export function cmdAdopt(name: string, opts: { dryRun?: boolean } = {}): void {
|
|
127
|
+
const dryRun = opts.dryRun === true;
|
|
128
|
+
|
|
129
|
+
// D-00a: validate name format (rejects path separators, NEVER_SYNC, and arbitrary
|
|
130
|
+
// names that are not in SHARED_LINKS; SHARED_LINKS statics bypass isValidSharedDir
|
|
131
|
+
// because RESERVED_SHARED overlaps with SHARED_LINKS by design)
|
|
132
|
+
if (!isValidAdoptName(name)) {
|
|
133
|
+
fail(`invalid name: ${JSON.stringify(name)}`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// D-03: confirm name is an already-configured shared target
|
|
138
|
+
const map = readMapIfPresent(REPO_HOME);
|
|
139
|
+
if (!isConfiguredTarget(name, map)) {
|
|
140
|
+
fail(
|
|
141
|
+
`${name}: not a configured shared target. ` +
|
|
142
|
+
`Add it to sharedDirs in path-map.json first, then re-run adopt.`,
|
|
143
|
+
);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const linkPath = join(CLAUDE_HOME, name);
|
|
148
|
+
const sharedTarget = join(REPO_HOME, 'shared', name);
|
|
149
|
+
|
|
150
|
+
// D-00b precondition checks -- in order: absent, already symlink, would clobber
|
|
151
|
+
if (!existsSync(linkPath)) {
|
|
152
|
+
log(`${name}: nothing to adopt (not present in ~/.claude/)`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (lstatSync(linkPath).isSymbolicLink()) {
|
|
156
|
+
log(`${name}: already adopted (already a symlink)`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (lexists(sharedTarget)) {
|
|
160
|
+
fail(`${name}: shared/${name} already exists; would clobber. Remove it first.`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// D-00d: dry-run preview -- branch before any mutation
|
|
165
|
+
if (dryRun) {
|
|
166
|
+
const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
|
|
167
|
+
const ts = freshBackupTs(backupBase);
|
|
168
|
+
log(`would backup: ${linkPath} -> backup/${ts}/${name}`);
|
|
169
|
+
log(`would move: ${linkPath} -> shared/${name}`);
|
|
170
|
+
log(`would stage: shared/${name}`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* c8 ignore start -- catch is defensive: performAdoptMove only throws on a git/fs fault */
|
|
175
|
+
try {
|
|
176
|
+
performAdoptMove(name, linkPath, sharedTarget);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (!(err instanceof NomadFatal)) throw err;
|
|
179
|
+
fail(err.message);
|
|
180
|
+
process.exitCode = 1;
|
|
181
|
+
}
|
|
182
|
+
/* c8 ignore stop */
|
|
183
|
+
}
|
|
@@ -119,7 +119,10 @@ function classifySharedLink(name: string, p: string): { line: string; fail: bool
|
|
|
119
119
|
return { line: `${red(failGlyph)} ${name}: could not stat (${String(code)})`, fail: true };
|
|
120
120
|
}
|
|
121
121
|
if (!stat.isSymbolicLink()) {
|
|
122
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
line: `${red(failGlyph)} ${name}: NOT a symlink (blocks sync); run \`nomad adopt ${name}\` to fix`,
|
|
124
|
+
fail: true,
|
|
125
|
+
};
|
|
123
126
|
}
|
|
124
127
|
return classifySymlinkTarget(name, p);
|
|
125
128
|
}
|
|
@@ -13,12 +13,12 @@ const SHARED_PROJECT_LOGICAL = /^shared\/projects\/([^/]+)\//;
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* After a successful drop, remind the operator that the unstage is per-push
|
|
16
|
-
* only: the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* only: the local source still contains the secret, so the next `nomad push`
|
|
17
|
+
* re-copies it (via `remapPush`) and `nomad doctor --check-shared` keeps
|
|
18
|
+
* reporting it (it scans the live `~/.claude/projects/` source, not the repo
|
|
19
|
+
* index). Full remediation requires rotating the credential, then running
|
|
20
|
+
* `nomad redact <id>` (or scrubbing the local transcript manually). Advisory
|
|
21
|
+
* output only; never mutates state.
|
|
22
22
|
*
|
|
23
23
|
* @param id Already-validated session id.
|
|
24
24
|
* @param matches Repo-relative paths collected by `collectMatches`.
|
|
@@ -27,10 +27,12 @@ export function reportScrubHint(id: string, matches: string[]): void {
|
|
|
27
27
|
const live = resolveLiveTranscript(id, matches);
|
|
28
28
|
const target = live ?? `~/.claude/projects/<encoded>/${id}.jsonl`;
|
|
29
29
|
log(
|
|
30
|
-
'note: this only un-stages the session from the next push
|
|
31
|
-
'
|
|
32
|
-
' doctor --check-shared keeps reporting it
|
|
33
|
-
|
|
30
|
+
'note: this only un-stages the session from the next push.\n' +
|
|
31
|
+
' The local source still contains the secret, so nomad push re-stages it\n' +
|
|
32
|
+
' on the next run and nomad doctor --check-shared keeps reporting it.\n' +
|
|
33
|
+
' To fully remediate: rotate the credential, then run:\n' +
|
|
34
|
+
` nomad redact ${id}\n` +
|
|
35
|
+
` (or scrub ${target} manually)`,
|
|
34
36
|
);
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* I/O action dispatchers for the push-time recovery menu: `applyAllow`,
|
|
3
|
+
* `applyRedact`, `collectActions`, `dispatchActions`, `redactAllFindings`.
|
|
4
|
+
* Pure seams live in `commands.push.recovery.seams.ts`; lock-free drop
|
|
5
|
+
* helper in `commands.push.recovery.drop.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PathMap } from './config.ts';
|
|
9
|
+
import { appendGitleaksIgnore } from './commands.redact.ts';
|
|
10
|
+
import { applyRedact } from './commands.push.recovery.redact.ts';
|
|
11
|
+
import { dropSessionFromStaged } from './commands.push.recovery.drop.ts';
|
|
12
|
+
import type { Finding } from './push-gitleaks.scan.ts';
|
|
13
|
+
import { scanFile } from './push-gitleaks.scan.ts';
|
|
14
|
+
import { log } from './utils.ts';
|
|
15
|
+
import {
|
|
16
|
+
type FindingAction,
|
|
17
|
+
type PromptFn,
|
|
18
|
+
findingKey,
|
|
19
|
+
parseAction,
|
|
20
|
+
sessionIdFromFinding,
|
|
21
|
+
} from './commands.push.recovery.seams.ts';
|
|
22
|
+
|
|
23
|
+
export type { FindingAction, PromptFn };
|
|
24
|
+
export { dropSessionFromStaged, findingKey, parseAction, sessionIdFromFinding };
|
|
25
|
+
|
|
26
|
+
/** Apply the Allow action: append the finding's fingerprint to .gitleaksignore. */
|
|
27
|
+
export function applyAllow(f: Finding): void {
|
|
28
|
+
appendGitleaksIgnore(f.Fingerprint);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Walk all findings and prompt the user for one action each. Returns a map
|
|
33
|
+
* from `findingKey` to the chosen action, defaulting to `'skip'` on empty
|
|
34
|
+
* input.
|
|
35
|
+
*
|
|
36
|
+
* @param findings The findings to present.
|
|
37
|
+
* @param prompt An injectable prompt function (one question per call).
|
|
38
|
+
* @returns Populated actions map.
|
|
39
|
+
*/
|
|
40
|
+
export async function collectActions(
|
|
41
|
+
findings: Finding[],
|
|
42
|
+
prompt: PromptFn,
|
|
43
|
+
): Promise<Map<string, FindingAction>> {
|
|
44
|
+
const actions = new Map<string, FindingAction>();
|
|
45
|
+
for (const f of findings) {
|
|
46
|
+
const sid = sessionIdFromFinding(f);
|
|
47
|
+
const header =
|
|
48
|
+
`\nFinding: ${f.RuleID} in ${f.File} line ${f.StartLine}` +
|
|
49
|
+
(sid !== null ? ` (session: ${sid})` : '') +
|
|
50
|
+
'\n [R]edact [A]llow [D]rop session [S]kip (default)\n';
|
|
51
|
+
actions.set(findingKey(f), parseAction(await prompt(header + '> ')));
|
|
52
|
+
}
|
|
53
|
+
return actions;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply one finding's triaged action against local state. Extracted from
|
|
58
|
+
* `dispatchActions` so each function stays under the cognitive-complexity gate.
|
|
59
|
+
* `redactedSids` and `droppedSids` are mutated in place so per-session
|
|
60
|
+
* de-duplication is maintained across the caller's loop. Drop wins: once a
|
|
61
|
+
* session id appears in `droppedSids`, subsequent redact or allow actions for
|
|
62
|
+
* findings in that session are skipped.
|
|
63
|
+
*
|
|
64
|
+
* @param f The finding to act on.
|
|
65
|
+
* @param findings Full findings list (passed to `applyRedact` for per-session redaction).
|
|
66
|
+
* @param actions The action map returned by `collectActions`.
|
|
67
|
+
* @param ts Backup timestamp.
|
|
68
|
+
* @param map Parsed path-map.
|
|
69
|
+
* @param nowMs Injectable clock.
|
|
70
|
+
* @param scan Injectable scan function for `applyRedact`.
|
|
71
|
+
* @param drop Injectable staged-copy remover for the Drop action.
|
|
72
|
+
* @param redactedSids Set of already-redacted session ids (mutated in place).
|
|
73
|
+
* @param droppedSids Set of already-dropped session ids (mutated in place).
|
|
74
|
+
*/
|
|
75
|
+
function dispatchOne(
|
|
76
|
+
f: Finding,
|
|
77
|
+
findings: Finding[],
|
|
78
|
+
actions: Map<string, FindingAction>,
|
|
79
|
+
ts: string,
|
|
80
|
+
map: PathMap,
|
|
81
|
+
nowMs: () => number,
|
|
82
|
+
scan: (p: string) => Finding[] | null,
|
|
83
|
+
drop: (sid: string, map: PathMap) => boolean,
|
|
84
|
+
redactedSids: Set<string>,
|
|
85
|
+
droppedSids: Set<string>,
|
|
86
|
+
): void {
|
|
87
|
+
const action = actions.get(findingKey(f)) ?? 'skip';
|
|
88
|
+
if (action === 'skip') return;
|
|
89
|
+
if (action === 'allow') {
|
|
90
|
+
applyAllow(f);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const sid = sessionIdFromFinding(f);
|
|
94
|
+
if (sid === null) return;
|
|
95
|
+
if (droppedSids.has(sid)) return;
|
|
96
|
+
if (action === 'drop') {
|
|
97
|
+
droppedSids.add(sid);
|
|
98
|
+
if (drop(sid, map)) {
|
|
99
|
+
log(
|
|
100
|
+
`dropped session ${sid} from this push (local transcript kept; the secret remains in your local copy)`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (action === 'redact' && !redactedSids.has(sid)) {
|
|
106
|
+
if (applyRedact(f, findings, ts, map, nowMs, scan)) redactedSids.add(sid);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Dispatch all non-skip actions from the triage map against local state.
|
|
112
|
+
* Redacted sessions are de-duplicated: the first finding for a given session
|
|
113
|
+
* triggers the in-place rewrite; subsequent findings for the same session are
|
|
114
|
+
* skipped (the rewrite already covered all findings in one pass).
|
|
115
|
+
*
|
|
116
|
+
* @param findings Full findings list from the current verdict.
|
|
117
|
+
* @param actions The action map returned by `collectActions`.
|
|
118
|
+
* @param ts Backup timestamp.
|
|
119
|
+
* @param map Parsed path-map.
|
|
120
|
+
* @param nowMs Injectable clock.
|
|
121
|
+
* @param scan Injectable scan function for `applyRedact` (default: `scanFile`).
|
|
122
|
+
* @param drop Injectable staged-copy remover for the Drop action (default: `dropSessionFromStaged`).
|
|
123
|
+
*/
|
|
124
|
+
export function dispatchActions(
|
|
125
|
+
findings: Finding[],
|
|
126
|
+
actions: Map<string, FindingAction>,
|
|
127
|
+
ts: string,
|
|
128
|
+
map: PathMap,
|
|
129
|
+
nowMs: () => number,
|
|
130
|
+
scan: (p: string) => Finding[] | null = scanFile,
|
|
131
|
+
drop: (sid: string, map: PathMap) => boolean = dropSessionFromStaged,
|
|
132
|
+
): void {
|
|
133
|
+
const redactedSids = new Set<string>();
|
|
134
|
+
const droppedSids = new Set<string>();
|
|
135
|
+
for (const f of findings) {
|
|
136
|
+
dispatchOne(f, findings, actions, ts, map, nowMs, scan, drop, redactedSids, droppedSids);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Batch-redact all findings non-interactively (the `--redact-all` path).
|
|
142
|
+
* Does not require a TTY. Findings with no resolvable session id are skipped.
|
|
143
|
+
* Sessions are de-duplicated: the first finding per session triggers the
|
|
144
|
+
* rewrite.
|
|
145
|
+
*
|
|
146
|
+
* @param findings All findings from the current verdict.
|
|
147
|
+
* @param ts Backup timestamp.
|
|
148
|
+
* @param map Parsed path-map.
|
|
149
|
+
* @param nowMs Injectable clock.
|
|
150
|
+
* @param scan Injectable scan function for `applyRedact` (default: `scanFile`).
|
|
151
|
+
*/
|
|
152
|
+
export function redactAllFindings(
|
|
153
|
+
findings: Finding[],
|
|
154
|
+
ts: string,
|
|
155
|
+
map: PathMap,
|
|
156
|
+
nowMs: () => number,
|
|
157
|
+
scan: (p: string) => Finding[] | null = scanFile,
|
|
158
|
+
): void {
|
|
159
|
+
const redactedSids = new Set<string>();
|
|
160
|
+
for (const f of findings) {
|
|
161
|
+
const sid = sessionIdFromFinding(f);
|
|
162
|
+
if (sid === null || redactedSids.has(sid)) continue;
|
|
163
|
+
if (applyRedact(f, findings, ts, map, nowMs, scan)) redactedSids.add(sid);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock-free session drop helper for the push-time recovery menu.
|
|
3
|
+
* `dropSessionFromStaged` removes a session's generated copies from the
|
|
4
|
+
* `REPO_HOME/shared/projects/` tree so the recovery loop's subsequent
|
|
5
|
+
* `git add -A` stages the deletion rather than re-staging the file.
|
|
6
|
+
*
|
|
7
|
+
* Kept separate from `commands.push.recovery.actions.ts` to respect the
|
|
8
|
+
* ~220-line module cap.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { rmSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import type { PathMap } from './config.ts';
|
|
15
|
+
import { REPO_HOME } from './config.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Remove the session's generated copies from the staged tree under
|
|
19
|
+
* `REPO_HOME/shared/projects/<logical>/` so the subsequent `git add -A` in
|
|
20
|
+
* the recovery loop stages the deletion rather than re-staging the file.
|
|
21
|
+
*
|
|
22
|
+
* Removes both the flat `<sid>.jsonl` transcript and the sibling subagent
|
|
23
|
+
* directory `<sid>/` (if present) for every logical project in `map`. These
|
|
24
|
+
* are generated copies produced by `remapPush`; the originals under
|
|
25
|
+
* `~/.claude/projects/` are never touched.
|
|
26
|
+
*
|
|
27
|
+
* Lock-free by design: the caller (`dispatchActions`) runs inside a `push`
|
|
28
|
+
* that already holds the global nomad lock. Calling `cmdDropSession` here
|
|
29
|
+
* would deadlock on the lock it already owns.
|
|
30
|
+
*
|
|
31
|
+
* @param sid Session id to drop from the staged tree.
|
|
32
|
+
* @param map Parsed path-map; provides the logical project names.
|
|
33
|
+
* @returns True when `map.projects` has at least one logical entry (the
|
|
34
|
+
* session copies were targeted for removal), false when the map is empty
|
|
35
|
+
* and no paths were evaluated.
|
|
36
|
+
*/
|
|
37
|
+
export function dropSessionFromStaged(sid: string, map: PathMap): boolean {
|
|
38
|
+
const logicals = Object.keys(map.projects);
|
|
39
|
+
if (logicals.length === 0) return false;
|
|
40
|
+
for (const logical of logicals) {
|
|
41
|
+
const jsonl = join(REPO_HOME, 'shared', 'projects', logical, `${sid}.jsonl`);
|
|
42
|
+
const dir = join(REPO_HOME, 'shared', 'projects', logical, sid);
|
|
43
|
+
rmSync(jsonl, { force: true });
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
}
|