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.
@@ -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, UPSTREAM_REPO_SLUG } from './config.ts';
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 release tag on
13
- * the upstream GitHub repo (cached 1h, 3s curl timeout), and emits one of:
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
- * `tag_name`, missing/unreadable package.json) is a SILENT skip; this module
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 `fetchLatestTag`. Treating malformed cache as a
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 release tag from the upstream GitHub releases API. Uses
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 `tag_name` field, or a tag that fails
131
- * strict-semver validation after the leading `v` strip). Release tags ship as
132
- * `v<semver>` per `release-please-config.json`'s `include-v-in-tag: true`.
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 fetchLatestTag(): string | null {
134
+ function fetchLatestVersion(): string | null {
135
135
  try {
136
- const url = `https://api.github.com/repos/${UPSTREAM_REPO_SLUG}/releases/latest`;
137
- const raw = execFileSync(
138
- 'curl',
139
- ['-fsSL', '-m', '3', '-H', 'Accept: application/vnd.github+json', url],
140
- { stdio: ['ignore', 'pipe', 'pipe'] },
141
- ).toString();
142
- const parsed = JSON.parse(raw) as { tag_name?: unknown };
143
- if (typeof parsed.tag_name !== 'string') return null;
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 = fetchLatestTag();
174
+ latest = fetchLatestVersion();
178
175
  if (latest === null) return;
179
176
  saveCache(latest);
180
177
  }
@@ -1,191 +1,30 @@
1
- import { existsSync } from 'node:fs';
1
+ import { execFileSync } from 'node:child_process';
2
2
 
3
- import { cmdDoctor } from './commands.doctor.ts';
4
- import { currentBranch, headSha, reinstallIfNeeded } from './commands.update.git.ts';
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
- * Caller-supplied options for `cmdUpdate`. All flags optional; defaults are
13
- * conservative (no dirty-tree override, prompt for fork push, mutate state).
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
- * The prompt step is gated by `pushOrigin` (no prompt when explicit) and by
56
- * `dryRun` (no prompt, no push when previewing). When `opts.dryRun === true`
57
- * the function only logs the git actions it would perform and returns
58
- * without running any commands. When `opts.pushOrigin === true` the function
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
- * When the merge (and any extras precommit) leaves `HEAD` unchanged from
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 opts - Update options; respected fields are:
71
- * - `dryRun`: when true, log actions instead of executing them
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 runFork(opts: CmdUpdateOpts, beforeSha?: string): boolean {
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
- gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
22
+ run('npm', ['update', '-g', 'claude-nomad'], { stdio: 'inherit' });
98
23
  } catch (err) {
99
- if (!tryAutoResolveMergeConflict(opts)) throw err;
100
- autoResolved = true;
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
- warn('working tree is not clean, proceeding because --force was passed');
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.
@@ -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(' init', 'Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).'),
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', 'Topology-aware upgrade of ~/claude-nomad/ to the latest upstream.'),
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
  '',