claude-nomad 0.17.1 → 0.18.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 +14 -0
- package/README.md +31 -13
- package/package.json +1 -1
- package/src/commands.doctor.checks.ts +1 -1
- package/src/gh-actions.ts +125 -0
- package/src/init.classify.ts +87 -0
- package/src/init.ts +76 -73
- package/src/nomad.ts +21 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.18.0](https://github.com/funkadelic/claude-nomad/compare/v0.17.2...v0.18.0) (2026-05-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
* **init:** auto-disable GitHub Actions on private mirror ([#94](https://github.com/funkadelic/claude-nomad/issues/94)) ([aee4736](https://github.com/funkadelic/claude-nomad/commit/aee47365f504c4700a619a2828c6fca8c18b1868))
|
|
9
|
+
|
|
10
|
+
## [0.17.2](https://github.com/funkadelic/claude-nomad/compare/v0.17.1...v0.17.2) (2026-05-22)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **ci:** retry smoke-test install for registry propagation ([#92](https://github.com/funkadelic/claude-nomad/issues/92)) ([1ae7576](https://github.com/funkadelic/claude-nomad/commit/1ae75760f674e93be1fadc2b927ee4dd03f9d346))
|
|
16
|
+
|
|
3
17
|
## [0.17.1](https://github.com/funkadelic/claude-nomad/compare/v0.17.0...v0.17.1) (2026-05-21)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -30,6 +30,9 @@ Two things it does that ad-hoc dotfiles syncing can't:
|
|
|
30
30
|
- **Getting started**
|
|
31
31
|
- [Requirements](#requirements)
|
|
32
32
|
- [Setup](#setup)
|
|
33
|
+
- [Privacy by default](#privacy-by-default)
|
|
34
|
+
- [Bootstrap](#bootstrap)
|
|
35
|
+
- [Initialize the repo layout](#initialize-the-repo-layout)
|
|
33
36
|
- [Migrating an existing ~/.claude/](#migrating-an-existing-claude)
|
|
34
37
|
- [Upgrading the tool](#upgrading-the-tool)
|
|
35
38
|
- **Reference**
|
|
@@ -51,7 +54,7 @@ npm i -g claude-nomad
|
|
|
51
54
|
|
|
52
55
|
```bash
|
|
53
56
|
# Clone your private mirror so nomad has a repo to sync into.
|
|
54
|
-
git clone git@github.com
|
|
57
|
+
git clone git@github.com:<your-username>/claude-nomad.git ~/claude-nomad
|
|
55
58
|
|
|
56
59
|
# Add to ~/.zshrc or ~/.bashrc:
|
|
57
60
|
export NOMAD_HOST=<your-host-label>
|
|
@@ -76,7 +79,7 @@ First-host bootstrap and the safe-migration sequence for a populated `~/.claude/
|
|
|
76
79
|
claude-nomad is a **tool**, not a config store. You maintain a separate **private** repo that holds your actual config (`CLAUDE.md`, agents, skills, settings overrides, session transcripts). The tool's source and your config end up coexisting in one working tree on each host.
|
|
77
80
|
|
|
78
81
|
```
|
|
79
|
-
public funkadelic/claude-nomad your private
|
|
82
|
+
public funkadelic/claude-nomad your private <your-username>/claude-nomad
|
|
80
83
|
├── src/ (the CLI) ├── src/ (copy of the CLI)
|
|
81
84
|
├── package.json ├── package.json
|
|
82
85
|
└── ... ├── ...
|
|
@@ -101,7 +104,7 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
|
|
|
101
104
|
```
|
|
102
105
|
~/claude-nomad/
|
|
103
106
|
├── src/ # the CLI (came from the public tool repo)
|
|
104
|
-
├── scripts/ #
|
|
107
|
+
├── scripts/ # helper scripts you add
|
|
105
108
|
├── shared/ # synced to every machine
|
|
106
109
|
│ ├── CLAUDE.md
|
|
107
110
|
│ ├── settings.base.json # baseline settings
|
|
@@ -209,25 +212,36 @@ Read these before adopting so you opt in with eyes open.
|
|
|
209
212
|
|
|
210
213
|
**Why not just fork?** GitHub doesn't let you flip a public fork to private, and your config (especially session transcripts) must stay private. So the bootstrap is a one-time mirror-push into a fresh private repo, not a fork.
|
|
211
214
|
|
|
215
|
+
### Privacy by default
|
|
216
|
+
|
|
217
|
+
Your private mirror has two layers of defense against leaking transcripts via CI, both applied automatically:
|
|
218
|
+
|
|
219
|
+
1. Every workflow under `.github/workflows/` is gated on `${{ !github.event.repository.private }}`, so they skip on private repos and only run on public ones.
|
|
220
|
+
2. `nomad init` calls `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false` on first run, turning Actions off at the repo level. Requires `gh` CLI authed; if missing or unauthed, init logs a manual fallback tip and continues.
|
|
221
|
+
|
|
222
|
+
Pass `--keep-actions` to either form of init to skip step 2 (for example, when your org already enforces an Actions policy upstream).
|
|
223
|
+
|
|
212
224
|
> [!WARNING]
|
|
213
|
-
>
|
|
225
|
+
> If you ever flip the mirror to public, both protections evaporate: CI starts firing on every `nomad push` against `main`, and your session transcripts (which include conversation content) become world-readable. Keep it private.
|
|
214
226
|
|
|
215
|
-
Bootstrap
|
|
227
|
+
### Bootstrap
|
|
228
|
+
|
|
229
|
+
Steps 1-2 are once-ever across all hosts; step 3 repeats per host:
|
|
216
230
|
|
|
217
231
|
```bash
|
|
218
232
|
# 1. Create the private repo (or use the GitHub UI). Once, ever.
|
|
219
|
-
gh repo create
|
|
233
|
+
gh repo create <your-username>/claude-nomad --private
|
|
220
234
|
|
|
221
235
|
# 2. Mirror the public tool into it. This severs the fork relationship,
|
|
222
236
|
# so your repo is independent of upstream. Once, ever.
|
|
223
237
|
git clone --bare git@github.com:funkadelic/claude-nomad.git /tmp/cn.git
|
|
224
238
|
cd /tmp/cn.git
|
|
225
|
-
git push --mirror git@github.com
|
|
239
|
+
git push --mirror git@github.com:<your-username>/claude-nomad.git
|
|
226
240
|
cd .. && rm -rf /tmp/cn.git
|
|
227
241
|
|
|
228
242
|
# 3. Install the CLI globally and clone your private copy. Repeat on every host.
|
|
229
243
|
npm i -g claude-nomad
|
|
230
|
-
git clone git@github.com
|
|
244
|
+
git clone git@github.com:<your-username>/claude-nomad.git ~/claude-nomad
|
|
231
245
|
```
|
|
232
246
|
|
|
233
247
|
`npm i -g claude-nomad` puts a `nomad` binary on your PATH. The bin shim is the existing `src/nomad.ts` entrypoint resolved through tsx (a runtime dependency); no compile step. The npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes; npm only blocks the install when `engine-strict=true` is configured.
|
|
@@ -242,7 +256,9 @@ export NOMAD_HOST=<your-host-label> # any short, stable label; nomad reads
|
|
|
242
256
|
|
|
243
257
|
`NOMAD_HOST` overrides `os.hostname()`, which returns noisy values like `WINDOWS-I5NT6OH` on WSL or `<name>.local` on macOS. Pick a clean label per machine (e.g., `wsl-laptop`, `macbook`, `homelab-nuc`). `nomad doctor` reports the resolved host so you can confirm.
|
|
244
258
|
|
|
245
|
-
Initialize the repo layout
|
|
259
|
+
### Initialize the repo layout
|
|
260
|
+
|
|
261
|
+
First host only; subsequent hosts just clone and `nomad pull`. Both forms below auto-disable Actions on a detected private GitHub mirror as described in [Privacy by default](#privacy-by-default). Pick one:
|
|
246
262
|
|
|
247
263
|
```bash
|
|
248
264
|
# Fresh start: scaffold an empty shared/, hosts/, path-map.json skeleton.
|
|
@@ -252,6 +268,9 @@ nomad init
|
|
|
252
268
|
# starting point. Stages shared/ and writes hosts/<NOMAD_HOST>.json from
|
|
253
269
|
# your current ~/.claude/settings.json. Does NOT touch the originals.
|
|
254
270
|
nomad init --snapshot
|
|
271
|
+
|
|
272
|
+
# Either form accepts --keep-actions to skip the auto-disable.
|
|
273
|
+
nomad init --keep-actions
|
|
255
274
|
```
|
|
256
275
|
|
|
257
276
|
`nomad init` refuses to clobber existing scaffold artifacts, so re-running on a populated repo is a safe no-op (it errors out naming the offender). `nomad pull` against an unscaffolded repo fails fast with `FATAL: repo not initialized; run 'nomad init' to scaffold` instead of silently leaving a half-state.
|
|
@@ -324,8 +343,6 @@ One-time setup if you're running a fork layout and don't have the `upstream` rem
|
|
|
324
343
|
git remote add upstream git@github.com:funkadelic/claude-nomad.git
|
|
325
344
|
```
|
|
326
345
|
|
|
327
|
-
`npm run update` still exists as a legacy shim that shells out to `scripts/update.sh`; prefer `nomad update` for new invocations.
|
|
328
|
-
|
|
329
346
|
To pin to a specific release (`vX.Y.Z`, tagged by release-please) instead of tracking `main`, fetch tags from the public repo and check out the tag (detached HEAD). On vanilla topology that's `origin`; on fork topology that's `upstream` (the private mirror at `origin` does not accumulate upstream release tags). Example: `git fetch upstream --tags && git switch --detach vX.Y.Z` (substitute `origin` for vanilla; use `git checkout vX.Y.Z` on older Git).
|
|
330
347
|
|
|
331
348
|
If you installed an earlier version via `./install.sh` and a shell alias (the pre-npm path), your existing alias keeps working unchanged. Run `npm i -g claude-nomad` whenever you're ready to switch to the global binary, confirm `nomad --version` resolves to the npm install (`which nomad` should point under your npm prefix's `bin/`), then delete the alias line from your shell rc.
|
|
@@ -334,8 +351,9 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
|
|
|
334
351
|
|
|
335
352
|
| Command | Description |
|
|
336
353
|
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
337
|
-
| `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold.
|
|
338
|
-
| `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified.
|
|
354
|
+
| `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
|
|
355
|
+
| `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
|
|
356
|
+
| `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
|
|
339
357
|
| `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths. FATAL if scaffold missing. |
|
|
340
358
|
| `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
|
|
341
359
|
| `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
// prettier-ignore
|
|
18
18
|
import { CLAUDE_HOME, HOST, KNOWN_SETTINGS_KEYS, NEVER_SYNC, REPO_HOME, SHARED_LINKS, type PathMap } from './config.ts';
|
|
19
19
|
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
20
|
-
import { classifyRepoState, reasonForPartial } from './init.ts';
|
|
20
|
+
import { classifyRepoState, reasonForPartial } from './init.classify.ts';
|
|
21
21
|
import { findGitlinks } from './push-checks.ts';
|
|
22
22
|
import { encodePath, gitStatusPorcelainZ, readJson } from './utils.ts';
|
|
23
23
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFileSync, type ExecFileSyncOptions } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub repo owner/name pair parsed from a remote URL. Used by
|
|
5
|
+
* `cmdInit`'s auto-disable hook and `nomad doctor`'s mirror-Actions check.
|
|
6
|
+
*/
|
|
7
|
+
export type GhRepoRef = { owner: string; repo: string };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reason `ghAuthStatus` returned without success. Distinguishes the two
|
|
11
|
+
* actionable failure modes so callers can print useful tips.
|
|
12
|
+
*/
|
|
13
|
+
export type GhUnavailableReason = 'gh-not-installed' | 'gh-not-authed';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Injectable subprocess runner so tests can mock without `vi.doMock` and
|
|
17
|
+
* without touching `execFileSync` on the real shell. Default binds to
|
|
18
|
+
* `child_process.execFileSync` with the same signature.
|
|
19
|
+
*/
|
|
20
|
+
export type SpawnSyncFn = (
|
|
21
|
+
bin: string,
|
|
22
|
+
args: readonly string[],
|
|
23
|
+
opts?: ExecFileSyncOptions,
|
|
24
|
+
) => Buffer | string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maximum time in milliseconds to wait for a `gh` CLI subprocess. Prevents
|
|
28
|
+
* `nomad init` from hanging indefinitely on a slow or captive-portal network;
|
|
29
|
+
* `execFileSync` throws `ETIMEDOUT` on expiry, which the callers' try/catch
|
|
30
|
+
* blocks already handle as a silent-skip.
|
|
31
|
+
*/
|
|
32
|
+
const GH_TIMEOUT_MS = 5_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a git remote URL into `{ owner, repo }` when it points at GitHub.
|
|
36
|
+
* Returns `null` for any non-GitHub URL (other forge, local path, malformed)
|
|
37
|
+
* so the caller silently skips rather than failing init. Strips a trailing
|
|
38
|
+
* `.git` if present.
|
|
39
|
+
*/
|
|
40
|
+
export function parseGitHubRemote(remoteUrl: string): GhRepoRef | null {
|
|
41
|
+
const normalized = remoteUrl.trim().replace(/\/$/, '');
|
|
42
|
+
const m = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(normalized);
|
|
43
|
+
if (m === null) return null;
|
|
44
|
+
return { owner: m[1], repo: m[2] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check `gh` CLI availability and auth status in one call. Returns null on
|
|
49
|
+
* success or a structured reason string. `gh auth status` exits 0 when the
|
|
50
|
+
* user is authed against github.com and non-zero otherwise; ENOENT signals
|
|
51
|
+
* the binary itself is missing.
|
|
52
|
+
*/
|
|
53
|
+
export function ghAuthStatus(run: SpawnSyncFn = execFileSync): GhUnavailableReason | null {
|
|
54
|
+
try {
|
|
55
|
+
run('gh', ['auth', 'status'], {
|
|
56
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
57
|
+
timeout: GH_TIMEOUT_MS,
|
|
58
|
+
});
|
|
59
|
+
return null;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const e = err as { code?: string };
|
|
62
|
+
if (e.code === 'ENOENT') return 'gh-not-installed';
|
|
63
|
+
return 'gh-not-authed';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch the `isPrivate` flag for a repo. Throws on subprocess or JSON
|
|
69
|
+
* failure; callers wrap with try/catch and treat as silent-skip.
|
|
70
|
+
*/
|
|
71
|
+
export function isRepoPrivate(ref: GhRepoRef, run: SpawnSyncFn = execFileSync): boolean {
|
|
72
|
+
const out = run('gh', ['repo', 'view', `${ref.owner}/${ref.repo}`, '--json', 'isPrivate'], {
|
|
73
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
74
|
+
timeout: GH_TIMEOUT_MS,
|
|
75
|
+
}).toString();
|
|
76
|
+
const parsed = JSON.parse(out) as { isPrivate?: unknown };
|
|
77
|
+
return parsed.isPrivate === true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fetch the `enabled` field of the repo's Actions permissions. Throws on
|
|
82
|
+
* subprocess failure; callers wrap with try/catch.
|
|
83
|
+
*/
|
|
84
|
+
export function isActionsEnabled(ref: GhRepoRef, run: SpawnSyncFn = execFileSync): boolean {
|
|
85
|
+
const out = run(
|
|
86
|
+
'gh',
|
|
87
|
+
['api', `repos/${ref.owner}/${ref.repo}/actions/permissions`, '--jq', '.enabled'],
|
|
88
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: GH_TIMEOUT_MS },
|
|
89
|
+
)
|
|
90
|
+
.toString()
|
|
91
|
+
.trim();
|
|
92
|
+
return out === 'true';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Disable GitHub Actions on a repo. Idempotent on GitHub's side: re-disabling
|
|
97
|
+
* an already-disabled repo returns success. Throws on subprocess failure.
|
|
98
|
+
*/
|
|
99
|
+
export function disableActions(ref: GhRepoRef, run: SpawnSyncFn = execFileSync): void {
|
|
100
|
+
run(
|
|
101
|
+
'gh',
|
|
102
|
+
[
|
|
103
|
+
'api',
|
|
104
|
+
'-X',
|
|
105
|
+
'PUT',
|
|
106
|
+
`repos/${ref.owner}/${ref.repo}/actions/permissions`,
|
|
107
|
+
'-F',
|
|
108
|
+
'enabled=false',
|
|
109
|
+
],
|
|
110
|
+
{ stdio: ['ignore', 'ignore', 'pipe'], timeout: GH_TIMEOUT_MS },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read the `origin` remote URL for a git working tree at `cwd`. Throws on
|
|
116
|
+
* any failure (no remote, not a git repo); callers treat as silent-skip.
|
|
117
|
+
*/
|
|
118
|
+
export function readOriginRemote(cwd: string, run: SpawnSyncFn = execFileSync): string {
|
|
119
|
+
return run('git', ['remote', 'get-url', 'origin'], {
|
|
120
|
+
cwd,
|
|
121
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
122
|
+
})
|
|
123
|
+
.toString()
|
|
124
|
+
.trim();
|
|
125
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { type PathMap } from './config.ts';
|
|
5
|
+
import { readJson } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read-only health classifier for `cmdDoctor`'s `repo state:` header.
|
|
9
|
+
* Inspects three signals at the given `repoHome`: `shared/settings.base.json`
|
|
10
|
+
* presence, `path-map.json.projects` having at least one entry, and
|
|
11
|
+
* `hosts/<host>.json` presence.
|
|
12
|
+
*
|
|
13
|
+
* Returns `'empty'` when the base is missing AND the path-map has no entries
|
|
14
|
+
* (either missing or `projects` is empty); `'populated'` when all three
|
|
15
|
+
* signals are positive; `'partial'` for anything in between. Malformed
|
|
16
|
+
* `path-map.json` is treated as zero entries rather than thrown, so a doctor
|
|
17
|
+
* run against a corrupted scaffold still produces a classification line.
|
|
18
|
+
*
|
|
19
|
+
* The `host` parameter is passed explicitly (rather than read from the
|
|
20
|
+
* imported `HOST` constant) so the test fixture can drive multiple host
|
|
21
|
+
* scenarios without mutating module-level state via `vi.resetModules()`.
|
|
22
|
+
*/
|
|
23
|
+
export function classifyRepoState(
|
|
24
|
+
repoHome: string,
|
|
25
|
+
host: string,
|
|
26
|
+
): 'empty' | 'partial' | 'populated' {
|
|
27
|
+
const basePath = join(repoHome, 'shared', 'settings.base.json');
|
|
28
|
+
const mapPath = join(repoHome, 'path-map.json');
|
|
29
|
+
const hostPath = join(repoHome, 'hosts', `${host}.json`);
|
|
30
|
+
|
|
31
|
+
const hasBase = existsSync(basePath);
|
|
32
|
+
const hasMap = existsSync(mapPath);
|
|
33
|
+
const hasHost = existsSync(hostPath);
|
|
34
|
+
|
|
35
|
+
let mapEntryCount = 0;
|
|
36
|
+
if (hasMap) {
|
|
37
|
+
try {
|
|
38
|
+
const map = readJson<PathMap>(mapPath);
|
|
39
|
+
mapEntryCount = Object.keys(map.projects).length;
|
|
40
|
+
} catch {
|
|
41
|
+
// Malformed JSON: treat as zero entries, do NOT throw. The doctor's
|
|
42
|
+
// own JSON-parse FAIL line will surface the malformed file separately.
|
|
43
|
+
mapEntryCount = 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!hasBase && mapEntryCount === 0) return 'empty';
|
|
48
|
+
if (hasBase && mapEntryCount > 0 && hasHost) return 'populated';
|
|
49
|
+
return 'partial';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Suffix that follows `repo state: WARN partial` per the fixed priority
|
|
54
|
+
* order. First matching condition wins, exactly one suffix per line.
|
|
55
|
+
* Inspects the same on-disk signals `classifyRepoState` reads (base file,
|
|
56
|
+
* `path-map.json` + its `.projects` entry count, `hosts/<host>.json`), but
|
|
57
|
+
* explicitly distinguishes "path-map missing" from "path-map present but
|
|
58
|
+
* empty" because users debug differently for each.
|
|
59
|
+
*
|
|
60
|
+
* Lives alongside `classifyRepoState` so the suffix rules and the classifier
|
|
61
|
+
* stay co-located: changes to one almost always require updating the other.
|
|
62
|
+
* Returns the string with a leading `- ` separator so the caller can
|
|
63
|
+
* concatenate directly without re-deciding the separator.
|
|
64
|
+
*/
|
|
65
|
+
export function reasonForPartial(repoHome: string, host: string): string {
|
|
66
|
+
const basePath = join(repoHome, 'shared', 'settings.base.json');
|
|
67
|
+
const mapPath = join(repoHome, 'path-map.json');
|
|
68
|
+
const hostPath = join(repoHome, 'hosts', `${host}.json`);
|
|
69
|
+
if (!existsSync(basePath)) return '- shared/settings.base.json missing';
|
|
70
|
+
if (!existsSync(mapPath)) return '- path-map.json missing';
|
|
71
|
+
let mapEntryCount: number;
|
|
72
|
+
try {
|
|
73
|
+
const map = readJson<PathMap>(mapPath);
|
|
74
|
+
mapEntryCount = Object.keys(map.projects).length;
|
|
75
|
+
} catch {
|
|
76
|
+
// Malformed JSON: treat as zero entries. Doctor's own JSON-parse FAIL
|
|
77
|
+
// line surfaces the malformed file separately.
|
|
78
|
+
mapEntryCount = 0;
|
|
79
|
+
}
|
|
80
|
+
if (mapEntryCount === 0) return '- path-map.json.projects has no entries';
|
|
81
|
+
if (!existsSync(hostPath)) return `- hosts/${host}.json missing`;
|
|
82
|
+
// Defensive fallback: classifyRepoState returned 'partial' for a reason
|
|
83
|
+
// not captured by the four signals above. Should be unreachable in
|
|
84
|
+
// practice because the priority order is exhaustive against the
|
|
85
|
+
// classifier's definition of populated.
|
|
86
|
+
return '- partial state (unknown gap)';
|
|
87
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -2,8 +2,17 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { CLAUDE_HOME, type PathMap, REPO_HOME } from './config.ts';
|
|
5
|
+
import {
|
|
6
|
+
disableActions,
|
|
7
|
+
ghAuthStatus,
|
|
8
|
+
isActionsEnabled,
|
|
9
|
+
isRepoPrivate,
|
|
10
|
+
parseGitHubRemote,
|
|
11
|
+
readOriginRemote,
|
|
12
|
+
type SpawnSyncFn,
|
|
13
|
+
} from './gh-actions.ts';
|
|
5
14
|
import { snapshotIntoShared } from './init.snapshot.ts';
|
|
6
|
-
import { die, log,
|
|
15
|
+
import { die, log, writeJsonAtomic } from './utils.ts';
|
|
7
16
|
|
|
8
17
|
/**
|
|
9
18
|
* The HTML comment line that anchors `shared/CLAUDE.md` on a fresh scaffold.
|
|
@@ -59,8 +68,11 @@ function preflightConflict(repoHome: string): string | null {
|
|
|
59
68
|
* identical to plain init; a bare `shared/` dir is enough to refuse since
|
|
60
69
|
* partial state is unsafe to merge with.
|
|
61
70
|
*/
|
|
62
|
-
export function cmdInit(
|
|
71
|
+
export function cmdInit(
|
|
72
|
+
opts: { snapshot?: boolean; keepActions?: boolean; run?: SpawnSyncFn } = {},
|
|
73
|
+
): void {
|
|
63
74
|
const snapshot = opts.snapshot === true;
|
|
75
|
+
const keepActions = opts.keepActions === true;
|
|
64
76
|
|
|
65
77
|
const conflict = preflightConflict(REPO_HOME);
|
|
66
78
|
if (conflict !== null) {
|
|
@@ -104,87 +116,78 @@ export function cmdInit(opts: { snapshot?: boolean } = {}): void {
|
|
|
104
116
|
log('~/.claude/ originals were NOT removed.');
|
|
105
117
|
}
|
|
106
118
|
|
|
119
|
+
if (!keepActions) {
|
|
120
|
+
maybeDisableMirrorActions(REPO_HOME, opts.run);
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
log('init complete');
|
|
108
124
|
}
|
|
109
125
|
|
|
110
126
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `
|
|
115
|
-
*
|
|
116
|
-
* Returns `'empty'` when the base is missing AND the path-map has no entries
|
|
117
|
-
* (either missing or `projects` is empty); `'populated'` when all three
|
|
118
|
-
* signals are positive; `'partial'` for anything in between. Malformed
|
|
119
|
-
* `path-map.json` is treated as zero entries rather than thrown, so a doctor
|
|
120
|
-
* run against a corrupted scaffold still produces a classification line.
|
|
127
|
+
* Best-effort hook that disables GitHub Actions on the user's private mirror
|
|
128
|
+
* after a fresh `nomad init`. The private mirror is a settings store, not a
|
|
129
|
+
* CI target; leaving Actions enabled there causes the mirror-pushed workflows
|
|
130
|
+
* (release-please, npm-publish, etc.) to fire on every `nomad push`, which is
|
|
131
|
+
* pure noise.
|
|
121
132
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
133
|
+
* Silently no-ops when: the repo is not a git repo, the origin remote is not
|
|
134
|
+
* GitHub, the origin is public (not a private mirror), `gh` CLI is missing,
|
|
135
|
+
* or `gh` is not authed. Prints a tip on the last two so the user can finish
|
|
136
|
+
* the step manually. Suppress entirely with `nomad init --keep-actions`.
|
|
125
137
|
*/
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const hostPath = join(repoHome, 'hosts', `${host}.json`);
|
|
133
|
-
|
|
134
|
-
const hasBase = existsSync(basePath);
|
|
135
|
-
const hasMap = existsSync(mapPath);
|
|
136
|
-
const hasHost = existsSync(hostPath);
|
|
137
|
-
|
|
138
|
-
let mapEntryCount = 0;
|
|
139
|
-
if (hasMap) {
|
|
140
|
-
try {
|
|
141
|
-
const map = readJson<PathMap>(mapPath);
|
|
142
|
-
mapEntryCount = Object.keys(map.projects).length;
|
|
143
|
-
} catch {
|
|
144
|
-
// Malformed JSON: treat as zero entries, do NOT throw. The doctor's
|
|
145
|
-
// own JSON-parse FAIL line will surface the malformed file separately.
|
|
146
|
-
mapEntryCount = 0;
|
|
147
|
-
}
|
|
138
|
+
function maybeDisableMirrorActions(repoHome: string, run?: SpawnSyncFn): void {
|
|
139
|
+
let remote: string;
|
|
140
|
+
try {
|
|
141
|
+
remote = readOriginRemote(repoHome, run);
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
148
144
|
}
|
|
145
|
+
const ref = parseGitHubRemote(remote);
|
|
146
|
+
if (ref === null) return;
|
|
149
147
|
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
}
|
|
148
|
+
const ghStatus = ghAuthStatus(run);
|
|
149
|
+
if (ghStatus === 'gh-not-installed') {
|
|
150
|
+
log(
|
|
151
|
+
`tip: install gh CLI and run 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false' to disable Actions on your private mirror.`,
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (ghStatus === 'gh-not-authed') {
|
|
156
|
+
log(
|
|
157
|
+
`tip: run 'gh auth login' then 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false' to disable Actions on your private mirror.`,
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let isPrivate: boolean;
|
|
163
|
+
try {
|
|
164
|
+
isPrivate = isRepoPrivate(ref, run);
|
|
165
|
+
} catch {
|
|
166
|
+
log(
|
|
167
|
+
`could not determine privacy for ${ref.owner}/${ref.repo}; run 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false' manually if it is private.`,
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!isPrivate) return;
|
|
172
|
+
|
|
173
|
+
let alreadyDisabled = false;
|
|
174
|
+
try {
|
|
175
|
+
alreadyDisabled = !isActionsEnabled(ref, run);
|
|
176
|
+
} catch {
|
|
177
|
+
// Treat as enabled and attempt the disable; the API call itself is
|
|
178
|
+
// idempotent so this is safe.
|
|
179
|
+
}
|
|
180
|
+
if (alreadyDisabled) {
|
|
181
|
+
log(`actions already disabled on ${ref.owner}/${ref.repo}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
154
184
|
|
|
155
|
-
/**
|
|
156
|
-
* Suffix that follows `repo state: WARN partial` per the fixed priority
|
|
157
|
-
* order. First matching condition wins, exactly one suffix per line.
|
|
158
|
-
* Inspects the same on-disk signals `classifyRepoState` reads (base file,
|
|
159
|
-
* `path-map.json` + its `.projects` entry count, `hosts/<host>.json`), but
|
|
160
|
-
* explicitly distinguishes "path-map missing" from "path-map present but
|
|
161
|
-
* empty" because users debug differently for each.
|
|
162
|
-
*
|
|
163
|
-
* Lives alongside `classifyRepoState` so the suffix rules and the classifier
|
|
164
|
-
* stay co-located: changes to one almost always require updating the other.
|
|
165
|
-
* Returns the string with a leading `- ` separator so the caller can
|
|
166
|
-
* concatenate directly without re-deciding the separator.
|
|
167
|
-
*/
|
|
168
|
-
export function reasonForPartial(repoHome: string, host: string): string {
|
|
169
|
-
const basePath = join(repoHome, 'shared', 'settings.base.json');
|
|
170
|
-
const mapPath = join(repoHome, 'path-map.json');
|
|
171
|
-
const hostPath = join(repoHome, 'hosts', `${host}.json`);
|
|
172
|
-
if (!existsSync(basePath)) return '- shared/settings.base.json missing';
|
|
173
|
-
if (!existsSync(mapPath)) return '- path-map.json missing';
|
|
174
|
-
let mapEntryCount: number;
|
|
175
185
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
disableActions(ref, run);
|
|
187
|
+
log(`disabled GitHub Actions on private mirror ${ref.owner}/${ref.repo}`);
|
|
178
188
|
} catch {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
189
|
+
log(
|
|
190
|
+
`could not auto-disable Actions on ${ref.owner}/${ref.repo}; run 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false' manually.`,
|
|
191
|
+
);
|
|
182
192
|
}
|
|
183
|
-
if (mapEntryCount === 0) return '- path-map.json.projects has no entries';
|
|
184
|
-
if (!existsSync(hostPath)) return `- hosts/${host}.json missing`;
|
|
185
|
-
// Defensive fallback: classifyRepoState returned 'partial' for a reason
|
|
186
|
-
// not captured by the four signals above. Should be unreachable in
|
|
187
|
-
// practice because the priority order is exhaustive against the
|
|
188
|
-
// classifier's definition of populated.
|
|
189
|
-
return '- partial state (unknown gap)';
|
|
190
193
|
}
|
package/src/nomad.ts
CHANGED
|
@@ -57,7 +57,8 @@ const DEFAULT_HELP = [
|
|
|
57
57
|
' No git pull, no lock acquired.',
|
|
58
58
|
'',
|
|
59
59
|
' init Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).',
|
|
60
|
-
' --snapshot
|
|
60
|
+
' --snapshot Overlay the current ~/.claude/ into shared/ as the initial seed.',
|
|
61
|
+
' --keep-actions Skip auto-disabling GitHub Actions on the private mirror.',
|
|
61
62
|
'',
|
|
62
63
|
' doctor Read-only health check (symlinks, host file, path-map,',
|
|
63
64
|
' gitleaks, gitlinks).',
|
|
@@ -128,20 +129,28 @@ try {
|
|
|
128
129
|
}
|
|
129
130
|
break;
|
|
130
131
|
}
|
|
131
|
-
case 'init':
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
132
|
+
case 'init': {
|
|
133
|
+
// Set-based parse so flag order does not matter and duplicates are
|
|
134
|
+
// rejected. Unknown flags hit the same usage-error pattern as other
|
|
135
|
+
// subcommands.
|
|
136
|
+
const known = new Set(['--snapshot', '--keep-actions']);
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
let argvOk = true;
|
|
139
|
+
for (let i = 3; i < process.argv.length; i++) {
|
|
140
|
+
const flag = process.argv[i];
|
|
141
|
+
if (!known.has(flag) || seen.has(flag)) {
|
|
142
|
+
argvOk = false;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
seen.add(flag);
|
|
146
|
+
}
|
|
147
|
+
if (!argvOk) {
|
|
148
|
+
console.error('usage: nomad init [--snapshot] [--keep-actions]');
|
|
142
149
|
process.exit(1);
|
|
143
150
|
}
|
|
151
|
+
cmdInit({ snapshot: seen.has('--snapshot'), keepActions: seen.has('--keep-actions') });
|
|
144
152
|
break;
|
|
153
|
+
}
|
|
145
154
|
case 'diff':
|
|
146
155
|
// Offline, lockless preview against local repo state. No git pull, no
|
|
147
156
|
// lock acquisition. Reject any argv after `diff` since this slice
|