claude-nomad 0.25.4 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +314 -102
- package/package.json +9 -2
- package/src/commands.doctor.check-shared.scan.ts +29 -5
- package/src/commands.doctor.checks.pathmap.ts +2 -2
- package/src/commands.doctor.checks.repo.ts +85 -40
- package/src/commands.doctor.format.ts +22 -7
- package/src/commands.doctor.mirror-actions.ts +8 -3
- package/src/commands.doctor.ts +1 -1
- package/src/commands.doctor.version.ts +15 -9
- package/src/gh-actions.ts +19 -7
- package/src/init.ts +5 -0
- package/src/nomad.help.ts +51 -21
- package/src/nomad.ts +2 -6
- package/src/preview.ts +42 -42
- package/src/push-gitleaks.scan.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-nomad",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
|
|
6
6
|
"keywords": [
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit",
|
|
43
43
|
"lint": "eslint .",
|
|
44
44
|
"lint:fix": "eslint . --fix",
|
|
45
|
+
"lint:md": "markdownlint-cli2",
|
|
45
46
|
"format": "prettier --write .",
|
|
46
47
|
"format:check": "prettier --check .",
|
|
47
48
|
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && node scripts/verify-tarball.cjs",
|
|
@@ -52,7 +53,11 @@
|
|
|
52
53
|
"eslint --fix",
|
|
53
54
|
"prettier --write"
|
|
54
55
|
],
|
|
55
|
-
"*.{js,mjs,cjs,json
|
|
56
|
+
"*.{js,mjs,cjs,json}": [
|
|
57
|
+
"prettier --write"
|
|
58
|
+
],
|
|
59
|
+
"*.md": [
|
|
60
|
+
"markdownlint-cli2 --fix",
|
|
56
61
|
"prettier --write"
|
|
57
62
|
]
|
|
58
63
|
},
|
|
@@ -64,9 +69,11 @@
|
|
|
64
69
|
"@vitest/coverage-v8": "^4.1.6",
|
|
65
70
|
"eslint": "^10.4.0",
|
|
66
71
|
"eslint-config-prettier": "^10.1.8",
|
|
72
|
+
"eslint-plugin-sonarjs": "^4.0.3",
|
|
67
73
|
"globals": "^17.6.0",
|
|
68
74
|
"husky": "^9.1.7",
|
|
69
75
|
"lint-staged": "^17.0.5",
|
|
76
|
+
"markdownlint-cli2": "^0.22.1",
|
|
70
77
|
"prettier": "^3.8.3",
|
|
71
78
|
"typescript": "^6.0.3",
|
|
72
79
|
"typescript-eslint": "^8.59.4",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
-
import { green, red, okGlyph, failGlyph } from './color.ts';
|
|
17
|
+
import { green, red, dim, okGlyph, failGlyph } from './color.ts';
|
|
18
18
|
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
19
19
|
import { CLAUDE_HOME } from './config.ts';
|
|
20
20
|
import { type Finding, partitionFindings, scanStagedTree } from './push-gitleaks.ts';
|
|
@@ -47,16 +47,16 @@ function reportSessionFindings(
|
|
|
47
47
|
): void {
|
|
48
48
|
for (const [sid, counts] of bySession) {
|
|
49
49
|
const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
|
|
50
|
-
addItem(section, `${red(failGlyph)}
|
|
50
|
+
addItem(section, `${red(failGlyph)} ${red(summary)} in session ${sid}`);
|
|
51
51
|
const logical = logicalBySession.get(sid);
|
|
52
52
|
/* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
|
|
53
53
|
if (logical !== undefined) {
|
|
54
54
|
addItem(
|
|
55
55
|
section,
|
|
56
|
-
` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
|
|
56
|
+
` ${dim(`rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`)}`,
|
|
57
57
|
);
|
|
58
58
|
}
|
|
59
|
-
addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
|
|
59
|
+
addItem(section, ` ${dim('false positive? add a pattern to .gitleaks.toml')}`);
|
|
60
60
|
}
|
|
61
61
|
process.exitCode = 1;
|
|
62
62
|
}
|
|
@@ -70,7 +70,7 @@ function reportSessionFindings(
|
|
|
70
70
|
*/
|
|
71
71
|
function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
|
|
72
72
|
for (const f of other) {
|
|
73
|
-
addItem(section, `${red(failGlyph)}
|
|
73
|
+
addItem(section, `${red(failGlyph)} ${red(f.RuleID)} leak in ${f.File}`);
|
|
74
74
|
}
|
|
75
75
|
process.exitCode = 1;
|
|
76
76
|
}
|
|
@@ -111,6 +111,29 @@ function buildLogicalBySession(findings: Finding[]): Map<string, string> {
|
|
|
111
111
|
return logicalBySession;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Emit a deduplicated description legend in the footer: one `[rule-id]:
|
|
116
|
+
* description` row per distinct RuleID across all findings, set off by a blank
|
|
117
|
+
* line before and after. Sourced from the `Description` gitleaks bakes into
|
|
118
|
+
* each finding, so it needs no network; rules whose description is absent
|
|
119
|
+
* (older gitleaks, custom rules) are skipped, and the whole block (including
|
|
120
|
+
* the surrounding blanks) is omitted when no descriptions are available. The
|
|
121
|
+
* legend lives in the footer so a rule hit across many files or sessions
|
|
122
|
+
* (e.g. `sonar-api-token`) is explained once, not per occurrence.
|
|
123
|
+
*/
|
|
124
|
+
function emitDescriptionLegend(section: DoctorSection, findings: Finding[]): void {
|
|
125
|
+
const descByRule = new Map<string, string>();
|
|
126
|
+
for (const f of findings) {
|
|
127
|
+
if (f.Description && !descByRule.has(f.RuleID)) descByRule.set(f.RuleID, f.Description);
|
|
128
|
+
}
|
|
129
|
+
if (descByRule.size === 0) return;
|
|
130
|
+
addItem(section, '');
|
|
131
|
+
for (const [rule, desc] of descByRule) {
|
|
132
|
+
addItem(section, ` ${red(`[${rule}]`)}: ${dim(desc)}`);
|
|
133
|
+
}
|
|
134
|
+
addItem(section, '');
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
/**
|
|
115
138
|
* Scan the staged temp tree through the shared `scanStagedTree` and emit the
|
|
116
139
|
* result rows. Isolates the deepest nesting from `reportCheckShared`: the scan
|
|
@@ -155,4 +178,5 @@ export function scanAndReport(
|
|
|
155
178
|
if (bySession.size > 0) {
|
|
156
179
|
reportSessionFindings(section, bySession, buildLogicalBySession(findings), logicalToEncoded);
|
|
157
180
|
}
|
|
181
|
+
emitDescriptionLegend(section, findings);
|
|
158
182
|
}
|
|
@@ -13,7 +13,7 @@ import { encodePath } from './utils.json.ts';
|
|
|
13
13
|
* `process.exitCode = 1`. Read-only: FAIL lines stay on stdout.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
/** Emits the mapped-projects header for the current host and one line per mapped project. */
|
|
16
|
+
/** Emits the mapped-projects header for the current host and one indented child line per mapped project. */
|
|
17
17
|
function reportMappedProjects(section: DoctorSection, map: PathMap): void {
|
|
18
18
|
const mapped = Object.entries(map.projects).filter(([, hosts]) => hosts[HOST]);
|
|
19
19
|
addItem(
|
|
@@ -21,7 +21,7 @@ function reportMappedProjects(section: DoctorSection, map: PathMap): void {
|
|
|
21
21
|
`${dim(infoGlyph)} mapped projects for ${cyan(HOST)}: ${dim(String(mapped.length))}`,
|
|
22
22
|
);
|
|
23
23
|
for (const [name, hosts] of mapped) {
|
|
24
|
-
addItem(section,
|
|
24
|
+
addItem(section, ` ${name} -> ${blue(hosts[HOST])}`);
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -43,7 +43,7 @@ export function isOverrideActive(): boolean {
|
|
|
43
43
|
* Pushes the host identity (info) and the two key path lines (repo and
|
|
44
44
|
* claude-home) with gutter glyphs. Path presence is reported via warnGlyph
|
|
45
45
|
* (not failGlyph) so an absent CLAUDE_HOME does not flip sectionFailed to
|
|
46
|
-
* decorate the Host header with
|
|
46
|
+
* decorate the Host header with a fail glyph. The authoritative empty-repo FAIL is
|
|
47
47
|
* owned by reportRepoState; these two lines remain informational and do
|
|
48
48
|
* NOT mutate process.exitCode.
|
|
49
49
|
*/
|
|
@@ -83,51 +83,96 @@ export function reportRepoState(section: DoctorSection): void {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* True when the repo has a `shared/<name>` source for this link. `applySharedLinks`
|
|
88
|
+
* only creates a symlink when this source exists, so when it does NOT, an absent
|
|
89
|
+
* or dangling link in `~/.claude/` is expected (nothing to sync), not a problem to
|
|
90
|
+
* fix. Doctor uses this to downgrade those rows from a warn to an info note.
|
|
91
|
+
*/
|
|
92
|
+
function repoHasSharedSource(name: string): boolean {
|
|
93
|
+
return existsSync(join(REPO_HOME, 'shared', name));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the display item and optional exit-code side-effect for a single
|
|
98
|
+
* shared-link path. Returns `{ line, fail }` where `fail` true means the
|
|
99
|
+
* caller should set `process.exitCode = 1`.
|
|
100
|
+
*
|
|
101
|
+
* Extracted from `reportSharedLinks` to reduce cognitive complexity: the lstat
|
|
102
|
+
* try/catch and the inner symlink-target try/catch each count against the
|
|
103
|
+
* parent function's score.
|
|
104
|
+
*/
|
|
105
|
+
function classifySharedLink(name: string, p: string): { line: string; fail: boolean } {
|
|
106
|
+
let stat;
|
|
107
|
+
try {
|
|
108
|
+
stat = lstatSync(p);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
111
|
+
if (code === 'ENOENT') {
|
|
112
|
+
return repoHasSharedSource(name)
|
|
113
|
+
? {
|
|
114
|
+
line: `${yellow(warnGlyph)} ${name}: missing (run \`nomad pull\` to restore)`,
|
|
115
|
+
fail: false,
|
|
116
|
+
}
|
|
117
|
+
: { line: `${dim(infoGlyph)} ${name}: not synced (nothing in shared/)`, fail: false };
|
|
118
|
+
}
|
|
119
|
+
return { line: `${red(failGlyph)} ${name}: could not stat (${String(code)})`, fail: true };
|
|
120
|
+
}
|
|
121
|
+
if (!stat.isSymbolicLink()) {
|
|
122
|
+
return { line: `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`, fail: true };
|
|
123
|
+
}
|
|
124
|
+
return classifySymlinkTarget(name, p);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the display item for a path already confirmed to be a symlink.
|
|
129
|
+
* Follows the link via statSync; a throw means the target is missing or
|
|
130
|
+
* unreadable. Never FAILs (`fail: false`): a dangling link whose source still
|
|
131
|
+
* lives in the repo is a WARN with a `nomad pull` hint, a dangling link whose
|
|
132
|
+
* source is gone from the repo is an info note (stale, safe to remove), and a
|
|
133
|
+
* non-ENOENT stat error is a WARN naming the code.
|
|
134
|
+
*/
|
|
135
|
+
function classifySymlinkTarget(name: string, p: string): { line: string; fail: boolean } {
|
|
136
|
+
try {
|
|
137
|
+
statSync(p);
|
|
138
|
+
return { line: `${green(okGlyph)} ${name}: symlink`, fail: false };
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
141
|
+
if (code === 'ENOENT') {
|
|
142
|
+
return repoHasSharedSource(name)
|
|
143
|
+
? {
|
|
144
|
+
line: `${yellow(warnGlyph)} ${name}: broken symlink (target missing, run \`nomad pull\`)`,
|
|
145
|
+
fail: false,
|
|
146
|
+
}
|
|
147
|
+
: {
|
|
148
|
+
line: `${dim(infoGlyph)} ${name}: stale symlink (no longer in shared/, safe to remove)`,
|
|
149
|
+
fail: false,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
line: `${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
|
|
154
|
+
fail: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
86
159
|
/**
|
|
87
160
|
* Emits a per-entry status line for each name in SHARED_LINKS
|
|
88
|
-
* (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs
|
|
89
|
-
* process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
|
|
161
|
+
* (okGlyph/warnGlyph/infoGlyph/failGlyph). A non-symlink blocks sync and FAILs
|
|
162
|
+
* via process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
|
|
90
163
|
* that vanishes or becomes unreadable between the probe and the stat yields a
|
|
91
|
-
* row instead of an unhandled throw that aborts the whole doctor run.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
164
|
+
* row instead of an unhandled throw that aborts the whole doctor run. Severity
|
|
165
|
+
* keys off whether the repo still has a `shared/<name>` source: an absent or
|
|
166
|
+
* dangling link is a WARN with a `nomad pull` hint when the source exists (a
|
|
167
|
+
* real out-of-sync state), and a calm info note when it does not (nothing to
|
|
168
|
+
* sync). A symlink whose target cannot be resolved is never a healthy OK, so a
|
|
169
|
+
* dangling or unreadable link is not masked.
|
|
95
170
|
*/
|
|
96
171
|
export function reportSharedLinks(section: DoctorSection): void {
|
|
97
172
|
for (const name of SHARED_LINKS) {
|
|
98
173
|
const p = join(CLAUDE_HOME, name);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
} catch (err) {
|
|
103
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
104
|
-
if (code === 'ENOENT') {
|
|
105
|
-
addItem(section, `${yellow(warnGlyph)} ${name}: missing`);
|
|
106
|
-
} else {
|
|
107
|
-
addItem(section, `${red(failGlyph)} ${name}: could not stat (${String(code)})`);
|
|
108
|
-
process.exitCode = 1;
|
|
109
|
-
}
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (stat.isSymbolicLink()) {
|
|
113
|
-
try {
|
|
114
|
-
// statSync follows the link; a throw means the target does not resolve.
|
|
115
|
-
statSync(p);
|
|
116
|
-
addItem(section, `${green(okGlyph)} ${name}: symlink`);
|
|
117
|
-
} catch (err) {
|
|
118
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
119
|
-
if (code === 'ENOENT') {
|
|
120
|
-
addItem(section, `${yellow(warnGlyph)} ${name}: broken symlink (target missing)`);
|
|
121
|
-
} else {
|
|
122
|
-
addItem(
|
|
123
|
-
section,
|
|
124
|
-
`${yellow(warnGlyph)} ${name}: symlink target unreadable (${String(code)})`,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
} else {
|
|
129
|
-
addItem(section, `${red(failGlyph)} ${name}: NOT a symlink (blocks sync)`);
|
|
130
|
-
process.exitCode = 1;
|
|
131
|
-
}
|
|
174
|
+
const { line, fail } = classifySharedLink(name, p);
|
|
175
|
+
addItem(section, line);
|
|
176
|
+
if (fail) process.exitCode = 1;
|
|
132
177
|
}
|
|
133
178
|
}
|
|
@@ -70,17 +70,32 @@ function sectionFailed(s: DoctorSection): boolean {
|
|
|
70
70
|
* headers with a red `✗ ` glyph (U+2717, same as the per-item FAIL glyph so
|
|
71
71
|
* `grep -F '✗'` catches both row and header failures), and writes one blank
|
|
72
72
|
* line between rendered sections (no leading or trailing blank).
|
|
73
|
+
*
|
|
74
|
+
* An empty-string item renders as a true blank line (no tree connector), which
|
|
75
|
+
* lets a reporter set off a footer block (e.g. the `--check-shared` description
|
|
76
|
+
* legend) with vertical whitespace. The `└` connector attaches to the last
|
|
77
|
+
* non-empty item rather than the last array slot so a trailing blank does not
|
|
78
|
+
* strand the elbow on an empty line.
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* Render one section: a (possibly fail-glyph-prefixed) header followed by its
|
|
82
|
+
* items as a tree. Empty-string items print as true blank lines; the `└` elbow
|
|
83
|
+
* attaches to the last non-empty item so a trailing blank cannot strand it.
|
|
73
84
|
*/
|
|
85
|
+
function renderSection(s: DoctorSection): void {
|
|
86
|
+
const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
|
|
87
|
+
console.log(header);
|
|
88
|
+
const lastContent = s.items.reduce((acc, item, j) => (item !== '' ? j : acc), -1);
|
|
89
|
+
for (let j = 0; j < s.items.length; j++) {
|
|
90
|
+
if (s.items[j] === '') console.log('');
|
|
91
|
+
else console.log(`${j === lastContent ? ' └ ' : ' ├ '}${s.items[j]}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
export function renderDoctor(sections: DoctorSection[]): void {
|
|
75
96
|
const visible = sections.filter((s) => s.items.length > 0);
|
|
76
97
|
for (let i = 0; i < visible.length; i++) {
|
|
77
98
|
if (i > 0) console.log('');
|
|
78
|
-
|
|
79
|
-
const header = sectionFailed(s) ? `${red(FAIL_GLYPH_BARE)} ${s.header}` : s.header;
|
|
80
|
-
console.log(header);
|
|
81
|
-
for (let j = 0; j < s.items.length; j++) {
|
|
82
|
-
const isLast = j === s.items.length - 1;
|
|
83
|
-
console.log(`${isLast ? ' └ ' : ' ├ '}${s.items[j]}`);
|
|
84
|
-
}
|
|
99
|
+
renderSection(visible[i]);
|
|
85
100
|
}
|
|
86
101
|
}
|
|
@@ -52,9 +52,14 @@ export function reportMirrorActions(section: DoctorSection, run: SpawnSyncFn = e
|
|
|
52
52
|
const ref = parseGitHubRemote(remote);
|
|
53
53
|
if (ref === null) return;
|
|
54
54
|
|
|
55
|
-
// Gate 3: gh available and authed.
|
|
56
|
-
// (init prints a tip here; doctor does not, per the
|
|
57
|
-
|
|
55
|
+
// Gate 3: gh available and authed. A definitive gh-not-installed / gh-not-authed
|
|
56
|
+
// result is a silent skip (init prints a tip here; doctor does not, per the
|
|
57
|
+
// read-only contract). A gh-probe-error (the auth-status call timed out or
|
|
58
|
+
// hiccuped) is NOT definitive, so fall through: gates 4-5 run their own probes
|
|
59
|
+
// and silently skip if the network is genuinely down, but the drift WARN can
|
|
60
|
+
// still fire when only the auth-status call blipped on an authed host (#124).
|
|
61
|
+
const auth = ghAuthStatus(run);
|
|
62
|
+
if (auth === 'gh-not-installed' || auth === 'gh-not-authed') return;
|
|
58
63
|
|
|
59
64
|
// Gate 4: private mirror. A public repo, or a probe that throws, is a skip.
|
|
60
65
|
let isPrivate: boolean;
|
package/src/commands.doctor.ts
CHANGED
|
@@ -62,7 +62,7 @@ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
|
|
|
62
62
|
reportRebaseClean(repository);
|
|
63
63
|
reportMirrorActions(repository);
|
|
64
64
|
|
|
65
|
-
const version = section('Version');
|
|
65
|
+
const version = section('Version Checks');
|
|
66
66
|
reportVersionCheck(version);
|
|
67
67
|
reportNodeEngineCheck(version);
|
|
68
68
|
reportGitleaksVersionCheck(version);
|
|
@@ -11,9 +11,9 @@ import { HOME, UPSTREAM_REPO_SLUG } from './config.ts';
|
|
|
11
11
|
* Soft, offline-tolerant release-version check appended to `cmdDoctor`. Reads
|
|
12
12
|
* the local `package.json.version`, compares it to the latest release tag on
|
|
13
13
|
* the upstream GitHub repo (cached 1h, 3s curl timeout), and emits one of:
|
|
14
|
-
* - `✓
|
|
15
|
-
* - `⚠︎
|
|
16
|
-
* - `ℹ︎
|
|
14
|
+
* - `✓ claude-nomad: <local> (latest)` when local == latest
|
|
15
|
+
* - `⚠︎ claude-nomad: <local> -> <latest>` when local < latest
|
|
16
|
+
* - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when local > latest
|
|
17
17
|
* Every failure path (offline, curl missing, non-2xx, malformed JSON, missing
|
|
18
18
|
* `tag_name`, missing/unreadable package.json) is a SILENT skip; this module
|
|
19
19
|
* never sets `process.exitCode` and never writes to stderr.
|
|
@@ -153,9 +153,9 @@ function fetchLatestTag(): string | null {
|
|
|
153
153
|
* Emit a single, non-fatal version diagnostic for `nomad doctor` by comparing the local package.json version to the latest upstream release.
|
|
154
154
|
*
|
|
155
155
|
* Logs one of:
|
|
156
|
-
* - `✓
|
|
157
|
-
* - `⚠︎
|
|
158
|
-
* - `ℹ︎
|
|
156
|
+
* - `✓ claude-nomad: <local> (latest)` when the versions match
|
|
157
|
+
* - `⚠︎ claude-nomad: <local> -> <latest> (run \`nomad update\`)` when the local version is behind
|
|
158
|
+
* - `ℹ︎ claude-nomad: <local> (ahead of latest release <latest>)` when the local version is ahead
|
|
159
159
|
*
|
|
160
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
161
|
*/
|
|
@@ -181,10 +181,16 @@ export function reportVersionCheck(section: DoctorSection): void {
|
|
|
181
181
|
|
|
182
182
|
const cmp = compareSemver(localPure, latest);
|
|
183
183
|
if (cmp === 0) {
|
|
184
|
-
addItem(section, `${green(okGlyph)}
|
|
184
|
+
addItem(section, `${green(okGlyph)} claude-nomad: ${local} (latest)`);
|
|
185
185
|
} else if (cmp === -1) {
|
|
186
|
-
addItem(
|
|
186
|
+
addItem(
|
|
187
|
+
section,
|
|
188
|
+
`${yellow(warnGlyph)} claude-nomad: ${local} -> ${latest} (run \`nomad update\`)`,
|
|
189
|
+
);
|
|
187
190
|
} else {
|
|
188
|
-
addItem(
|
|
191
|
+
addItem(
|
|
192
|
+
section,
|
|
193
|
+
`${dim(infoGlyph)} claude-nomad: ${local} (ahead of latest release ${latest})`,
|
|
194
|
+
);
|
|
189
195
|
}
|
|
190
196
|
}
|
package/src/gh-actions.ts
CHANGED
|
@@ -7,10 +7,13 @@ import { execFileSync, type ExecFileSyncOptions } from 'node:child_process';
|
|
|
7
7
|
export type GhRepoRef = { owner: string; repo: string };
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Reason `ghAuthStatus` returned without success. Distinguishes
|
|
11
|
-
* actionable failure modes so callers
|
|
10
|
+
* Reason `ghAuthStatus` returned without success. Distinguishes three
|
|
11
|
+
* actionable failure modes so callers decide how to treat each:
|
|
12
|
+
* `gh-not-installed` (the binary is missing), `gh-not-authed` (gh ran and
|
|
13
|
+
* reported no authentication), and `gh-probe-error` (the probe itself failed,
|
|
14
|
+
* e.g. a timeout or transient spawn error, so the auth state is unknown).
|
|
12
15
|
*/
|
|
13
|
-
export type GhUnavailableReason = 'gh-not-installed' | 'gh-not-authed';
|
|
16
|
+
export type GhUnavailableReason = 'gh-not-installed' | 'gh-not-authed' | 'gh-probe-error';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Injectable subprocess runner so tests can mock without `vi.doMock` and
|
|
@@ -47,8 +50,16 @@ export function parseGitHubRemote(remoteUrl: string): GhRepoRef | null {
|
|
|
47
50
|
/**
|
|
48
51
|
* Check `gh` CLI availability and auth status in one call. Returns null on
|
|
49
52
|
* success or a structured reason string. `gh auth status` exits 0 when the
|
|
50
|
-
* user is authed against github.com and non-zero otherwise
|
|
51
|
-
*
|
|
53
|
+
* user is authed against github.com and non-zero otherwise.
|
|
54
|
+
*
|
|
55
|
+
* The catch separates a definitive answer from an indeterminate one so callers
|
|
56
|
+
* are not forced to treat a transient probe failure as "not authed":
|
|
57
|
+
* - `ENOENT`: the binary is missing, so `gh-not-installed`.
|
|
58
|
+
* - the child ran and exited with a numeric code (`typeof status === 'number'`,
|
|
59
|
+
* which by spawnSync semantics means it was not signal-killed): the only
|
|
60
|
+
* definitive unauthenticated answer, so `gh-not-authed`.
|
|
61
|
+
* - anything else (a timeout SIGTERM-kills the child so `status` is null, an
|
|
62
|
+
* `ETIMEDOUT`, a spawn hiccup): the probe itself failed, so `gh-probe-error`.
|
|
52
63
|
*/
|
|
53
64
|
export function ghAuthStatus(run: SpawnSyncFn = execFileSync): GhUnavailableReason | null {
|
|
54
65
|
try {
|
|
@@ -58,9 +69,10 @@ export function ghAuthStatus(run: SpawnSyncFn = execFileSync): GhUnavailableReas
|
|
|
58
69
|
});
|
|
59
70
|
return null;
|
|
60
71
|
} catch (err) {
|
|
61
|
-
const e = err as { code?: string };
|
|
72
|
+
const e = err as { code?: string; status?: number | null };
|
|
62
73
|
if (e.code === 'ENOENT') return 'gh-not-installed';
|
|
63
|
-
return 'gh-not-authed';
|
|
74
|
+
if (typeof e.status === 'number') return 'gh-not-authed';
|
|
75
|
+
return 'gh-probe-error';
|
|
64
76
|
}
|
|
65
77
|
}
|
|
66
78
|
|
package/src/init.ts
CHANGED
|
@@ -159,6 +159,11 @@ function maybeDisableMirrorActions(repoHome: string, run?: SpawnSyncFn): void {
|
|
|
159
159
|
);
|
|
160
160
|
return;
|
|
161
161
|
}
|
|
162
|
+
// A gh-probe-error (auth-status timed out or hiccuped) is deliberately left to
|
|
163
|
+
// fall through: auth state is unknown, so the privacy probe below tries
|
|
164
|
+
// optimistically with its own catch + tip. This avoids the misleading
|
|
165
|
+
// 'gh auth login' tip a transient failure used to trigger when the user may
|
|
166
|
+
// in fact be authed (#124).
|
|
162
167
|
|
|
163
168
|
let isPrivate: boolean;
|
|
164
169
|
try {
|
package/src/nomad.help.ts
CHANGED
|
@@ -5,37 +5,67 @@
|
|
|
5
5
|
* cold invocation of `nomad` is self-describing without forcing the user
|
|
6
6
|
* into the README. Channel is stderr, exit code is 1.
|
|
7
7
|
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Column (0-indexed) at which every command and flag description starts. Sized
|
|
11
|
+
* to clear the longest label (`--resume-cmd <id>`, which ends at column 24)
|
|
12
|
+
* with a two-space gutter. A single constant is what keeps every row aligned;
|
|
13
|
+
* padding lines by hand is how a description drifts out of column.
|
|
14
|
+
*/
|
|
15
|
+
const DESC_COL = 26;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render a `label` + `desc` help row, padding the label out to DESC_COL so the
|
|
19
|
+
* description lands in the shared column. `padEnd` is a no-op when a label is
|
|
20
|
+
* already at or past the column, so no row can throw or fall out of alignment.
|
|
21
|
+
*/
|
|
22
|
+
const row = (label: string, desc: string): string => label.padEnd(DESC_COL) + desc;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Indent a continuation line (wrapped description text with no label of its
|
|
26
|
+
* own) to DESC_COL so it sits directly under the description column.
|
|
27
|
+
*/
|
|
28
|
+
const cont = (text: string): string => ' '.repeat(DESC_COL) + text;
|
|
29
|
+
|
|
8
30
|
export const DEFAULT_HELP = [
|
|
9
31
|
'usage: nomad <command> [flags]',
|
|
10
32
|
'',
|
|
11
33
|
'Commands:',
|
|
12
|
-
' pull
|
|
13
|
-
' --dry-run
|
|
34
|
+
row(' pull', 'Sync ~/.claude/ from the shared repo (settings, symlinks, sessions).'),
|
|
35
|
+
row(' --dry-run', 'Run lock + git pull, then preview every mutation without writing.'),
|
|
36
|
+
'',
|
|
37
|
+
row(' push', 'Rebase, run safety checks (gitleaks, gitlinks, allow-list), commit, push.'),
|
|
38
|
+
row(' --dry-run', 'Run pre-checks (rebase, gitleaks probe, gitlink scan) and preview'),
|
|
39
|
+
cont('remap, without staging or pushing.'),
|
|
14
40
|
'',
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
' remap, without staging or pushing.',
|
|
41
|
+
row(' diff', 'Offline preview of what `pull` would change against local repo state.'),
|
|
42
|
+
cont('No git pull, no lock acquired.'),
|
|
18
43
|
'',
|
|
19
|
-
'
|
|
20
|
-
'
|
|
44
|
+
row(' init', 'Scaffold an empty ~/claude-nomad/ repo (shared/, hosts/, path-map).'),
|
|
45
|
+
row(' --snapshot', 'Overlay the current ~/.claude/ into shared/ as the initial seed.'),
|
|
46
|
+
row(' --keep-actions', 'Skip auto-disabling GitHub Actions on the private mirror.'),
|
|
21
47
|
'',
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
' --
|
|
48
|
+
row(' doctor', 'Read-only health check (symlinks, host file, path-map,'),
|
|
49
|
+
cont('gitleaks, gitlinks).'),
|
|
50
|
+
row(' --check-shared', 'Preflight gitleaks scan of the session transcripts a'),
|
|
51
|
+
cont('`nomad push` would stage (a temp copy, never the live dir).'),
|
|
52
|
+
row(' --resume-cmd <id>', 'Print `cd <abspath> && claude --resume <id>` for a session id'),
|
|
53
|
+
cont('from ~/.claude/projects/.'),
|
|
25
54
|
'',
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
' --resume-cmd <id> Print `cd <abspath> && claude --resume <id>` for a session id',
|
|
31
|
-
' from ~/.claude/projects/.',
|
|
55
|
+
row(
|
|
56
|
+
' drop-session <id>',
|
|
57
|
+
'Unstage shared/projects/<logical>/<id>.jsonl from the staged tree (local ~/.claude/projects is never touched).',
|
|
58
|
+
),
|
|
32
59
|
'',
|
|
33
|
-
'
|
|
60
|
+
row(' update', 'Topology-aware upgrade of ~/claude-nomad/ to the latest upstream.'),
|
|
61
|
+
row(' --dry-run', 'Detect topology + pre-flight, print would-be git commands only.'),
|
|
62
|
+
row(' --force', 'Proceed even when the working tree is not clean.'),
|
|
63
|
+
row(
|
|
64
|
+
' --push-origin',
|
|
65
|
+
'Fork topology only: push the merge to origin/main without prompting.',
|
|
66
|
+
),
|
|
34
67
|
'',
|
|
35
|
-
'
|
|
36
|
-
' --dry-run Detect topology + pre-flight, print would-be git commands only.',
|
|
37
|
-
' --force Proceed even when the working tree is not clean.',
|
|
38
|
-
' --push-origin Fork topology only: push the merge to origin/main without prompting.',
|
|
68
|
+
row(' --version', 'Print the installed CLI version as bare semver to stdout; exits 0.'),
|
|
39
69
|
'',
|
|
40
70
|
'Run `nomad doctor` to validate your setup. Edit shared/ or hosts/<HOST>.json',
|
|
41
71
|
'in the repo, never ~/.claude/settings.json directly (it is regenerated on',
|
package/src/nomad.ts
CHANGED
|
@@ -154,15 +154,11 @@ try {
|
|
|
154
154
|
// Single positional argv; cmdDropSession revalidates id at entry as
|
|
155
155
|
// defense-in-depth (the function may be called from non-argv paths
|
|
156
156
|
// in tests). The argv regex mirrors the function-entry allowlist
|
|
157
|
-
// (`[
|
|
157
|
+
// (`[\w-]`) but additionally rejects ids starting with `-`
|
|
158
158
|
// so a typo like `nomad drop-session --bogus` shows the usage line,
|
|
159
159
|
// not a FATAL. The length bound matches cmdDropSession.
|
|
160
160
|
const id = process.argv[3];
|
|
161
|
-
if (
|
|
162
|
-
process.argv.length !== 4 ||
|
|
163
|
-
typeof id !== 'string' ||
|
|
164
|
-
!/^[A-Za-z0-9_][A-Za-z0-9_-]{0,127}$/.test(id)
|
|
165
|
-
) {
|
|
161
|
+
if (process.argv.length !== 4 || typeof id !== 'string' || !/^\w[\w-]{0,127}$/.test(id)) {
|
|
166
162
|
console.error('usage: nomad drop-session <id>');
|
|
167
163
|
process.exit(1);
|
|
168
164
|
}
|