claude-nomad 0.25.0 → 0.25.2
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 +19 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/commands.doctor.check-shared.scan.ts +158 -0
- package/src/commands.doctor.check-shared.ts +58 -189
- package/src/commands.doctor.checks.pathmap.ts +101 -0
- package/src/commands.doctor.checks.repo.ts +133 -0
- package/src/commands.doctor.checks.repository.ts +105 -0
- package/src/commands.doctor.checks.settings.ts +88 -0
- package/src/commands.doctor.format.ts +18 -0
- package/src/commands.doctor.ts +10 -7
- package/src/commands.drop-session.git.ts +81 -0
- package/src/commands.drop-session.ts +79 -138
- package/src/commands.pull.ts +3 -2
- package/src/commands.push.allowlist.ts +119 -0
- package/src/commands.push.ts +6 -121
- package/src/commands.update.git.ts +90 -0
- package/src/commands.update.resolve.ts +138 -0
- package/src/commands.update.test-helpers.git.ts +107 -0
- package/src/commands.update.ts +4 -221
- package/src/diff.ts +2 -1
- package/src/extras-sync.diff.ts +40 -0
- package/src/extras-sync.guards.ts +52 -0
- package/src/extras-sync.ts +146 -236
- package/src/init.classify.ts +1 -1
- package/src/init.snapshot.ts +3 -1
- package/src/init.ts +2 -1
- package/src/links.ts +3 -10
- package/src/nomad.dispatch.ts +25 -0
- package/src/nomad.help.ts +43 -0
- package/src/nomad.ts +6 -68
- package/src/preview.ts +2 -1
- package/src/push-gitleaks.scan.ts +115 -0
- package/src/push-gitleaks.ts +50 -106
- package/src/remap.ts +3 -1
- package/src/resume.ts +2 -1
- package/src/update.fork-extras.ts +2 -1
- package/src/utils.fs.ts +152 -0
- package/src/utils.json.ts +55 -0
- package/src/utils.lockfile.ts +168 -0
- package/src/utils.ts +0 -327
- package/src/commands.doctor.checks.ts +0 -350
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync, lstatSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
blue,
|
|
6
|
+
cyan,
|
|
7
|
+
dim,
|
|
8
|
+
failGlyph,
|
|
9
|
+
green,
|
|
10
|
+
infoGlyph,
|
|
11
|
+
okGlyph,
|
|
12
|
+
red,
|
|
13
|
+
warnGlyph,
|
|
14
|
+
yellow,
|
|
15
|
+
} from './color.ts';
|
|
16
|
+
import { CLAUDE_HOME, HOST, REPO_HOME, SHARED_LINKS } from './config.ts';
|
|
17
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
18
|
+
import { classifyRepoState, reasonForPartial } from './init.classify.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Host- and repo-state reporters for `cmdDoctor`. Each helper appends one or
|
|
22
|
+
* more items to its target `DoctorSection` (via `addItem`) and signals failure
|
|
23
|
+
* by setting `process.exitCode = 1`. Items go to stdout at render time through
|
|
24
|
+
* `renderDoctor` in `commands.doctor.format`; nothing here writes to stderr
|
|
25
|
+
* (read-only doctor contract: FAIL lines stay on stdout so a piped
|
|
26
|
+
* `nomad doctor 2>/dev/null` does not lose them).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when the `NOMAD_REPO` env override is set to a non-empty value.
|
|
31
|
+
* Mirrors the `||` empty-string-fallthrough semantics of `REPO_HOME` itself
|
|
32
|
+
* (see `src/config.ts`): an unset env, or `export NOMAD_REPO=`, both return
|
|
33
|
+
* false because the default fallback fires. Reads `process.env.NOMAD_REPO`
|
|
34
|
+
* directly so a set-but-empty value is distinguishable from "set to the
|
|
35
|
+
* default path"; reading via the imported `REPO_HOME` constant cannot make
|
|
36
|
+
* that distinction. Exposed for `reportRepoState`; not for general use.
|
|
37
|
+
*/
|
|
38
|
+
export function isOverrideActive(): boolean {
|
|
39
|
+
return Boolean(process.env.NOMAD_REPO);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pushes the host identity (info) and the two key path lines (repo and
|
|
44
|
+
* claude-home) with gutter glyphs. Path presence is reported via warnGlyph
|
|
45
|
+
* (not failGlyph) so an absent CLAUDE_HOME does not flip sectionFailed to
|
|
46
|
+
* decorate the Host header with `✘`. The authoritative empty-repo FAIL is
|
|
47
|
+
* owned by reportRepoState; these two lines remain informational and do
|
|
48
|
+
* NOT mutate process.exitCode.
|
|
49
|
+
*/
|
|
50
|
+
export function reportHostAndPaths(section: DoctorSection): void {
|
|
51
|
+
addItem(section, `${dim(infoGlyph)} host: ${cyan(HOST)}`);
|
|
52
|
+
addItem(
|
|
53
|
+
section,
|
|
54
|
+
`${existsSync(REPO_HOME) ? green(okGlyph) : yellow(warnGlyph)} repo: ${blue(REPO_HOME)}`,
|
|
55
|
+
);
|
|
56
|
+
addItem(
|
|
57
|
+
section,
|
|
58
|
+
`${existsSync(CLAUDE_HOME) ? green(okGlyph) : yellow(warnGlyph)} claude home: ${blue(CLAUDE_HOME)}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 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. */
|
|
63
|
+
export function reportRepoState(section: DoctorSection): void {
|
|
64
|
+
const state = classifyRepoState(REPO_HOME, HOST);
|
|
65
|
+
// Computed once so populated/partial/empty branches share the same
|
|
66
|
+
// annotation. Leading space before `(` keeps the line readable on every
|
|
67
|
+
// branch; empty string produces zero visual change when the override is
|
|
68
|
+
// not in play, matching SPEC §5 (acceptance: unset env -> no annotation).
|
|
69
|
+
const overrideLabel = isOverrideActive() ? ' (NOMAD_REPO)' : '';
|
|
70
|
+
if (state === 'populated') {
|
|
71
|
+
addItem(section, `${green(okGlyph)} repo state: populated${overrideLabel}`);
|
|
72
|
+
} else if (state === 'partial') {
|
|
73
|
+
addItem(
|
|
74
|
+
section,
|
|
75
|
+
`${yellow(warnGlyph)} repo state: partial ${reasonForPartial(REPO_HOME, HOST)}${overrideLabel}`,
|
|
76
|
+
);
|
|
77
|
+
} else {
|
|
78
|
+
addItem(
|
|
79
|
+
section,
|
|
80
|
+
`${red(failGlyph)} repo state: empty - run 'nomad init' to scaffold${overrideLabel}`,
|
|
81
|
+
);
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Emits a per-entry status line for each name in SHARED_LINKS
|
|
88
|
+
* (okGlyph/warnGlyph/failGlyph). A non-symlink blocks sync and FAILs via
|
|
89
|
+
* process.exitCode. TOCTOU-safe: lstatSync is wrapped in try/catch so a path
|
|
90
|
+
* 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. A symlink
|
|
92
|
+
* whose target cannot be resolved is a WARN (broken-symlink for a missing
|
|
93
|
+
* target, target-unreadable otherwise), never a healthy OK, so a dangling or
|
|
94
|
+
* unreadable link is not masked.
|
|
95
|
+
*/
|
|
96
|
+
export function reportSharedLinks(section: DoctorSection): void {
|
|
97
|
+
for (const name of SHARED_LINKS) {
|
|
98
|
+
const p = join(CLAUDE_HOME, name);
|
|
99
|
+
let stat;
|
|
100
|
+
try {
|
|
101
|
+
stat = lstatSync(p);
|
|
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
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } 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
|
+
import { REPO_HOME } from './config.ts';
|
|
18
|
+
import { addItem, type DoctorSection } from './commands.doctor.format.ts';
|
|
19
|
+
import { findGitlinks } from './push-checks.ts';
|
|
20
|
+
import { gitStatusPorcelainZ } from './utils.ts';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Repository-state reporters for `cmdDoctor`: the gitleaks presence probe, the
|
|
24
|
+
* nested-gitlink scan of `shared/`, the remote-origin line, and the
|
|
25
|
+
* rebase-clean-tree WARN. Each helper appends items to its target
|
|
26
|
+
* `DoctorSection` and signals failure by setting `process.exitCode = 1`.
|
|
27
|
+
* Read-only: FAIL lines stay on stdout.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with
|
|
32
|
+
* ENOENT vs other-error distinction (sets exitCode=1). Returns `true` when a
|
|
33
|
+
* usable binary was found so the caller can skip a redundant second `version`
|
|
34
|
+
* probe (e.g. the `--check-shared` Shared scan section).
|
|
35
|
+
*/
|
|
36
|
+
export function reportGitleaksProbe(section: DoctorSection): boolean {
|
|
37
|
+
try {
|
|
38
|
+
const v = execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
39
|
+
.toString()
|
|
40
|
+
.trim();
|
|
41
|
+
addItem(section, `${green(okGlyph)} gitleaks: ${dim(v)}`);
|
|
42
|
+
return true;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
45
|
+
addItem(section, `${red(failGlyph)} gitleaks: not on PATH (required for nomad push)`);
|
|
46
|
+
} else {
|
|
47
|
+
addItem(section, `${red(failGlyph)} gitleaks: probe failed: ${(err as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Walks shared/ for nested .git gitlinks; emits failGlyph per gitlink found (sets exitCode=1), okGlyph when none. */
|
|
55
|
+
export function reportGitlinks(section: DoctorSection): void {
|
|
56
|
+
const sharedDir = join(REPO_HOME, 'shared');
|
|
57
|
+
if (existsSync(sharedDir)) {
|
|
58
|
+
const gitlinks = findGitlinks(sharedDir);
|
|
59
|
+
for (const p of gitlinks) {
|
|
60
|
+
const rel = relative(REPO_HOME, p);
|
|
61
|
+
addItem(
|
|
62
|
+
section,
|
|
63
|
+
`${red(failGlyph)} gitlink: ${blue(rel)} would push as submodule (run: rm -rf ${rel} or remove the nested repo)`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (gitlinks.length > 0) {
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
} else {
|
|
69
|
+
addItem(section, `${green(okGlyph)} gitlink scan: no nested .git in shared/`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pushes the `git remote get-url origin` line or a `not configured` informational line. */
|
|
75
|
+
export function reportRemote(section: DoctorSection): void {
|
|
76
|
+
try {
|
|
77
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
78
|
+
cwd: REPO_HOME,
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
})
|
|
81
|
+
.toString()
|
|
82
|
+
.trim();
|
|
83
|
+
addItem(section, `${dim(infoGlyph)} remote origin: ${cyan(url)}`);
|
|
84
|
+
} catch {
|
|
85
|
+
addItem(section, `${dim(infoGlyph)} remote origin: not configured`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** WARNs when ~/claude-nomad/ has uncommitted changes (autostash territory for push). */
|
|
90
|
+
export function reportRebaseClean(section: DoctorSection): void {
|
|
91
|
+
try {
|
|
92
|
+
const status = gitStatusPorcelainZ(REPO_HOME);
|
|
93
|
+
if (status.length > 0) {
|
|
94
|
+
addItem(
|
|
95
|
+
section,
|
|
96
|
+
`${yellow(warnGlyph)} ${blue('~/claude-nomad/')} has uncommitted changes (nomad push will --autostash these)`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// gitStatusPorcelainZ failure on a missing or non-repo REPO_HOME is
|
|
101
|
+
// already surfaced by reportHostAndPaths (warnGlyph on the `repo:` line
|
|
102
|
+
// when the directory is absent) and reportRepoState ('empty' FAIL when
|
|
103
|
+
// the scaffold is absent). Swallowing here avoids double-reporting.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
blue,
|
|
6
|
+
dim,
|
|
7
|
+
failGlyph,
|
|
8
|
+
green,
|
|
9
|
+
infoGlyph,
|
|
10
|
+
okGlyph,
|
|
11
|
+
red,
|
|
12
|
+
warnGlyph,
|
|
13
|
+
yellow,
|
|
14
|
+
} from './color.ts';
|
|
15
|
+
import { HOST, KNOWN_SETTINGS_KEYS, REPO_HOME, CLAUDE_HOME } from './config.ts';
|
|
16
|
+
import { addItem, readJsonSafe, type DoctorSection } from './commands.doctor.format.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Settings reporters for `cmdDoctor`: the shared base, the local
|
|
20
|
+
* `settings.json` schema check, and the host-override diagnostic. Each helper
|
|
21
|
+
* appends items to its target `DoctorSection` and signals failure by setting
|
|
22
|
+
* `process.exitCode = 1`. Read-only: FAIL lines stay on stdout (a piped
|
|
23
|
+
* `nomad doctor 2>/dev/null` keeps them).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Loads shared/settings.base.json; on missing or malformed, records a FAIL item in the supplied section. Returns the parsed object or null. */
|
|
27
|
+
export function loadBaseSettings(section: DoctorSection): Record<string, unknown> | null {
|
|
28
|
+
const basePath = join(REPO_HOME, 'shared', 'settings.base.json');
|
|
29
|
+
if (!existsSync(basePath)) {
|
|
30
|
+
addItem(section, `${red(failGlyph)} shared/settings.base.json missing at ${blue(basePath)}`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return readJsonSafe<Record<string, unknown>>(basePath, basePath, section);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 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. */
|
|
38
|
+
export function loadAndReportSettings(section: DoctorSection): Record<string, unknown> | null {
|
|
39
|
+
const settingsPath = join(CLAUDE_HOME, 'settings.json');
|
|
40
|
+
if (!existsSync(settingsPath)) return null;
|
|
41
|
+
const settings = readJsonSafe<Record<string, unknown>>(settingsPath, settingsPath, section);
|
|
42
|
+
if (settings === null) return null;
|
|
43
|
+
const unknownKeys = Object.keys(settings).filter((k) => !KNOWN_SETTINGS_KEYS.has(k));
|
|
44
|
+
if (unknownKeys.length > 0) {
|
|
45
|
+
addItem(
|
|
46
|
+
section,
|
|
47
|
+
`${yellow(warnGlyph)} settings.json has unknown keys (schema drift?): ${unknownKeys.join(', ')}`,
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
addItem(section, `${green(okGlyph)} settings.json schema: known keys only`);
|
|
51
|
+
}
|
|
52
|
+
return settings;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 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. */
|
|
56
|
+
export function reportHostOverrides(
|
|
57
|
+
section: DoctorSection,
|
|
58
|
+
base: Record<string, unknown> | null,
|
|
59
|
+
settings: Record<string, unknown> | null,
|
|
60
|
+
): void {
|
|
61
|
+
const hostFile = join(REPO_HOME, 'hosts', `${HOST}.json`);
|
|
62
|
+
let drift: string[] = [];
|
|
63
|
+
if (base !== null && settings !== null) {
|
|
64
|
+
const baseKeys = new Set(Object.keys(base));
|
|
65
|
+
drift = Object.keys(settings).filter((k) => !baseKeys.has(k));
|
|
66
|
+
}
|
|
67
|
+
if (existsSync(hostFile)) {
|
|
68
|
+
if (readJsonSafe<Record<string, unknown>>(hostFile, hostFile, section) !== null) {
|
|
69
|
+
addItem(section, `${green(okGlyph)} host overrides: ${blue(hostFile)}`);
|
|
70
|
+
}
|
|
71
|
+
} else if (drift.length > 0) {
|
|
72
|
+
addItem(
|
|
73
|
+
section,
|
|
74
|
+
`${red(failGlyph)} no hosts/${HOST}.json AND settings.json has unbased keys ${JSON.stringify(drift)}`,
|
|
75
|
+
);
|
|
76
|
+
const hostsDir = join(REPO_HOME, 'hosts');
|
|
77
|
+
if (existsSync(hostsDir)) {
|
|
78
|
+
const cands = readdirSync(hostsDir).filter((f) => f.endsWith('.json'));
|
|
79
|
+
if (cands.length > 0) addItem(section, `${dim(infoGlyph)} candidates: ${cands.join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
} else {
|
|
83
|
+
addItem(
|
|
84
|
+
section,
|
|
85
|
+
`${green(okGlyph)} host overrides: none (base-only is fine, no settings drift)`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { failGlyph, red } from './color.ts';
|
|
2
|
+
import { readJson } from './utils.json.ts';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Bare `failGlyph` codepoint (`✗`, U+2717) without any WSL padding the
|
|
@@ -38,6 +39,23 @@ export function addItem(s: DoctorSection, text: string): void {
|
|
|
38
39
|
s.items.push(text);
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Tolerant JSON reader for `cmdDoctor`. Doctor reads three JSON files
|
|
44
|
+
* (`settings.json`, `settings.base.json`, `path-map.json`); a malformed
|
|
45
|
+
* input must not throw mid-output (user would lose every line below it).
|
|
46
|
+
* Returns `null` on parse failure, records a FAIL item in the supplied
|
|
47
|
+
* section, and sets `process.exitCode = 1` so scripts can gate on the result.
|
|
48
|
+
*/
|
|
49
|
+
export function readJsonSafe<T>(path: string, label: string, section: DoctorSection): T | null {
|
|
50
|
+
try {
|
|
51
|
+
return readJson<T>(path);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
addItem(section, `${red(failGlyph)} ${label} malformed JSON: ${(err as Error).message}`);
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
/**
|
|
42
60
|
* True when any item in the section contains the FAIL glyph.
|
|
43
61
|
* Color-wrapped failGlyph (`[31m✗[39m`) still contains the
|
package/src/commands.doctor.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
reportHostAndPaths,
|
|
3
|
+
reportRepoState,
|
|
4
|
+
reportSharedLinks,
|
|
5
|
+
} from './commands.doctor.checks.repo.ts';
|
|
1
6
|
import {
|
|
2
7
|
loadAndReportSettings,
|
|
3
8
|
loadBaseSettings,
|
|
9
|
+
reportHostOverrides,
|
|
10
|
+
} from './commands.doctor.checks.settings.ts';
|
|
11
|
+
import { reportNeverSync, reportPathMap } from './commands.doctor.checks.pathmap.ts';
|
|
12
|
+
import {
|
|
4
13
|
reportGitleaksProbe,
|
|
5
14
|
reportGitlinks,
|
|
6
|
-
reportHostAndPaths,
|
|
7
|
-
reportHostOverrides,
|
|
8
|
-
reportNeverSync,
|
|
9
|
-
reportPathMap,
|
|
10
15
|
reportRebaseClean,
|
|
11
16
|
reportRemote,
|
|
12
|
-
|
|
13
|
-
reportSharedLinks,
|
|
14
|
-
} from './commands.doctor.checks.ts';
|
|
17
|
+
} from './commands.doctor.checks.repository.ts';
|
|
15
18
|
import { reportCheckShared } from './commands.doctor.check-shared.ts';
|
|
16
19
|
import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
|
|
17
20
|
import { renderDoctor, section } from './commands.doctor.format.ts';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import { REPO_HOME } from './config.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Expand a repo-relative directory into its staged entries via
|
|
7
|
+
* `git ls-files -z -- <dirRel>` (argv-array form, NUL-split for path
|
|
8
|
+
* safety). Returns repo-relative POSIX paths for every staged file under
|
|
9
|
+
* the directory, or an empty array when none are staged or `git` fails
|
|
10
|
+
* (missing/corrupt index); the caller then falls through to the existing
|
|
11
|
+
* per-entry idempotency guard rather than escalating to a FATAL.
|
|
12
|
+
*
|
|
13
|
+
* @param dirRel Repo-relative directory path (`shared/projects/<logical>/<id>`).
|
|
14
|
+
*/
|
|
15
|
+
export function expandStagedDir(dirRel: string): string[] {
|
|
16
|
+
try {
|
|
17
|
+
const out = execFileSync('git', ['ls-files', '-z', '--', dirRel], {
|
|
18
|
+
cwd: REPO_HOME,
|
|
19
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
20
|
+
});
|
|
21
|
+
return out
|
|
22
|
+
.toString()
|
|
23
|
+
.split('\0')
|
|
24
|
+
.filter((p) => p !== '');
|
|
25
|
+
} catch {
|
|
26
|
+
/* c8 ignore next -- defensive: a git ls-files failure falls back to an empty list */
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Is `rel` (repo-relative path) present in the HEAD tree? Wraps
|
|
33
|
+
* `git cat-file -e HEAD:<rel>`: exit 0 means tracked in HEAD,
|
|
34
|
+
* non-zero means either no HEAD exists yet (empty repo) or the path is
|
|
35
|
+
* only in the index (newly-staged-not-in-HEAD). `git ls-files
|
|
36
|
+
* --error-unmatch` is NOT a HEAD-presence check; it matches anything in
|
|
37
|
+
* the index too, which would misclassify newly-staged paths.
|
|
38
|
+
*
|
|
39
|
+
* The catch deliberately collapses three cases to `false`: (a) HEAD has
|
|
40
|
+
* no commit yet (fresh `git init`), (b) HEAD is unresolvable / corrupt
|
|
41
|
+
* (e.g., `.git/refs/heads/main` was deleted manually), and (c) the
|
|
42
|
+
* specific path simply does not exist in a valid HEAD. Git produces the
|
|
43
|
+
* same exit 128 and the same stderr (`fatal: invalid object name 'HEAD'`)
|
|
44
|
+
* for (a) and (b), so a probe-based distinction would require additional
|
|
45
|
+
* git-plumbing reads (`rev-parse --verify HEAD`, `.git/refs/heads/`
|
|
46
|
+
* inspection) that are brittle and break the empty-repo path every
|
|
47
|
+
* existing test runs through. The downstream `git rm --cached -f` is
|
|
48
|
+
* idempotent and produces the user-intended unstage outcome regardless
|
|
49
|
+
* of which case fired, so the collapsed return is intentional. Repo
|
|
50
|
+
* health belongs to `nomad doctor`, not drop-session.
|
|
51
|
+
*/
|
|
52
|
+
export function isTrackedInHead(rel: string): boolean {
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('git', ['cat-file', '-e', `HEAD:${rel}`], {
|
|
55
|
+
cwd: REPO_HOME,
|
|
56
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
});
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Is `rel` present in the index at all? Wraps `git ls-files -- <rel>` and
|
|
66
|
+
* checks for non-empty stdout. Required for the Pitfall 7 idempotency
|
|
67
|
+
* guard: a second invocation on the same id finds the file on disk (per
|
|
68
|
+
* `existsSync`) but absent from the index, and must NOT call `git rm
|
|
69
|
+
* --cached` on it (which would fail with exit 128).
|
|
70
|
+
*/
|
|
71
|
+
export function isInIndex(rel: string): boolean {
|
|
72
|
+
try {
|
|
73
|
+
const out = execFileSync('git', ['ls-files', '--', rel], {
|
|
74
|
+
cwd: REPO_HOME,
|
|
75
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
76
|
+
});
|
|
77
|
+
return out.toString().trim() !== '';
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|