claude-nomad 0.32.4 → 0.34.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 +20 -0
- package/README.md +180 -200
- package/package.json +5 -6
- package/src/commands.doctor.version.ts +19 -22
- package/src/commands.update.ts +18 -179
- package/src/config.ts +1 -8
- package/src/init.gh-onboard.ts +139 -0
- package/src/init.ts +19 -1
- package/src/nomad.dispatch.ts +95 -0
- package/src/nomad.help.ts +13 -8
- package/src/nomad.ts +19 -20
- package/src/push-checks.ts +16 -10
- package/src/push-gitleaks.config.ts +161 -0
- package/src/push-gitleaks.scan.ts +35 -12
- package/src/commands.update.git.ts +0 -90
- package/src/commands.update.resolve.ts +0 -138
- package/src/commands.update.test-helpers.git.ts +0 -107
- package/src/update.fork-extras.ts +0 -102
- package/src/update.topology.ts +0 -118
|
@@ -5,17 +5,17 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
|
|
6
6
|
import { dim, green, infoGlyph, okGlyph, warnGlyph, yellow } from './color.ts';
|
|
7
7
|
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
8
|
-
import { HOME
|
|
8
|
+
import { HOME } from './config.ts';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Soft, offline-tolerant release-version check appended to `cmdDoctor`. Reads
|
|
12
|
-
* the local `package.json.version`, compares it to the latest
|
|
13
|
-
* the
|
|
12
|
+
* the local `package.json.version`, compares it to the latest published version
|
|
13
|
+
* on the npm registry (cached 1h, 3s curl timeout), and emits one of:
|
|
14
14
|
* - `✓ claude-nomad: <local> (latest)` when local == latest
|
|
15
15
|
* - `⚠︎ claude-nomad: <local> -> <latest>` when local < latest
|
|
16
16
|
* - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when local > latest
|
|
17
17
|
* Every failure path (offline, curl missing, non-2xx, malformed JSON, missing
|
|
18
|
-
* `
|
|
18
|
+
* `version`, missing/unreadable package.json) is a SILENT skip; this module
|
|
19
19
|
* never sets `process.exitCode` and never writes to stderr.
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -85,7 +85,7 @@ function readLocalVersion(): string | null {
|
|
|
85
85
|
* Load the cached latest-tag entry. Returns the parsed entry when the file
|
|
86
86
|
* exists, parses cleanly, and matches the expected shape (`checked_at` finite
|
|
87
87
|
* number, `latest` strict-semver string); any failure surfaces as `null` so
|
|
88
|
-
* the caller falls through to `
|
|
88
|
+
* the caller falls through to `fetchLatestVersion`. Treating malformed cache as a
|
|
89
89
|
* miss is the safer default than crashing or surfacing the error.
|
|
90
90
|
*/
|
|
91
91
|
function loadCache(): CacheEntry | null {
|
|
@@ -121,29 +121,26 @@ function saveCache(latest: string): void {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
|
-
* Fetch the latest
|
|
124
|
+
* Fetch the latest published version from the npm registry. Uses
|
|
125
125
|
* `execFileSync('curl', ...)` rather than `node:https` because curl honors
|
|
126
126
|
* system proxies and respects the `-m` timeout reliably. curl is optional, not
|
|
127
127
|
* a hard dependency: this is its only consumer, so a host without curl simply
|
|
128
128
|
* skips the version line. 3-second timeout, fail-fast on non-2xx (`-f`), silent
|
|
129
129
|
* (`-s`), follow redirects (`-L`). Returns `null` on ANY failure path (curl
|
|
130
|
-
* missing from PATH, a missing `
|
|
131
|
-
* strict-semver validation
|
|
132
|
-
*
|
|
130
|
+
* missing from PATH, a missing or non-string `version` field, or a version that
|
|
131
|
+
* fails strict-semver validation). The npm registry `version` field is already
|
|
132
|
+
* bare semver (no leading `v` strip needed).
|
|
133
133
|
*/
|
|
134
|
-
function
|
|
134
|
+
function fetchLatestVersion(): string | null {
|
|
135
135
|
try {
|
|
136
|
-
const url =
|
|
137
|
-
const raw = execFileSync(
|
|
138
|
-
'
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const tag = parsed.tag_name.startsWith('v') ? parsed.tag_name.slice(1) : parsed.tag_name;
|
|
145
|
-
if (!STRICT_SEMVER.test(tag)) return null;
|
|
146
|
-
return tag;
|
|
136
|
+
const url = 'https://registry.npmjs.org/claude-nomad/latest';
|
|
137
|
+
const raw = execFileSync('curl', ['-fsSL', '-m', '3', url], {
|
|
138
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
139
|
+
}).toString();
|
|
140
|
+
const parsed = JSON.parse(raw) as { version?: unknown };
|
|
141
|
+
if (typeof parsed.version !== 'string') return null;
|
|
142
|
+
if (!STRICT_SEMVER.test(parsed.version)) return null;
|
|
143
|
+
return parsed.version;
|
|
147
144
|
} catch {
|
|
148
145
|
return null;
|
|
149
146
|
}
|
|
@@ -174,7 +171,7 @@ export function reportVersionCheck(section: DoctorSection): void {
|
|
|
174
171
|
latest = cached.latest;
|
|
175
172
|
}
|
|
176
173
|
if (latest === null) {
|
|
177
|
-
latest =
|
|
174
|
+
latest = fetchLatestVersion();
|
|
178
175
|
if (latest === null) return;
|
|
179
176
|
saveCache(latest);
|
|
180
177
|
}
|
package/src/commands.update.ts
CHANGED
|
@@ -1,191 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { defaultPrompt, tryAutoResolveMergeConflict } from './commands.update.resolve.ts';
|
|
6
|
-
import { REPO_HOME } from './config.ts';
|
|
7
|
-
import { commitRegeneratedLockfile, precommitForkExtras } from './update.fork-extras.ts';
|
|
8
|
-
import { loadTopology } from './update.topology.ts';
|
|
9
|
-
import { die, gitOrFatal, gitStatusPorcelainZ, log, warn } from './utils.ts';
|
|
3
|
+
import { type SpawnSyncFn } from './gh-actions.ts';
|
|
4
|
+
import { NomadFatal } from './utils.ts';
|
|
10
5
|
|
|
11
6
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
export type CmdUpdateOpts = {
|
|
16
|
-
/** When true, run topology detection + pre-flight only; print would-be git
|
|
17
|
-
* commands without mutating the repo. Skips the trailing `cmdDoctor` call. */
|
|
18
|
-
dryRun?: boolean;
|
|
19
|
-
/** When true, proceed even when `gitStatusPorcelainZ(REPO_HOME)` is
|
|
20
|
-
* non-empty. Emits a WARN log line before continuing. */
|
|
21
|
-
force?: boolean;
|
|
22
|
-
/** Fork topology only: when true, push the post-merge HEAD to `origin/main`
|
|
23
|
-
* without prompting. When false/unset, the user is prompted y/N. */
|
|
24
|
-
pushOrigin?: boolean;
|
|
25
|
-
/** Test injection point for the interactive y/N prompt. Production code
|
|
26
|
-
* reads one line from `/dev/tty`; tests override this to return a
|
|
27
|
-
* deterministic answer without a real controlling terminal. */
|
|
28
|
-
prompt?: (question: string) => string;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Perform a vanilla update by fast-forward pulling `origin/main`.
|
|
33
|
-
*
|
|
34
|
-
* Non-ff pulls (someone else pushed in the meantime) surface as `NomadFatal`
|
|
35
|
-
* via `gitOrFatal`. If `opts.dryRun` is true, logs the would-be pull command
|
|
36
|
-
* instead of executing it. Takes the full `CmdUpdateOpts` so the signature
|
|
37
|
-
* stays symmetric with `runFork` even though only `dryRun` is consulted
|
|
38
|
-
* today.
|
|
39
|
-
*
|
|
40
|
-
* @param opts - Update options; only `dryRun` is observed for this topology.
|
|
41
|
-
* @returns `true` when this path already ran `npm install` and committed the merged lockfile (so the caller should skip `reinstallIfNeeded`). Vanilla `--ff-only` pulls never conflict, so this is always `false`.
|
|
42
|
-
*/
|
|
43
|
-
function runVanilla(opts: CmdUpdateOpts): boolean {
|
|
44
|
-
if (opts.dryRun === true) {
|
|
45
|
-
log('DRY-RUN: would run `git pull --ff-only origin main`');
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
gitOrFatal(['pull', '--ff-only', 'origin', 'main'], 'git pull', REPO_HOME);
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Perform a fork-style update by fetching from `upstream`, merging `upstream/main` into `main`, and optionally pushing the merge to `origin`.
|
|
7
|
+
* Update the claude-nomad CLI to the latest published npm release by running
|
|
8
|
+
* `npm update -g claude-nomad`.
|
|
54
9
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* pushes to `origin/main` without prompting; otherwise it prompts (via
|
|
60
|
-
* `opts.prompt` if provided, or the default `/dev/tty` prompt) and only
|
|
61
|
-
* pushes when the answer is `y` or `yes` (case-insensitive). Non-affirmative
|
|
62
|
-
* answers skip the push and log a "run later" hint.
|
|
10
|
+
* Design decision D-01: self-update and data sync are separate concerns. This
|
|
11
|
+
* command only updates the CLI binary; it does NOT run `nomad pull`, `nomad
|
|
12
|
+
* doctor`, or any git operation. Use `nomad pull` after updating if you want
|
|
13
|
+
* to sync config state.
|
|
63
14
|
*
|
|
64
|
-
*
|
|
65
|
-
* `beforeSha`, there is nothing new to push: the function logs a one-line
|
|
66
|
-
* "already in sync" and returns without pushing or prompting (issue #66). An
|
|
67
|
-
* auto-resolved conflict always advances `HEAD` via its merge commit, so that
|
|
68
|
-
* path is never mistaken for a no-op.
|
|
15
|
+
* Uses an argv-array (no shell) with an injectable `run` for test isolation.
|
|
69
16
|
*
|
|
70
|
-
* @param
|
|
71
|
-
*
|
|
72
|
-
* - `pushOrigin`: when true, push to `origin/main` without prompting
|
|
73
|
-
* - `prompt`: optional prompt function used for interactive confirmation
|
|
74
|
-
* @param beforeSha - `HEAD` SHA captured before the fork update began; the
|
|
75
|
-
* post-merge `HEAD` is compared against it to detect a no-op. When omitted
|
|
76
|
-
* (dry-run preview) the no-op short-circuit is skipped.
|
|
17
|
+
* @param run - Subprocess runner; defaults to `execFileSync`. Inject a fake in
|
|
18
|
+
* tests to assert the exact args without touching the real npm registry.
|
|
77
19
|
*/
|
|
78
|
-
function
|
|
79
|
-
const promptFn = opts.prompt ?? defaultPrompt;
|
|
80
|
-
if (opts.dryRun === true) {
|
|
81
|
-
log('DRY-RUN: would run `git fetch upstream`');
|
|
82
|
-
log('DRY-RUN: would run `git merge upstream/main`');
|
|
83
|
-
if (opts.pushOrigin === true) {
|
|
84
|
-
log('DRY-RUN: would run `git push origin main`');
|
|
85
|
-
} else {
|
|
86
|
-
log('DRY-RUN: would prompt before pushing to origin/main');
|
|
87
|
-
}
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
gitOrFatal(['fetch', 'upstream'], 'git fetch upstream', REPO_HOME);
|
|
91
|
-
// Pre-commit whitelisted extras (issue #112): otherwise untracked
|
|
92
|
-
// shared/extras/ content that upstream also adds makes the merge abort
|
|
93
|
-
// pre-merge with no UU state, so the lone-lockfile auto-resolve never fires.
|
|
94
|
-
precommitForkExtras();
|
|
95
|
-
let autoResolved = false;
|
|
20
|
+
export function cmdUpdate(run: SpawnSyncFn = execFileSync): void {
|
|
96
21
|
try {
|
|
97
|
-
|
|
22
|
+
run('npm', ['update', '-g', 'claude-nomad'], { stdio: 'inherit' });
|
|
98
23
|
} catch (err) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// No-op merge (and no extras precommit): HEAD never moved, so there is
|
|
103
|
-
// nothing new to push. A `beforeSha` of undefined (dry-run never reaches
|
|
104
|
-
// here) can never equal a real SHA, so the comparison is self-guarding.
|
|
105
|
-
if (headSha() === beforeSha) {
|
|
106
|
-
log('already in sync with origin/main, nothing to push');
|
|
107
|
-
return autoResolved;
|
|
108
|
-
}
|
|
109
|
-
if (opts.pushOrigin === true) {
|
|
110
|
-
gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
|
|
111
|
-
return autoResolved;
|
|
112
|
-
}
|
|
113
|
-
const answer = promptFn(
|
|
114
|
-
'Push merge to origin/main? (y publishes to your private mirror so other hosts see it; N keeps it local) [y/N] ',
|
|
115
|
-
).toLowerCase();
|
|
116
|
-
if (answer === 'y' || answer === 'yes') {
|
|
117
|
-
gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
|
|
118
|
-
} else {
|
|
119
|
-
log('skipping push to origin (run `git push origin main` later)');
|
|
120
|
-
}
|
|
121
|
-
return autoResolved;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Perform a topology-aware repository update.
|
|
126
|
-
*
|
|
127
|
-
* Detects `vanilla` (`origin` -> public) vs `fork` (`upstream` -> public,
|
|
128
|
-
* `origin` -> private mirror) layouts, runs the right git invocation, runs
|
|
129
|
-
* `npm install` only when `package-lock.json` changed in the update, and
|
|
130
|
-
* ends with `cmdDoctor()` so the version-check PASS line confirms the
|
|
131
|
-
* upgrade landed.
|
|
132
|
-
*
|
|
133
|
-
* Pre-flight (each fatal unless overridden):
|
|
134
|
-
* 1. `REPO_HOME` exists.
|
|
135
|
-
* 2. Topology resolves to `vanilla` or `fork`.
|
|
136
|
-
* 3. `--push-origin` is fork-only (rejected on `vanilla`).
|
|
137
|
-
* 4. Current branch is `main`.
|
|
138
|
-
* 5. Working tree clean per `gitStatusPorcelainZ` (override with `force`).
|
|
139
|
-
*
|
|
140
|
-
* Fork-topology prompts read one line from `/dev/tty`; tests inject
|
|
141
|
-
* `opts.prompt` to bypass the TTY read.
|
|
142
|
-
*
|
|
143
|
-
* @param opts - Update options. `dryRun` runs pre-flight + logs the would-be git, install, and doctor actions without mutating the repo or invoking `cmdDoctor`. `force` proceeds past a dirty working tree. `pushOrigin` (fork topology only) skips the y/N prompt. `prompt` injects a synchronous answer function for tests.
|
|
144
|
-
*/
|
|
145
|
-
export function cmdUpdate(opts: CmdUpdateOpts = {}): void {
|
|
146
|
-
if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
|
|
147
|
-
|
|
148
|
-
const topology = loadTopology();
|
|
149
|
-
if (topology === 'unknown') {
|
|
150
|
-
die(
|
|
151
|
-
`could not detect upstream remote in ${REPO_HOME}. Run \`git fetch <remote>\` and \`git merge <remote>/main\` manually.`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (topology === 'vanilla' && opts.pushOrigin === true) {
|
|
156
|
-
die('`--push-origin` is only valid for fork topology');
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const branch = currentBranch();
|
|
160
|
-
if (branch !== 'main') {
|
|
161
|
-
die(`current branch is \`${branch}\`, expected \`main\``);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const status = gitStatusPorcelainZ(REPO_HOME);
|
|
165
|
-
if (status.length > 0) {
|
|
166
|
-
if (opts.force !== true) {
|
|
167
|
-
die('working tree is not clean, use `--force` to override');
|
|
24
|
+
const e = err as NodeJS.ErrnoException;
|
|
25
|
+
if (e.code === 'ENOENT') {
|
|
26
|
+
throw new NomadFatal('npm not found on PATH; install Node.js/npm and retry.');
|
|
168
27
|
}
|
|
169
|
-
|
|
28
|
+
throw new NomadFatal(`npm update -g claude-nomad failed: ${e.message}`);
|
|
170
29
|
}
|
|
171
|
-
|
|
172
|
-
log(`topology: ${topology}`);
|
|
173
|
-
|
|
174
|
-
if (opts.dryRun === true) {
|
|
175
|
-
if (topology === 'vanilla') runVanilla(opts);
|
|
176
|
-
else runFork(opts);
|
|
177
|
-
log('DRY-RUN: would run `npm install` only if `package-lock.json` changed');
|
|
178
|
-
log('DRY-RUN: would run `nomad doctor` to confirm the upgrade');
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const beforeSha = headSha();
|
|
183
|
-
const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts, beforeSha);
|
|
184
|
-
|
|
185
|
-
if (!installAlreadyRan) reinstallIfNeeded(beforeSha);
|
|
186
|
-
// Secondary item of issue #112: a post-merge `npm install` that regenerated
|
|
187
|
-
// package-lock.json leaves uncommitted drift the trailing doctor flags.
|
|
188
|
-
// Commit just the lockfile (fork topology only) so the repo is clean.
|
|
189
|
-
if (topology === 'fork') commitRegeneratedLockfile();
|
|
190
|
-
cmdDoctor();
|
|
191
30
|
}
|
package/src/config.ts
CHANGED
|
@@ -31,14 +31,6 @@ export const CLAUDE_HOME = resolve(HOME, '.claude');
|
|
|
31
31
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
32
32
|
export const REPO_HOME = process.env.NOMAD_REPO || resolve(HOME, 'claude-nomad');
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
* Upstream GitHub repository slug for the release-version check in
|
|
36
|
-
* `nomad doctor`. Hardcoded for the same reason `REPO_HOME` is hardcoded:
|
|
37
|
-
* the deployed sync target is canonical for this CLI. Source of truth for
|
|
38
|
-
* the `GET /repos/<slug>/releases/latest` call in `reportVersionCheck`.
|
|
39
|
-
*/
|
|
40
|
-
export const UPSTREAM_REPO_SLUG = 'funkadelic/claude-nomad';
|
|
41
|
-
|
|
42
34
|
/**
|
|
43
35
|
* The official Claude Code settings JSON schema. Source of truth for
|
|
44
36
|
* `SCHEMA_KEYS` (kept current by `scripts/sync-settings-keys.ts`) and the
|
|
@@ -189,6 +181,7 @@ export const PUSH_ALLOWED_STATIC = [
|
|
|
189
181
|
'hosts/',
|
|
190
182
|
'path-map.json',
|
|
191
183
|
'.gitleaksignore', // written by nomad push Allow action (D-04)
|
|
184
|
+
'.gitleaks.overlay.toml', // user-owned gitleaks allowlist overlay layered on the bundled base
|
|
192
185
|
] as const;
|
|
193
186
|
|
|
194
187
|
/**
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { REPO_HOME } from './config.ts';
|
|
4
|
+
import { ghAuthStatus, readOriginRemote, type SpawnSyncFn } from './gh-actions.ts';
|
|
5
|
+
import { die, log, NomadFatal } from './utils.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default private GitHub repository name used by `ensureOriginRepo` when no
|
|
9
|
+
* explicit `--repo <name>` flag is provided. Users can override with
|
|
10
|
+
* `nomad init --repo <name>`.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_REPO_NAME = 'claude-nomad-config';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validate a user-supplied GitHub repository name. Accepts only characters
|
|
16
|
+
* that GitHub allows: alphanumerics, hyphens, underscores, and dots, up to
|
|
17
|
+
* 100 characters. This blocks argument-injection or path-escape attempts when
|
|
18
|
+
* the name flows into subprocess argv (T-32-06).
|
|
19
|
+
*/
|
|
20
|
+
function isValidRepoName(name: string): boolean {
|
|
21
|
+
return /^[A-Za-z0-9._-]{1,100}$/.test(name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Timeout for the network-bound `gh` calls in the onboarding create flow. These
|
|
26
|
+
* hit the GitHub API (repo creation, user lookup) and may also refresh auth, so
|
|
27
|
+
* a tight bound risks a false NomadFatal on a slow link. Generous on purpose:
|
|
28
|
+
* this is a one-time, user-initiated step, not the soft doctor version probe.
|
|
29
|
+
*/
|
|
30
|
+
const GH_NETWORK_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ensure REPO_HOME has a GitHub `origin` remote. When one already exists the
|
|
34
|
+
* function is a no-op (D-09 idempotency). When none exists, a new private
|
|
35
|
+
* repository named `repoName` is created via `gh repo create`, the owner is
|
|
36
|
+
* resolved from `gh api user`, and `git remote add origin` is wired into
|
|
37
|
+
* REPO_HOME. All subprocess calls use the argv-array form via the injectable
|
|
38
|
+
* `run` runner; no shell strings are used (T-32-06).
|
|
39
|
+
*
|
|
40
|
+
* `gh` is a hard prerequisite on this path: missing or unauthenticated `gh`
|
|
41
|
+
* results in a `NomadFatal` (D-08), not a soft tip.
|
|
42
|
+
*
|
|
43
|
+
* @param repoName - The GitHub repository name to create (validated by
|
|
44
|
+
* `isValidRepoName` before any subprocess call).
|
|
45
|
+
* @param run - Injectable subprocess runner; defaults to `execFileSync`.
|
|
46
|
+
*/
|
|
47
|
+
export function ensureOriginRepo(repoName: string, run: SpawnSyncFn = execFileSync): void {
|
|
48
|
+
if (!isValidRepoName(repoName)) {
|
|
49
|
+
die(
|
|
50
|
+
`invalid repo name: ${JSON.stringify(repoName)}. Use only letters, digits, hyphens, underscores, and dots (1-100 chars).`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fast idempotency path: if origin is already wired, nothing to do (D-09).
|
|
55
|
+
try {
|
|
56
|
+
readOriginRemote(REPO_HOME, run);
|
|
57
|
+
return;
|
|
58
|
+
} catch {
|
|
59
|
+
// No origin configured; fall through to the create flow.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// gh is a hard prerequisite when no origin is present (D-08).
|
|
63
|
+
const ghStatus = ghAuthStatus(run);
|
|
64
|
+
if (ghStatus === 'gh-not-installed') {
|
|
65
|
+
die('gh CLI is required for nomad init. Install: https://cli.github.com');
|
|
66
|
+
}
|
|
67
|
+
if (ghStatus === 'gh-probe-error') {
|
|
68
|
+
die('could not verify gh CLI status (network issue?). Retry, or check `gh auth status`.');
|
|
69
|
+
}
|
|
70
|
+
if (ghStatus !== null) {
|
|
71
|
+
die('gh CLI is not authenticated. Run `gh auth login` and retry.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Initialize REPO_HOME as a git repo so `git remote add` below has a
|
|
75
|
+
// repository to write to. On a first host REPO_HOME is a brand-new empty
|
|
76
|
+
// directory (just mkdir'd by cmdInit); without this `git remote add` fails
|
|
77
|
+
// with "not a git repository". `git init` is idempotent: re-running on an
|
|
78
|
+
// already-initialized repo reinitializes harmlessly and leaves the branch
|
|
79
|
+
// and config untouched.
|
|
80
|
+
try {
|
|
81
|
+
run('git', ['init', '-b', 'main'], { cwd: REPO_HOME, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const e = err as NodeJS.ErrnoException;
|
|
84
|
+
throw new NomadFatal(`git init failed: ${e.message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Create the private repo on GitHub. When the repo already exists on the
|
|
88
|
+
// account, gh exits non-zero; treat that as a no-op and fall through to wire
|
|
89
|
+
// origin rather than failing (D-09 idempotency: a prior run may have created
|
|
90
|
+
// the repo but died before `git remote add`). Any other failure is fatal.
|
|
91
|
+
try {
|
|
92
|
+
run('gh', ['repo', 'create', repoName, '--private'], {
|
|
93
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
94
|
+
timeout: GH_NETWORK_TIMEOUT_MS,
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const e = err as NodeJS.ErrnoException & { stderr?: Buffer | string };
|
|
98
|
+
const detail = String(e.stderr ?? '') + e.message;
|
|
99
|
+
if (!/already exists/i.test(detail)) {
|
|
100
|
+
throw new NomadFatal(`gh repo create failed: ${e.message}`);
|
|
101
|
+
}
|
|
102
|
+
log(`repo ${repoName} already exists on your account; reusing it and wiring origin`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Resolve the authenticated user's login for the remote URL.
|
|
106
|
+
let owner: string;
|
|
107
|
+
try {
|
|
108
|
+
owner = run('gh', ['api', 'user', '--jq', '.login'], {
|
|
109
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
110
|
+
timeout: GH_NETWORK_TIMEOUT_MS,
|
|
111
|
+
})
|
|
112
|
+
.toString()
|
|
113
|
+
.trim();
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const e = err as NodeJS.ErrnoException;
|
|
116
|
+
throw new NomadFatal(`gh api user failed: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Guard an empty or null login: `gh api user --jq .login` yields an empty
|
|
120
|
+
// string or the literal "null" when the field is absent. Either would wire a
|
|
121
|
+
// malformed remote (git accepts any URL string) that fails confusingly only
|
|
122
|
+
// at first push, so fail fast here with a clear message instead.
|
|
123
|
+
if (owner.length === 0 || owner === 'null') {
|
|
124
|
+
throw new NomadFatal('gh api user returned an empty login; cannot wire origin remote.');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Wire origin in the local git working tree.
|
|
128
|
+
try {
|
|
129
|
+
run('git', ['remote', 'add', 'origin', `git@github.com:${owner}/${repoName}.git`], {
|
|
130
|
+
cwd: REPO_HOME,
|
|
131
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const e = err as NodeJS.ErrnoException;
|
|
135
|
+
throw new NomadFatal(`git remote add failed: ${e.message}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
log(`created private repo ${owner}/${repoName} and wired origin`);
|
|
139
|
+
}
|
package/src/init.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
readOriginRemote,
|
|
12
12
|
type SpawnSyncFn,
|
|
13
13
|
} from './gh-actions.ts';
|
|
14
|
+
import { DEFAULT_REPO_NAME, ensureOriginRepo } from './init.gh-onboard.ts';
|
|
14
15
|
import { snapshotIntoShared } from './init.snapshot.ts';
|
|
15
16
|
import { die, log } from './utils.ts';
|
|
16
17
|
import { writeJsonAtomic } from './utils.fs.ts';
|
|
@@ -60,6 +61,13 @@ function preflightConflict(repoHome: string): string | null {
|
|
|
60
61
|
* `{"projects":{}}`. No auto-commit; no lock (no concurrent-mutator surface
|
|
61
62
|
* on a fresh target).
|
|
62
63
|
*
|
|
64
|
+
* When no `origin` remote exists in REPO_HOME, a private GitHub repository is
|
|
65
|
+
* created via `gh` and wired as `origin` before scaffolding (D-06/D-07). The
|
|
66
|
+
* repo name defaults to {@link DEFAULT_REPO_NAME} but can be overridden with
|
|
67
|
+
* `opts.repoName`. `gh` is a hard prerequisite on this path and its absence or
|
|
68
|
+
* unauthenticated state results in a NomadFatal (D-08). When `origin` already
|
|
69
|
+
* exists the step is a no-op (D-09 idempotency).
|
|
70
|
+
*
|
|
63
71
|
* When `opts.snapshot` is true, the user's current `~/.claude/` SHARED_LINKS
|
|
64
72
|
* are overlaid onto `shared/` and `~/.claude/settings.json` (if present) is
|
|
65
73
|
* translated into `hosts/<HOST>.json`. The placeholder `shared/CLAUDE.md`
|
|
@@ -70,16 +78,26 @@ function preflightConflict(repoHome: string): string | null {
|
|
|
70
78
|
* partial state is unsafe to merge with.
|
|
71
79
|
*/
|
|
72
80
|
export function cmdInit(
|
|
73
|
-
opts: { snapshot?: boolean; keepActions?: boolean; run?: SpawnSyncFn } = {},
|
|
81
|
+
opts: { snapshot?: boolean; keepActions?: boolean; repoName?: string; run?: SpawnSyncFn } = {},
|
|
74
82
|
): void {
|
|
75
83
|
const snapshot = opts.snapshot === true;
|
|
76
84
|
const keepActions = opts.keepActions === true;
|
|
77
85
|
|
|
86
|
+
// Create REPO_HOME, then refuse to clobber an already-initialized tree BEFORE
|
|
87
|
+
// any onboarding side effects. ensureOriginRepo can create a GitHub repo and
|
|
88
|
+
// wire a remote, so the conflict guard must run first: otherwise a re-init on
|
|
89
|
+
// an already-scaffolded REPO_HOME that lacks an origin would create a stray
|
|
90
|
+
// private repo and wire it, then abort with "already initialized".
|
|
91
|
+
mkdirSync(REPO_HOME, { recursive: true });
|
|
92
|
+
|
|
78
93
|
const conflict = preflightConflict(REPO_HOME);
|
|
79
94
|
if (conflict !== null) {
|
|
80
95
|
die(`already initialized; refusing to clobber ${conflict}`);
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
// Wire the backing GitHub repo. Idempotent when origin already exists (D-09).
|
|
99
|
+
ensureOriginRepo(opts.repoName ?? DEFAULT_REPO_NAME, opts.run);
|
|
100
|
+
|
|
83
101
|
// Create the directory structure first so the subsequent file writes have
|
|
84
102
|
// a parent. `recursive: true` is a no-op when the dir already exists, but
|
|
85
103
|
// the preflight guarantees it does not.
|
package/src/nomad.dispatch.ts
CHANGED
|
@@ -24,6 +24,101 @@ export function parseFlags(argv: string[], known: Set<string>): Set<string> | nu
|
|
|
24
24
|
return seen;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Parsed result from {@link parseInitArgs}. */
|
|
28
|
+
export type InitArgs = {
|
|
29
|
+
/** True when `--snapshot` was present. */
|
|
30
|
+
snapshot: boolean;
|
|
31
|
+
/** True when `--keep-actions` was present. */
|
|
32
|
+
keepActions: boolean;
|
|
33
|
+
/** Optional repo name supplied via `--repo <name>`. */
|
|
34
|
+
repoName: string | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Extract the value following a `--flag <value>` pair. Returns the value
|
|
39
|
+
* string on success, or `null` when the next token is missing or starts with
|
|
40
|
+
* `--` (which would indicate the flag was supplied without a value).
|
|
41
|
+
*/
|
|
42
|
+
function extractFlagValue(argv: string[], i: number): string | null {
|
|
43
|
+
const val = argv[i + 1];
|
|
44
|
+
if (val === undefined || val.startsWith('--')) return null;
|
|
45
|
+
return val;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Internal state threaded through the parseInitArgs loop. */
|
|
49
|
+
type InitParseState = {
|
|
50
|
+
snapshot: boolean;
|
|
51
|
+
keepActions: boolean;
|
|
52
|
+
repoName: string | undefined;
|
|
53
|
+
sawSnapshot: boolean;
|
|
54
|
+
sawKeepActions: boolean;
|
|
55
|
+
sawRepo: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Apply one token from the init argv to the parse state. Returns `true` on
|
|
60
|
+
* success or `false` when the token is invalid (unknown flag, duplicate, or
|
|
61
|
+
* `--repo` with no valid value). Advances `i` by mutation via the returned
|
|
62
|
+
* increment: 1 for boolean flags, 2 for `--repo <value>`.
|
|
63
|
+
*/
|
|
64
|
+
function applyInitToken(
|
|
65
|
+
argv: string[],
|
|
66
|
+
i: number,
|
|
67
|
+
st: InitParseState,
|
|
68
|
+
): { ok: boolean; advance: number } {
|
|
69
|
+
const token = argv[i];
|
|
70
|
+
if (token === '--snapshot') {
|
|
71
|
+
if (st.sawSnapshot) return { ok: false, advance: 0 };
|
|
72
|
+
st.sawSnapshot = true;
|
|
73
|
+
st.snapshot = true;
|
|
74
|
+
return { ok: true, advance: 1 };
|
|
75
|
+
}
|
|
76
|
+
if (token === '--keep-actions') {
|
|
77
|
+
if (st.sawKeepActions) return { ok: false, advance: 0 };
|
|
78
|
+
st.sawKeepActions = true;
|
|
79
|
+
st.keepActions = true;
|
|
80
|
+
return { ok: true, advance: 1 };
|
|
81
|
+
}
|
|
82
|
+
if (token === '--repo') {
|
|
83
|
+
if (st.sawRepo) return { ok: false, advance: 0 };
|
|
84
|
+
st.sawRepo = true;
|
|
85
|
+
const val = extractFlagValue(argv, i);
|
|
86
|
+
if (val === null) return { ok: false, advance: 0 };
|
|
87
|
+
st.repoName = val;
|
|
88
|
+
return { ok: true, advance: 2 };
|
|
89
|
+
}
|
|
90
|
+
return { ok: false, advance: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Argv parser for `nomad init [--snapshot] [--keep-actions] [--repo <name>]`.
|
|
95
|
+
*
|
|
96
|
+
* Handles boolean `--snapshot` and `--keep-actions` flags plus an optional
|
|
97
|
+
* value-bearing `--repo <name>`. Returns `null` on any parse error: unknown
|
|
98
|
+
* flag, duplicate flag, `--repo` with no value, or `--repo` whose value
|
|
99
|
+
* starts with `--`.
|
|
100
|
+
*
|
|
101
|
+
* @param argv The full process argv array (parsing starts at index 3).
|
|
102
|
+
* @returns Parsed init arguments, or `null` on any parse error.
|
|
103
|
+
*/
|
|
104
|
+
export function parseInitArgs(argv: string[]): InitArgs | null {
|
|
105
|
+
const st: InitParseState = {
|
|
106
|
+
snapshot: false,
|
|
107
|
+
keepActions: false,
|
|
108
|
+
repoName: undefined,
|
|
109
|
+
sawSnapshot: false,
|
|
110
|
+
sawKeepActions: false,
|
|
111
|
+
sawRepo: false,
|
|
112
|
+
};
|
|
113
|
+
let i = 3;
|
|
114
|
+
while (i < argv.length) {
|
|
115
|
+
const { ok, advance } = applyInitToken(argv, i, st);
|
|
116
|
+
if (!ok) return null;
|
|
117
|
+
i += advance;
|
|
118
|
+
}
|
|
119
|
+
return { snapshot: st.snapshot, keepActions: st.keepActions, repoName: st.repoName };
|
|
120
|
+
}
|
|
121
|
+
|
|
27
122
|
/** Parsed result from {@link parseRedactArgs}. */
|
|
28
123
|
export type RedactArgs = {
|
|
29
124
|
/** Validated session id. */
|
package/src/nomad.help.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* into the README. Channel is stderr, exit code is 1.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Column (0-indexed) at which every command and flag description starts. Sized
|
|
11
13
|
* to clear the longest label (`--resume-cmd <id>`, which ends at column 24)
|
|
@@ -28,6 +30,8 @@ const row = (label: string, desc: string): string => label.padEnd(DESC_COL) + de
|
|
|
28
30
|
const cont = (text: string): string => ' '.repeat(DESC_COL) + text;
|
|
29
31
|
|
|
30
32
|
export const DEFAULT_HELP = [
|
|
33
|
+
`claude-nomad v${pkg.version}`,
|
|
34
|
+
'',
|
|
31
35
|
'usage: nomad <command> [flags]',
|
|
32
36
|
'',
|
|
33
37
|
'Commands:',
|
|
@@ -43,9 +47,16 @@ export const DEFAULT_HELP = [
|
|
|
43
47
|
row(' diff', 'Offline preview of what `pull` would change against local repo state.'),
|
|
44
48
|
cont('No git pull, no lock acquired.'),
|
|
45
49
|
'',
|
|
46
|
-
row(
|
|
50
|
+
row(
|
|
51
|
+
' init',
|
|
52
|
+
'Create a private GitHub repo via gh (if none exists), scaffold shared/, hosts/, path-map.',
|
|
53
|
+
),
|
|
47
54
|
row(' --snapshot', 'Overlay the current ~/.claude/ into shared/ as the initial seed.'),
|
|
48
55
|
row(' --keep-actions', 'Skip auto-disabling GitHub Actions on the private mirror.'),
|
|
56
|
+
row(
|
|
57
|
+
' --repo <name>',
|
|
58
|
+
'Name for the new GitHub repo (default: claude-nomad-config). No-op when origin exists.',
|
|
59
|
+
),
|
|
49
60
|
'',
|
|
50
61
|
row(' doctor', 'Read-only health check (symlinks, host file, path-map,'),
|
|
51
62
|
cont('gitleaks, gitlinks).'),
|
|
@@ -76,13 +87,7 @@ export const DEFAULT_HELP = [
|
|
|
76
87
|
row(' --rule <id>', 'Limit redaction to one gitleaks rule id.'),
|
|
77
88
|
row(' --dry-run', 'Show what would change without writing.'),
|
|
78
89
|
'',
|
|
79
|
-
row(' update', '
|
|
80
|
-
row(' --dry-run', 'Detect topology + pre-flight, print would-be git commands only.'),
|
|
81
|
-
row(' --force', 'Proceed even when the working tree is not clean.'),
|
|
82
|
-
row(
|
|
83
|
-
' --push-origin',
|
|
84
|
-
'Fork topology only: push the merge to origin/main without prompting.',
|
|
85
|
-
),
|
|
90
|
+
row(' update', 'Update the claude-nomad CLI to the latest npm release.'),
|
|
86
91
|
'',
|
|
87
92
|
row(' --version', 'Print the installed CLI version as bare semver to stdout; exits 0.'),
|
|
88
93
|
'',
|