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.
- package/.gitleaks.toml +16 -0
- package/CHANGELOG.md +293 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/package.json +79 -0
- package/shared/.gitignore +8 -0
- package/src/color.ts +81 -0
- package/src/commands.doctor.checks.ts +343 -0
- package/src/commands.doctor.format.ts +68 -0
- package/src/commands.doctor.ts +56 -0
- package/src/commands.doctor.version.ts +190 -0
- package/src/commands.drop-session.ts +173 -0
- package/src/commands.pull.ts +88 -0
- package/src/commands.push.ts +215 -0
- package/src/commands.update.ts +279 -0
- package/src/config.ts +149 -0
- package/src/diff.ts +49 -0
- package/src/init.snapshot.ts +53 -0
- package/src/init.ts +190 -0
- package/src/links.ts +123 -0
- package/src/nomad.ts +227 -0
- package/src/preview.ts +141 -0
- package/src/push-checks.ts +170 -0
- package/src/push-gitleaks.ts +217 -0
- package/src/remap.ts +158 -0
- package/src/resume.ts +159 -0
- package/src/summary.ts +43 -0
- package/src/update.topology.ts +118 -0
- package/src/utils.ts +368 -0
|
@@ -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 (`[31m✗[39m`) 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
|
+
}
|