claude-nomad 0.17.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.
@@ -0,0 +1,343 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+
5
+ import {
6
+ blue,
7
+ cyan,
8
+ dim,
9
+ failGlyph,
10
+ green,
11
+ infoGlyph,
12
+ okGlyph,
13
+ red,
14
+ warnGlyph,
15
+ yellow,
16
+ } from './color.ts';
17
+ // prettier-ignore
18
+ import { CLAUDE_HOME, HOST, KNOWN_SETTINGS_KEYS, NEVER_SYNC, REPO_HOME, SHARED_LINKS, type PathMap } from './config.ts';
19
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
20
+ import { classifyRepoState, reasonForPartial } from './init.ts';
21
+ import { findGitlinks } from './push-checks.ts';
22
+ import { encodePath, gitStatusPorcelainZ, readJson } from './utils.ts';
23
+
24
+ /**
25
+ * Per-check helpers used by `cmdDoctor`. Each helper appends one or more items
26
+ * to its target `DoctorSection` (via `addItem`) and signals failure by setting
27
+ * `process.exitCode = 1`. Items go to stdout at render time through
28
+ * `renderDoctor` in `commands.doctor.format`; nothing here writes to stderr
29
+ * (read-only doctor contract: FAIL lines stay on stdout so a piped
30
+ * `nomad doctor 2>/dev/null` does not lose them).
31
+ */
32
+
33
+ /**
34
+ * Tolerant JSON reader for `cmdDoctor`. Doctor reads three JSON files
35
+ * (`settings.json`, `settings.base.json`, `path-map.json`); a malformed
36
+ * input must not throw mid-output (user would lose every line below it).
37
+ * Returns `null` on parse failure, records a FAIL item in the supplied
38
+ * section, and sets `process.exitCode = 1` so scripts can gate on the result.
39
+ */
40
+ function readJsonSafe<T>(path: string, label: string, section: DoctorSection): T | null {
41
+ try {
42
+ return readJson<T>(path);
43
+ } catch (err) {
44
+ addItem(section, `${red(failGlyph)} ${label} malformed JSON: ${(err as Error).message}`);
45
+ process.exitCode = 1;
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * True when the `NOMAD_REPO` env override is set to a non-empty value.
52
+ * Mirrors the `||` empty-string-fallthrough semantics of `REPO_HOME` itself
53
+ * (see `src/config.ts`): an unset env, or `export NOMAD_REPO=`, both return
54
+ * false because the default fallback fires. Reads `process.env.NOMAD_REPO`
55
+ * directly so a set-but-empty value is distinguishable from "set to the
56
+ * default path"; reading via the imported `REPO_HOME` constant cannot make
57
+ * that distinction. Exposed for `reportRepoState`; not for general use.
58
+ */
59
+ export function isOverrideActive(): boolean {
60
+ return Boolean(process.env.NOMAD_REPO);
61
+ }
62
+
63
+ /**
64
+ * Pushes the host identity (info) and the two key path lines (repo and
65
+ * claude-home) with gutter glyphs. Path presence is reported via warnGlyph
66
+ * (not failGlyph) so an absent CLAUDE_HOME does not flip sectionFailed to
67
+ * decorate the Host header with `✘`. The authoritative empty-repo FAIL is
68
+ * owned by reportRepoState; these two lines remain informational and do
69
+ * NOT mutate process.exitCode.
70
+ */
71
+ export function reportHostAndPaths(section: DoctorSection): void {
72
+ addItem(section, `${dim(infoGlyph)} host: ${cyan(HOST)}`);
73
+ addItem(
74
+ section,
75
+ `${existsSync(REPO_HOME) ? green(okGlyph) : yellow(warnGlyph)} repo: ${blue(REPO_HOME)}`,
76
+ );
77
+ addItem(
78
+ section,
79
+ `${existsSync(CLAUDE_HOME) ? green(okGlyph) : yellow(warnGlyph)} claude home: ${blue(CLAUDE_HOME)}`,
80
+ );
81
+ }
82
+
83
+ /** Emits the repo-state status line derived from classifyRepoState (okGlyph/warnGlyph/failGlyph). When `NOMAD_REPO` is active, all three branches receive a ` (NOMAD_REPO)` suffix so the env override is visible whatever the repo state. FAIL signals via process.exitCode. */
84
+ export function reportRepoState(section: DoctorSection): void {
85
+ const state = classifyRepoState(REPO_HOME, HOST);
86
+ // Computed once so populated/partial/empty branches share the same
87
+ // annotation. Leading space before `(` keeps the line readable on every
88
+ // branch; empty string produces zero visual change when the override is
89
+ // not in play, matching SPEC §5 (acceptance: unset env -> no annotation).
90
+ const overrideLabel = isOverrideActive() ? ' (NOMAD_REPO)' : '';
91
+ if (state === 'populated') {
92
+ addItem(section, `${green(okGlyph)} repo state: populated${overrideLabel}`);
93
+ } else if (state === 'partial') {
94
+ addItem(
95
+ section,
96
+ `${yellow(warnGlyph)} repo state: partial ${reasonForPartial(REPO_HOME, HOST)}${overrideLabel}`,
97
+ );
98
+ } else {
99
+ addItem(
100
+ section,
101
+ `${red(failGlyph)} repo state: empty - run 'nomad init' to scaffold${overrideLabel}`,
102
+ );
103
+ process.exitCode = 1;
104
+ }
105
+ }
106
+
107
+ /** Emits a per-entry status line for each name in SHARED_LINKS (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs via process.exitCode. */
108
+ export function reportSharedLinks(section: DoctorSection): void {
109
+ for (const name of SHARED_LINKS) {
110
+ const p = join(CLAUDE_HOME, name);
111
+ if (!existsSync(p)) {
112
+ addItem(section, `${yellow(warnGlyph)} ${name}: missing`);
113
+ continue;
114
+ }
115
+ if (lstatSync(p).isSymbolicLink()) {
116
+ addItem(section, `${green(okGlyph)} ${name}: symlink`);
117
+ } else {
118
+ addItem(section, `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`);
119
+ process.exitCode = 1;
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Loads shared/settings.base.json; on missing or malformed, records a FAIL item in the supplied section. Returns the parsed object or null. */
125
+ export function loadBaseSettings(section: DoctorSection): Record<string, unknown> | null {
126
+ const basePath = join(REPO_HOME, 'shared', 'settings.base.json');
127
+ if (!existsSync(basePath)) {
128
+ addItem(section, `${red(failGlyph)} shared/settings.base.json missing at ${blue(basePath)}`);
129
+ process.exitCode = 1;
130
+ return null;
131
+ }
132
+ return readJsonSafe<Record<string, unknown>>(basePath, basePath, section);
133
+ }
134
+
135
+ /** Loads ~/.claude/settings.json when present and emits the schema status (okGlyph for known-keys-only, warnGlyph when unknown keys are present); returns the parsed object or null. */
136
+ export function loadAndReportSettings(section: DoctorSection): Record<string, unknown> | null {
137
+ const settingsPath = join(CLAUDE_HOME, 'settings.json');
138
+ if (!existsSync(settingsPath)) return null;
139
+ const settings = readJsonSafe<Record<string, unknown>>(settingsPath, settingsPath, section);
140
+ if (settings === null) return null;
141
+ const unknownKeys = Object.keys(settings).filter((k) => !KNOWN_SETTINGS_KEYS.has(k));
142
+ if (unknownKeys.length > 0) {
143
+ addItem(
144
+ section,
145
+ `${yellow(warnGlyph)} settings.json has unknown keys (schema drift?): ${unknownKeys.join(', ')}`,
146
+ );
147
+ } else {
148
+ addItem(section, `${green(okGlyph)} settings.json schema: known keys only`);
149
+ }
150
+ return settings;
151
+ }
152
+
153
+ /** Emits the host-override status: okGlyph when no host file is needed (base-only matches settings), failGlyph on drift without a host file (with candidate list), or okGlyph path when the host file parses. */
154
+ export function reportHostOverrides(
155
+ section: DoctorSection,
156
+ base: Record<string, unknown> | null,
157
+ settings: Record<string, unknown> | null,
158
+ ): void {
159
+ const hostFile = join(REPO_HOME, 'hosts', `${HOST}.json`);
160
+ let drift: string[] = [];
161
+ if (base !== null && settings !== null) {
162
+ const baseKeys = new Set(Object.keys(base));
163
+ drift = Object.keys(settings).filter((k) => !baseKeys.has(k));
164
+ }
165
+ if (existsSync(hostFile)) {
166
+ if (readJsonSafe<Record<string, unknown>>(hostFile, hostFile, section) !== null) {
167
+ addItem(section, `${green(okGlyph)} host overrides: ${blue(hostFile)}`);
168
+ }
169
+ } else if (drift.length > 0) {
170
+ addItem(
171
+ section,
172
+ `${red(failGlyph)} no hosts/${HOST}.json AND settings.json has unbased keys ${JSON.stringify(drift)}`,
173
+ );
174
+ const hostsDir = join(REPO_HOME, 'hosts');
175
+ if (existsSync(hostsDir)) {
176
+ const cands = readdirSync(hostsDir).filter((f) => f.endsWith('.json'));
177
+ if (cands.length > 0) addItem(section, `${dim(infoGlyph)} candidates: ${cands.join(', ')}`);
178
+ }
179
+ process.exitCode = 1;
180
+ } else {
181
+ addItem(
182
+ section,
183
+ `${green(okGlyph)} host overrides: none (base-only is fine, no settings drift)`,
184
+ );
185
+ }
186
+ }
187
+
188
+ /** Emits the mapped-projects header for the current host and one line per mapped project. */
189
+ function reportMappedProjects(section: DoctorSection, map: PathMap): void {
190
+ const mapped = Object.entries(map.projects).filter(([, hosts]) => hosts[HOST]);
191
+ addItem(
192
+ section,
193
+ `${dim(infoGlyph)} mapped projects for ${cyan(HOST)}: ${dim(String(mapped.length))}`,
194
+ );
195
+ for (const [name, hosts] of mapped) {
196
+ addItem(section, `${dim(infoGlyph)} ${name} -> ${blue(hosts[HOST])}`);
197
+ }
198
+ }
199
+
200
+ /** Scans every host of every project for encodePath collisions; emits failGlyph per collision (sets exitCode=1), okGlyph when clean. */
201
+ function reportPathCollisions(section: DoctorSection, map: PathMap): void {
202
+ const seen = new Map<string, string>();
203
+ let collisionCount = 0;
204
+ for (const hosts of Object.values(map.projects)) {
205
+ for (const abspath of Object.values(hosts)) {
206
+ if (!abspath || abspath === 'TBD') continue;
207
+ const encoded = encodePath(abspath);
208
+ const prior = seen.get(encoded);
209
+ if (prior !== undefined && prior !== abspath) {
210
+ addItem(
211
+ section,
212
+ `${red(failGlyph)} path-encoding collision: ${prior} and ${abspath} both encode to ${encoded}`,
213
+ );
214
+ collisionCount++;
215
+ } else {
216
+ seen.set(encoded, abspath);
217
+ }
218
+ }
219
+ }
220
+ if (collisionCount > 0) process.exitCode = 1;
221
+ else addItem(section, `${green(okGlyph)} path-encoding: no collisions`);
222
+ }
223
+
224
+ /** Pushes mapped projects for the current host and FAILs on path-encoding collisions across hosts; FAILs when path-map.json is missing. */
225
+ export function reportPathMap(section: DoctorSection): void {
226
+ const mapPath = join(REPO_HOME, 'path-map.json');
227
+ if (!existsSync(mapPath)) {
228
+ addItem(section, `${red(failGlyph)} path-map.json missing at ${blue(mapPath)}`);
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ const map = readJsonSafe<PathMap>(mapPath, mapPath, section);
233
+ if (map === null) return;
234
+ // Guard non-object `projects` and per-project non-object `hosts` so the
235
+ // helpers' `hosts[HOST]` / `Object.values(hosts)` cannot throw mid-output
236
+ // and break the tolerant-doctor contract.
237
+ const projects: unknown = (map as { projects?: unknown }).projects;
238
+ if (projects === null || typeof projects !== 'object' || Array.isArray(projects)) {
239
+ addItem(
240
+ section,
241
+ `${red(failGlyph)} path-map.json invalid schema: "projects" must be an object`,
242
+ );
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+ for (const [name, hosts] of Object.entries(projects as Record<string, unknown>)) {
247
+ if (hosts === null || typeof hosts !== 'object' || Array.isArray(hosts)) {
248
+ addItem(
249
+ section,
250
+ `${red(failGlyph)} path-map.json invalid schema: project "${name}" hosts must be an object`,
251
+ );
252
+ process.exitCode = 1;
253
+ return;
254
+ }
255
+ for (const [hostName, mappedPath] of Object.entries(hosts as Record<string, unknown>)) {
256
+ if (typeof mappedPath !== 'string') {
257
+ addItem(
258
+ section,
259
+ `${red(failGlyph)} path-map.json invalid schema: project "${name}" host "${hostName}" path must be a string`,
260
+ );
261
+ process.exitCode = 1;
262
+ return;
263
+ }
264
+ }
265
+ }
266
+ reportMappedProjects(section, map);
267
+ reportPathCollisions(section, map);
268
+ }
269
+
270
+ /** Pushes the comma-joined NEVER_SYNC set for informational visibility. */
271
+ export function reportNeverSync(section: DoctorSection): void {
272
+ addItem(section, `${dim(infoGlyph)} never-sync items: ${[...NEVER_SYNC].join(', ')}`);
273
+ }
274
+
275
+ /** Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with ENOENT vs other-error distinction (sets exitCode=1). */
276
+ export function reportGitleaksProbe(section: DoctorSection): void {
277
+ try {
278
+ const v = execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] })
279
+ .toString()
280
+ .trim();
281
+ addItem(section, `${green(okGlyph)} gitleaks: ${dim(v)}`);
282
+ } catch (err) {
283
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
284
+ addItem(section, `${red(failGlyph)} gitleaks: not on PATH (required for nomad push)`);
285
+ } else {
286
+ addItem(section, `${red(failGlyph)} gitleaks: probe failed: ${(err as Error).message}`);
287
+ }
288
+ process.exitCode = 1;
289
+ }
290
+ }
291
+
292
+ /** Walks shared/ for nested .git gitlinks; emits failGlyph per gitlink found (sets exitCode=1), okGlyph when none. */
293
+ export function reportGitlinks(section: DoctorSection): void {
294
+ const sharedDir = join(REPO_HOME, 'shared');
295
+ if (existsSync(sharedDir)) {
296
+ const gitlinks = findGitlinks(sharedDir);
297
+ for (const p of gitlinks) {
298
+ const rel = relative(REPO_HOME, p);
299
+ addItem(
300
+ section,
301
+ `${red(failGlyph)} gitlink: ${blue(rel)} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`,
302
+ );
303
+ }
304
+ if (gitlinks.length > 0) {
305
+ process.exitCode = 1;
306
+ } else {
307
+ addItem(section, `${green(okGlyph)} gitlink scan: no nested .git in shared/`);
308
+ }
309
+ }
310
+ }
311
+
312
+ /** Pushes the `git remote get-url origin` line or a `not configured` informational line. */
313
+ export function reportRemote(section: DoctorSection): void {
314
+ try {
315
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
316
+ cwd: REPO_HOME,
317
+ stdio: ['ignore', 'pipe', 'pipe'],
318
+ })
319
+ .toString()
320
+ .trim();
321
+ addItem(section, `${dim(infoGlyph)} remote origin: ${cyan(url)}`);
322
+ } catch {
323
+ addItem(section, `${dim(infoGlyph)} remote origin: not configured`);
324
+ }
325
+ }
326
+
327
+ /** WARNs when ~/claude-nomad/ has uncommitted changes (autostash territory for push). */
328
+ export function reportRebaseClean(section: DoctorSection): void {
329
+ try {
330
+ const status = gitStatusPorcelainZ(REPO_HOME);
331
+ if (status.length > 0) {
332
+ addItem(
333
+ section,
334
+ `${yellow(warnGlyph)} ${blue('~/claude-nomad/')} has uncommitted changes (nomad push will --autostash these)`,
335
+ );
336
+ }
337
+ } catch {
338
+ // gitStatusPorcelainZ failure on a missing or non-repo REPO_HOME is
339
+ // already surfaced by reportHostAndPaths (warnGlyph on the `repo:` line
340
+ // when the directory is absent) and reportRepoState ('empty' FAIL when
341
+ // the scaffold is absent). Swallowing here avoids double-reporting.
342
+ }
343
+ }
@@ -0,0 +1,68 @@
1
+ import { failGlyph, red } from './color.ts';
2
+
3
+ /**
4
+ * Bare `failGlyph` codepoint (`✗`, U+2717) without any WSL padding the
5
+ * `failGlyph` constant may carry. Header rendering composes its own
6
+ * spacing (`${red(failGlyph)} ${header}`), so the section-header path
7
+ * must use the unpadded codepoint to avoid a double space on WSL.
8
+ */
9
+ const FAIL_GLYPH_BARE = '✗';
10
+
11
+ /**
12
+ * Tree-style output builder for `cmdDoctor`. Doctor builds an ordered list of
13
+ * `DoctorSection`s, each reporter pushes plain-text items into the relevant
14
+ * section, then the orchestrator calls `renderDoctor` to emit a Claude Code
15
+ * `/doctor`-style tree (`Header` / ` ├ item` / ` └ last`) on stdout.
16
+ *
17
+ * Color and status glyphs (okGlyph/warnGlyph/failGlyph/infoGlyph) already
18
+ * live inside the item text; this module never re-colors or re-tokenizes.
19
+ * Sections with zero items are skipped at render time (no empty headers).
20
+ *
21
+ * Output goes directly through `console.log` rather than `utils.log` so the
22
+ * dim `ℹ︎` info glyph used by `pull` / `push` / `init` does NOT appear in
23
+ * doctor output (doctor has its own glyphs per row). Test assertions continue
24
+ * to spy on `console.log`.
25
+ */
26
+ export type DoctorSection = {
27
+ header: string;
28
+ items: string[];
29
+ };
30
+
31
+ /** Construct an empty section with the given header. */
32
+ export function section(header: string): DoctorSection {
33
+ return { header, items: [] };
34
+ }
35
+
36
+ /** Append one rendered line to a section. */
37
+ export function addItem(s: DoctorSection, text: string): void {
38
+ s.items.push(text);
39
+ }
40
+
41
+ /**
42
+ * True when any item in the section contains the FAIL glyph.
43
+ * Color-wrapped failGlyph (`✗`) still contains the
44
+ * glyph as a substring, so this works for both color-on and color-off output.
45
+ */
46
+ function sectionFailed(s: DoctorSection): boolean {
47
+ return s.items.some((line) => line.includes(failGlyph));
48
+ }
49
+
50
+ /**
51
+ * Emit the full doctor report. Skips empty sections, prefixes failed-section
52
+ * headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
53
+ * `grep -F '✗'` catches both row and header failures), and writes one blank
54
+ * line between rendered sections (no leading or trailing blank).
55
+ */
56
+ export function renderDoctor(sections: DoctorSection[]): void {
57
+ const visible = sections.filter((s) => s.items.length > 0);
58
+ for (let i = 0; i < visible.length; i++) {
59
+ if (i > 0) console.log('');
60
+ const s = visible[i];
61
+ const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
62
+ console.log(header);
63
+ for (let j = 0; j < s.items.length; j++) {
64
+ const isLast = j === s.items.length - 1;
65
+ console.log(`${isLast ? ' └ ' : ' ├ '}${s.items[j]}`);
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,56 @@
1
+ import {
2
+ loadAndReportSettings,
3
+ loadBaseSettings,
4
+ reportGitleaksProbe,
5
+ reportGitlinks,
6
+ reportHostAndPaths,
7
+ reportHostOverrides,
8
+ reportNeverSync,
9
+ reportPathMap,
10
+ reportRebaseClean,
11
+ reportRemote,
12
+ reportRepoState,
13
+ reportSharedLinks,
14
+ } from './commands.doctor.checks.ts';
15
+ import { renderDoctor, section } from './commands.doctor.format.ts';
16
+ import { reportVersionCheck } from './commands.doctor.version.ts';
17
+
18
+ /**
19
+ * Read-only health check for the nomad install on the current host. Each
20
+ * reporter pushes items into a named section; `renderDoctor` emits the final
21
+ * Claude Code `/doctor`-style tree on stdout via `console.log` (no `ℹ︎`
22
+ * prefix). FAILs in any section bubble up via `process.exitCode = 1` set
23
+ * inside the individual reporters, so a piped
24
+ * `nomad doctor 2>/dev/null` still exposes failures to scripts. Differs from
25
+ * `cmdPull` / `cmdPush` / `resumeCmd`, where FATAL is on stderr.
26
+ */
27
+ export function cmdDoctor(): void {
28
+ const host = section('Host');
29
+ reportHostAndPaths(host);
30
+ reportRepoState(host);
31
+
32
+ const links = section('Shared links');
33
+ reportSharedLinks(links);
34
+
35
+ const settings = section('Settings');
36
+ const base = loadBaseSettings(settings);
37
+ const parsedSettings = loadAndReportSettings(settings);
38
+ reportHostOverrides(settings, base, parsedSettings);
39
+
40
+ const pathMap = section('Path map');
41
+ reportPathMap(pathMap);
42
+
43
+ const neverSync = section('Never-sync');
44
+ reportNeverSync(neverSync);
45
+
46
+ const repository = section('Repository');
47
+ reportGitleaksProbe(repository);
48
+ reportGitlinks(repository);
49
+ reportRemote(repository);
50
+ reportRebaseClean(repository);
51
+
52
+ const version = section('Version');
53
+ reportVersionCheck(version);
54
+
55
+ renderDoctor([version, host, links, settings, pathMap, neverSync, repository]);
56
+ }
@@ -0,0 +1,190 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { dim, green, infoGlyph, okGlyph, warnGlyph, yellow } from './color.ts';
7
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
8
+ import { HOME, UPSTREAM_REPO_SLUG } from './config.ts';
9
+
10
+ /**
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:
14
+ * - `✓ version: <local> (latest)` when local == latest
15
+ * - `⚠︎ version: <local> -> <latest>` when local < latest
16
+ * - `ℹ︎ version: <local> (ahead of latest release <latest>)` when local > latest
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
19
+ * never sets `process.exitCode` and never writes to stderr.
20
+ */
21
+
22
+ /** Absolute path to the cached latest-tag entry. Sits under HOME so tests that
23
+ * override `process.env.HOME` get a sandboxed cache for free. */
24
+ const CACHE_PATH = join(HOME, '.cache', 'claude-nomad', 'version-check.json');
25
+
26
+ /** Cache TTL in milliseconds. Matches GitHub's 1-hour anonymous rate-limit
27
+ * reset window: long enough to collapse `nomad doctor` debugging bursts into a
28
+ * single fetch, short enough that new releases surface within the same day. */
29
+ const CACHE_TTL_MS = 60 * 60 * 1000;
30
+
31
+ /** Strict-semver regex used to gate both the local version and the latest tag
32
+ * fed into `compareSemver`. Pre-release suffixes like `-dev` are rejected at
33
+ * the regex; downstream callers strip them off before comparing. */
34
+ const STRICT_SEMVER = /^\d+\.\d+\.\d+$/;
35
+
36
+ /** Capturing variant of `STRICT_SEMVER` used to peel a strict-semver prefix
37
+ * off a pre-release version string (e.g. `0.12.0` out of `0.12.0-dev`). The
38
+ * trailing `(?:[-+]|$)` anchor rejects malformed inputs like `1.2.3foo` or
39
+ * `1.2.3.4` that would otherwise be silently truncated to `1.2.3` and yield
40
+ * a false PASS against an identical `latest`. */
41
+ const STRICT_SEMVER_PREFIX = /^(\d+\.\d+\.\d+)(?:[-+]|$)/;
42
+
43
+ /** Shape of the on-disk cache entry. Both fields are validated structurally
44
+ * in `loadCache` before use (typeof + finiteness + regex). */
45
+ type CacheEntry = { checked_at: number; latest: string };
46
+
47
+ /**
48
+ * Strict triple-segment semver comparison: returns -1 when `a < b`, 0 when
49
+ * equal, 1 when `a > b`. BOTH inputs must match `/^\d+\.\d+\.\d+$/`; any
50
+ * non-strict input causes a 0 return, which the caller treats as "skip the
51
+ * diagnostic" (silent-skip on undecidable comparisons is intentional, mirrors
52
+ * the rest of the version-check codepath that errs on the side of saying
53
+ * nothing). Pure, no side effects.
54
+ */
55
+ export function compareSemver(a: string, b: string): -1 | 0 | 1 {
56
+ if (!STRICT_SEMVER.test(a) || !STRICT_SEMVER.test(b)) return 0;
57
+ const [aMajor, aMinor, aPatch] = a.split('.').map((x) => Number.parseInt(x, 10));
58
+ const [bMajor, bMinor, bPatch] = b.split('.').map((x) => Number.parseInt(x, 10));
59
+ if (aMajor !== bMajor) return aMajor < bMajor ? -1 : 1;
60
+ if (aMinor !== bMinor) return aMinor < bMinor ? -1 : 1;
61
+ if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1;
62
+ return 0;
63
+ }
64
+
65
+ /**
66
+ * Locate and parse the local `package.json` (one directory above this source
67
+ * module). Returns the `version` string when present and non-empty, otherwise
68
+ * `null`. Any throw (missing file, parse error, etc.) becomes a `null` return
69
+ * so the caller silently skips the diagnostic.
70
+ */
71
+ function readLocalVersion(): string | null {
72
+ try {
73
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
74
+ const parsed = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: unknown };
75
+ if (typeof parsed.version === 'string' && parsed.version.length > 0) {
76
+ return parsed.version;
77
+ }
78
+ return null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Load the cached latest-tag entry. Returns the parsed entry when the file
86
+ * exists, parses cleanly, and matches the expected shape (`checked_at` finite
87
+ * number, `latest` strict-semver string); any failure surfaces as `null` so
88
+ * the caller falls through to `fetchLatestTag`. Treating malformed cache as a
89
+ * miss is the safer default than crashing or surfacing the error.
90
+ */
91
+ function loadCache(): CacheEntry | null {
92
+ try {
93
+ if (!existsSync(CACHE_PATH)) return null;
94
+ const parsed = JSON.parse(readFileSync(CACHE_PATH, 'utf8')) as Partial<CacheEntry>;
95
+ if (typeof parsed.checked_at !== 'number' || !Number.isFinite(parsed.checked_at)) {
96
+ return null;
97
+ }
98
+ if (typeof parsed.latest !== 'string' || !STRICT_SEMVER.test(parsed.latest)) {
99
+ return null;
100
+ }
101
+ return { checked_at: parsed.checked_at, latest: parsed.latest };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Persist the latest tag plus a `Date.now()` stamp to the cache file. Errors
109
+ * (read-only filesystem, missing parent dir despite `mkdirSync`, etc.) are
110
+ * swallowed so a cache-write failure never breaks `nomad doctor` output.
111
+ */
112
+ function saveCache(latest: string): void {
113
+ try {
114
+ mkdirSync(dirname(CACHE_PATH), { recursive: true });
115
+ writeFileSync(CACHE_PATH, JSON.stringify({ checked_at: Date.now(), latest }));
116
+ } catch {
117
+ // Silent on cache-write failure (locked design): the user-facing
118
+ // diagnostic still emits; the cost is one extra network round-trip on
119
+ // the next invocation.
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Fetch the latest release tag from the upstream GitHub releases API. Uses
125
+ * `execFileSync('curl', ...)` rather than `node:https` because curl honors
126
+ * system proxies, respects the `-m` timeout reliably, and is already a
127
+ * required dependency on every supported host (push uses gitleaks; pull uses
128
+ * git). 3-second timeout, fail-fast on non-2xx (`-f`), silent (`-s`), follow
129
+ * redirects (`-L`). Returns `null` on ANY failure path including a missing
130
+ * `tag_name` field or a tag that fails strict-semver validation after the
131
+ * leading `v` strip. Release tags ship as `v<semver>` per
132
+ * `release-please-config.json`'s `include-v-in-tag: true`.
133
+ */
134
+ function fetchLatestTag(): string | null {
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;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Emit a single, non-fatal version diagnostic for `nomad doctor` by comparing the local package.json version to the latest upstream release.
154
+ *
155
+ * Logs one of:
156
+ * - `✓ version: <local> (latest)` when the versions match
157
+ * - `⚠︎ version: <local> -> <latest> (run \`nomad update\`)` when the local version is behind
158
+ * - `ℹ︎ version: <local> (ahead of latest release <latest>)` when the local version is ahead
159
+ *
160
+ * Any failure to read the local version, retrieve or parse the latest release, or use the cache results in no output and does not change `process.exitCode`.
161
+ */
162
+ export function reportVersionCheck(section: DoctorSection): void {
163
+ const local = readLocalVersion();
164
+ if (local === null) return;
165
+ // Strip pre-release suffix for the COMPARISON. The display value keeps the
166
+ // full string so e.g. `0.12.0-dev (ahead of latest release 0.11.2)` is
167
+ // readable.
168
+ const localPure = STRICT_SEMVER_PREFIX.exec(local)?.[1] ?? null;
169
+ if (localPure === null) return;
170
+
171
+ let latest: string | null = null;
172
+ const cached = loadCache();
173
+ if (cached !== null && Date.now() - cached.checked_at < CACHE_TTL_MS) {
174
+ latest = cached.latest;
175
+ }
176
+ if (latest === null) {
177
+ latest = fetchLatestTag();
178
+ if (latest === null) return;
179
+ saveCache(latest);
180
+ }
181
+
182
+ const cmp = compareSemver(localPure, latest);
183
+ if (cmp === 0) {
184
+ addItem(section, `${green(okGlyph)} version: ${local} (latest)`);
185
+ } else if (cmp === -1) {
186
+ addItem(section, `${yellow(warnGlyph)} version: ${local} -> ${latest} (run \`nomad update\`)`);
187
+ } else {
188
+ addItem(section, `${dim(infoGlyph)} version: ${local} (ahead of latest release ${latest})`);
189
+ }
190
+ }