@on-par/factory-cli 0.9.3 → 0.9.5-next.1
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/README.md +3 -3
- package/dist/cli/doctor.d.ts +106 -3
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +494 -57
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/first-run.d.ts +6 -1
- package/dist/cli/first-run.d.ts.map +1 -1
- package/dist/cli/first-run.js +9 -2
- package/dist/cli/first-run.js.map +1 -1
- package/dist/cli/github-stub.d.ts +38 -0
- package/dist/cli/github-stub.d.ts.map +1 -0
- package/dist/cli/github-stub.js +206 -0
- package/dist/cli/github-stub.js.map +1 -0
- package/dist/cli/index.d.ts +25 -6
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +351 -131
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/stop-sentinel.d.ts.map +1 -1
- package/dist/cli/stop-sentinel.js +5 -4
- package/dist/cli/stop-sentinel.js.map +1 -1
- package/dist/cli/sweep-heartbeat.js +5 -5
- package/dist/cli/sweep-heartbeat.js.map +1 -1
- package/dist/entry.d.ts +4 -1
- package/dist/entry.d.ts.map +1 -1
- package/dist/entry.js +14 -3
- package/dist/entry.js.map +1 -1
- package/dist/node-version.d.ts +5 -1
- package/dist/node-version.d.ts.map +1 -1
- package/dist/node-version.js +11 -3
- package/dist/node-version.js.map +1 -1
- package/package.json +3 -3
package/dist/cli/doctor.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
// packages/cli/src/cli/doctor.ts — `factory doctor` environment preflight
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { resolve } from 'node:path';
|
|
3
4
|
import { detectSandboxRuntime } from '@on-par/factory-core';
|
|
5
|
+
import { findConfigSchemaErrors, isHarnessCliPresent, isTemplateToken, lintFactoryConfig, listBuiltInCheckers, } from '@on-par/factory-core/internal';
|
|
4
6
|
import { MIN_NODE_MAJOR } from '../node-version.js';
|
|
5
|
-
import { CLAUDE_CODE_URL, GITHUB_TOKENS_URL } from './first-run.js';
|
|
7
|
+
import { availableAgentClis, CLAUDE_CODE_URL, CODEX_CLI_URL, GITHUB_TOKENS_URL } from './first-run.js';
|
|
8
|
+
/** Section for rows about repo-configured command harnesses (#2691). */
|
|
9
|
+
export const COMMAND_HARNESS_SECTION = 'Command harness';
|
|
10
|
+
/** Repo wired-up rows, sectioned only inside a git repo (#2557). */
|
|
11
|
+
export const REPO_SECTION = 'Repo';
|
|
6
12
|
export const NOT_IN_REPO_HINT = 'cd into your repo, then run factory doctor again';
|
|
13
|
+
/** Detail for every repo wired-up row when doctor runs outside a git repository (#2554). */
|
|
14
|
+
export const NOT_IN_REPO_SKIP_DETAIL = `skipped: not inside a git repository; ${NOT_IN_REPO_HINT}`;
|
|
7
15
|
export function hasSandboxRuntime(platform, commandAvailable) {
|
|
8
16
|
return detectSandboxRuntime(platform, commandAvailable) !== 'none';
|
|
9
17
|
}
|
|
@@ -11,13 +19,298 @@ export function hasSandboxRuntime(platform, commandAvailable) {
|
|
|
11
19
|
* or null when cmdDoctor should resolve the policy as before (#2383). */
|
|
12
20
|
export function sandboxAuthSkipDetail(opts) {
|
|
13
21
|
if (!opts.runtimeAvailable)
|
|
14
|
-
return 'skipped
|
|
22
|
+
return 'skipped: no sandbox runtime (sandbox-exec/firejail) on this host';
|
|
15
23
|
if (!opts.hostOk)
|
|
16
|
-
return 'skipped
|
|
24
|
+
return 'skipped: host claude auth was not verified';
|
|
17
25
|
if (!opts.inRepo)
|
|
18
|
-
return
|
|
26
|
+
return NOT_IN_REPO_SKIP_DETAIL;
|
|
19
27
|
return null;
|
|
20
28
|
}
|
|
29
|
+
/** Sign-in method reported by `codex login status`, or null when it shows no login. Never returns
|
|
30
|
+
* any part of a credential: only a fixed label is derived from the status text. */
|
|
31
|
+
export function codexAuthMethod(statusOutput) {
|
|
32
|
+
if (statusOutput === null)
|
|
33
|
+
return null;
|
|
34
|
+
const text = statusOutput.toLowerCase();
|
|
35
|
+
if (!text.includes('logged in') || text.includes('not logged in'))
|
|
36
|
+
return null;
|
|
37
|
+
if (text.includes('chatgpt'))
|
|
38
|
+
return 'ChatGPT';
|
|
39
|
+
if (text.includes('api key'))
|
|
40
|
+
return 'API key';
|
|
41
|
+
if (text.includes('access token'))
|
|
42
|
+
return 'access token';
|
|
43
|
+
return 'unknown method';
|
|
44
|
+
}
|
|
45
|
+
const GIT_AUTH_CHECK = 'git auth';
|
|
46
|
+
/** Non-interactive read-only probe of origin. `credential.interactive=never` turns off Git
|
|
47
|
+
* Credential Manager UI and an empty `core.askPass` turns off configured askpass programs;
|
|
48
|
+
* stored credentials from any helper still work. */
|
|
49
|
+
export const GIT_AUTH_PROBE_CMD = 'git -c credential.interactive=never -c core.askPass= ls-remote --quiet origin HEAD';
|
|
50
|
+
/** GIT_TERMINAL_PROMPT=0 makes git fail instead of asking on the terminal; empty GIT_ASKPASS
|
|
51
|
+
* and SSH_ASKPASS turn off askpass programs and ssh's GUI askpass; GCM_INTERACTIVE=never
|
|
52
|
+
* turns off Git Credential Manager prompts. */
|
|
53
|
+
export const GIT_AUTH_PROBE_ENV = {
|
|
54
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
55
|
+
GIT_ASKPASS: '',
|
|
56
|
+
SSH_ASKPASS: '',
|
|
57
|
+
GCM_INTERACTIVE: 'never',
|
|
58
|
+
};
|
|
59
|
+
/** Bounds the probe, e.g. an ssh key passphrase prompt that waits on /dev/tty. */
|
|
60
|
+
export const GIT_AUTH_PROBE_TIMEOUT_MS = 15_000;
|
|
61
|
+
/** Remedy shown only when the git-auth probe fails: wires git's credential helper to gh. */
|
|
62
|
+
export const GIT_AUTH_FIX = 'run `gh auth setup-git` so git uses your gh credentials, then re-run `factory doctor`';
|
|
63
|
+
/** Git-auth check (#2482, #2483): skipped, never failing, outside a git repository or without
|
|
64
|
+
* an origin remote. Otherwise runs a non-interactive `git ls-remote` against origin and fails
|
|
65
|
+
* when it cannot authenticate. The detail never echoes the origin URL, which may hold a token. */
|
|
66
|
+
export function gitAuthCheck(repoRoot, tryExec) {
|
|
67
|
+
if (repoRoot === null) {
|
|
68
|
+
return {
|
|
69
|
+
name: GIT_AUTH_CHECK,
|
|
70
|
+
ok: true,
|
|
71
|
+
optional: true,
|
|
72
|
+
detail: NOT_IN_REPO_SKIP_DETAIL,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const origin = tryExec('git remote get-url origin');
|
|
76
|
+
if (!origin) {
|
|
77
|
+
return { name: GIT_AUTH_CHECK, ok: true, optional: true, detail: 'skipped: no origin remote configured' };
|
|
78
|
+
}
|
|
79
|
+
// An empty string is exit 0 with no HEAD ref (an empty repo): still authenticated.
|
|
80
|
+
const probe = tryExec(GIT_AUTH_PROBE_CMD, { env: { ...GIT_AUTH_PROBE_ENV }, timeoutMs: GIT_AUTH_PROBE_TIMEOUT_MS });
|
|
81
|
+
if (probe === null) {
|
|
82
|
+
return {
|
|
83
|
+
name: GIT_AUTH_CHECK,
|
|
84
|
+
ok: false,
|
|
85
|
+
detail: 'git could not authenticate to origin non-interactively (prompting disabled)',
|
|
86
|
+
fix: GIT_AUTH_FIX,
|
|
87
|
+
next: 'gh auth setup-git',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { name: GIT_AUTH_CHECK, ok: true, detail: 'git can authenticate to origin non-interactively' };
|
|
91
|
+
}
|
|
92
|
+
/** One doctor section per built-in harness (#2555), in display order. */
|
|
93
|
+
export const HARNESS_SETUP = [
|
|
94
|
+
{
|
|
95
|
+
section: 'Claude Code',
|
|
96
|
+
harnessId: 'claude-cli',
|
|
97
|
+
cli: 'claude',
|
|
98
|
+
install: 'npm install -g @anthropic-ai/claude-code',
|
|
99
|
+
login: 'claude /login',
|
|
100
|
+
envKeys: ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
section: 'Codex',
|
|
104
|
+
harnessId: 'codex-cli',
|
|
105
|
+
cli: 'codex',
|
|
106
|
+
install: 'npm install -g @openai/codex',
|
|
107
|
+
login: 'codex login',
|
|
108
|
+
envKeys: ['CODEX_API_KEY'],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
section: 'OpenCode',
|
|
112
|
+
harnessId: 'opencode',
|
|
113
|
+
cli: 'opencode',
|
|
114
|
+
install: 'npm install -g opencode-ai',
|
|
115
|
+
login: 'opencode auth login',
|
|
116
|
+
envKeys: ['OPENCODE_API_KEY'],
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
/** One optional `<cli> installed` row per harness, with the CLI version when found and the install command when not (#2555). */
|
|
120
|
+
export function harnessInstallChecks(commandAvailable, tryExec) {
|
|
121
|
+
return HARNESS_SETUP.map(({ section, cli, install }) => {
|
|
122
|
+
const name = `${cli} installed`;
|
|
123
|
+
if (!commandAvailable(cli)) {
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
section,
|
|
127
|
+
ok: false,
|
|
128
|
+
optional: true,
|
|
129
|
+
detail: `${cli} not found on PATH, ${cli} routes unavailable`,
|
|
130
|
+
fix: `install ${section}: ${install}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const version = tryExec(`${cli} --version`)?.trim().split('\n')[0]?.trim();
|
|
134
|
+
return {
|
|
135
|
+
name,
|
|
136
|
+
section,
|
|
137
|
+
ok: true,
|
|
138
|
+
optional: true,
|
|
139
|
+
detail: version ? `found on PATH (${version})` : 'found on PATH (version unknown)',
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const SIGN_IN_EVIDENCE = {
|
|
144
|
+
claude: 'no ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN or stored login',
|
|
145
|
+
codex: 'no CODEX_API_KEY or codex login; API billing: codex login --with-api-key; headless: codex login --device-auth',
|
|
146
|
+
opencode: 'no OPENCODE_API_KEY or stored auth login',
|
|
147
|
+
};
|
|
148
|
+
function safe(fn) {
|
|
149
|
+
if (!fn)
|
|
150
|
+
return false;
|
|
151
|
+
try {
|
|
152
|
+
return fn();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** One `<cli> signed in` row per installed harness CLI (#2556, #2631), judged only from local evidence: an env key,
|
|
159
|
+
* stored credentials, or `codex login status`. Never runs inference and never prints a secret. */
|
|
160
|
+
export function harnessSignInChecks(probes) {
|
|
161
|
+
const { commandAvailable, envPresent, tryExec } = probes;
|
|
162
|
+
const found = availableAgentClis(commandAvailable);
|
|
163
|
+
return HARNESS_SETUP.filter(({ harnessId }) => isHarnessCliPresent(harnessId, commandAvailable)).map(({ section, cli, login, envKeys }) => {
|
|
164
|
+
const name = `${cli} signed in`;
|
|
165
|
+
const fix = `sign in to ${section}: ${login}`;
|
|
166
|
+
if (cli === 'codex') {
|
|
167
|
+
const key = envKeys.find((k) => envPresent(k));
|
|
168
|
+
if (key)
|
|
169
|
+
return { name, section, ok: true, detail: `signed in (${key} set)` };
|
|
170
|
+
const method = codexAuthMethod(tryExec('codex login status 2>&1'));
|
|
171
|
+
const session = method === 'ChatGPT' ? (probes.codexSessionState?.() ?? 'unknown') : 'unknown';
|
|
172
|
+
if (session === 'unusable') {
|
|
173
|
+
return {
|
|
174
|
+
name,
|
|
175
|
+
section,
|
|
176
|
+
ok: false,
|
|
177
|
+
optional: found.length > 1,
|
|
178
|
+
detail: 'ChatGPT session in auth.json is unusable (unreadable, or no valid access token and no refresh token)',
|
|
179
|
+
fix,
|
|
180
|
+
next: login,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (session === 'refresh-pending') {
|
|
184
|
+
return {
|
|
185
|
+
name,
|
|
186
|
+
section,
|
|
187
|
+
ok: true,
|
|
188
|
+
detail: 'logged in with ChatGPT; access token expired, codex refreshes it on the next run (run `codex login` if that run fails with 401)',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (method) {
|
|
192
|
+
return {
|
|
193
|
+
name,
|
|
194
|
+
section,
|
|
195
|
+
ok: true,
|
|
196
|
+
detail: method === 'ChatGPT'
|
|
197
|
+
? 'logged in with ChatGPT; runs draw on your ChatGPT plan limits (manage usage in ChatGPT settings)'
|
|
198
|
+
: `logged in with ${method}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const key = envKeys.find((k) => envPresent(k));
|
|
204
|
+
if (key)
|
|
205
|
+
return { name, section, ok: true, detail: `signed in (${key} set)` };
|
|
206
|
+
if (cli === 'claude' && safe(probes.claudeLoggedIn)) {
|
|
207
|
+
return { name, section, ok: true, detail: 'signed in (stored Claude Code login found)' };
|
|
208
|
+
}
|
|
209
|
+
if (cli === 'opencode' && safe(probes.opencodeLoggedIn)) {
|
|
210
|
+
return { name, section, ok: true, detail: 'signed in (stored opencode auth login found)' };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
name,
|
|
215
|
+
section,
|
|
216
|
+
ok: false,
|
|
217
|
+
// Required only when this is the sole agent: then nothing else can run.
|
|
218
|
+
optional: found.length > 1,
|
|
219
|
+
detail: `unverified: no sign-in evidence (${SIGN_IN_EVIDENCE[cli]})`,
|
|
220
|
+
fix,
|
|
221
|
+
next: login,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function safeList(fn) {
|
|
226
|
+
if (!fn)
|
|
227
|
+
return [];
|
|
228
|
+
try {
|
|
229
|
+
return fn();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** The executable (first token) of a command template, or null when it cannot be parsed safely (#2691).
|
|
236
|
+
* Guards `isCommandAvailable`, which interpolates into `command -v` unescaped. */
|
|
237
|
+
export function commandExecutable(command) {
|
|
238
|
+
const tokens = command.trim().split(/\s+/);
|
|
239
|
+
while (tokens.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
|
|
240
|
+
tokens.shift();
|
|
241
|
+
let token = tokens[0];
|
|
242
|
+
if (!token)
|
|
243
|
+
return null;
|
|
244
|
+
const quoted = /^(['"])(.*)\1$/.exec(token);
|
|
245
|
+
if (quoted)
|
|
246
|
+
token = quoted[2];
|
|
247
|
+
return /^[\w./@+~-]+$/.test(token) ? token : null;
|
|
248
|
+
}
|
|
249
|
+
/** Declared, agentic command harnesses that at least one repo `models.registry` entry uses (#2691). */
|
|
250
|
+
export function repoCommandHarnesses(harnesses, registry) {
|
|
251
|
+
const used = new Set(Object.values(registry ?? {}).map((def) => def.harness));
|
|
252
|
+
return Object.entries(harnesses ?? {})
|
|
253
|
+
.filter(([id, decl]) => decl.agentic === true && used.has(id))
|
|
254
|
+
.map(([id, decl]) => ({ harness: id, command: decl.command, executable: commandExecutable(decl.command) }));
|
|
255
|
+
}
|
|
256
|
+
/** One optional `command harness <id>` row per configured command harness (#2691). */
|
|
257
|
+
export function commandHarnessChecks(harnesses, commandAvailable) {
|
|
258
|
+
return harnesses.map((h) => {
|
|
259
|
+
const name = `command harness ${h.harness}`;
|
|
260
|
+
if (h.executable !== null && commandAvailable(h.executable)) {
|
|
261
|
+
return {
|
|
262
|
+
name,
|
|
263
|
+
section: COMMAND_HARNESS_SECTION,
|
|
264
|
+
optional: true,
|
|
265
|
+
ok: true,
|
|
266
|
+
detail: `${h.executable} found on PATH (command: ${h.command})`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
name,
|
|
271
|
+
section: COMMAND_HARNESS_SECTION,
|
|
272
|
+
optional: true,
|
|
273
|
+
ok: false,
|
|
274
|
+
detail: `configured command \`${h.command}\`: ${h.executable === null ? 'cannot parse an executable from it' : `${h.executable} not found on PATH`}`,
|
|
275
|
+
fix: installFix(h),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function installFix(h) {
|
|
280
|
+
return `install ${h.executable ?? 'the agent CLI'} on PATH or fix "command" for harness ${h.harness} in the models config harnesses section`;
|
|
281
|
+
}
|
|
282
|
+
/** Coding agent prerequisites: at least one agent CLI is required; each CLI is then reported
|
|
283
|
+
* on its own optional line, with sign-in reported separately from installation (#1269, #2422, #2555, #2556).
|
|
284
|
+
* A repo-configured command harness whose executable is on PATH also counts as a coding agent (#2691). */
|
|
285
|
+
export function agentCliChecks(probes) {
|
|
286
|
+
const { commandAvailable, tryExec } = probes;
|
|
287
|
+
const found = availableAgentClis(commandAvailable);
|
|
288
|
+
const configured = safeList(probes.commandHarnesses);
|
|
289
|
+
const runnable = configured.filter((h) => h.executable !== null && commandAvailable(h.executable));
|
|
290
|
+
const agents = [...found, ...runnable.map((h) => `${h.harness} (command harness)`)];
|
|
291
|
+
const describe = (h) => `command harness ${h.harness} runs \`${h.command}\`, but ${h.executable ?? 'its executable'} is not on PATH`;
|
|
292
|
+
const missingRow = configured.length === 0
|
|
293
|
+
? {
|
|
294
|
+
name: 'coding agent',
|
|
295
|
+
ok: false,
|
|
296
|
+
detail: 'no coding agent CLI on PATH (claude, codex or opencode)',
|
|
297
|
+
fix: `install Claude Code (${CLAUDE_CODE_URL}) or the Codex CLI (${CODEX_CLI_URL})`,
|
|
298
|
+
next: HARNESS_SETUP[0].install,
|
|
299
|
+
}
|
|
300
|
+
: {
|
|
301
|
+
name: 'coding agent',
|
|
302
|
+
ok: false,
|
|
303
|
+
detail: `no coding agent CLI on PATH (claude, codex or opencode); ${configured.map(describe).join('; ')}`,
|
|
304
|
+
fix: installFix(configured[0]),
|
|
305
|
+
};
|
|
306
|
+
const checks = [
|
|
307
|
+
agents.length > 0 ? { name: 'coding agent', ok: true, detail: `found ${agents.join(', ')}` } : missingRow,
|
|
308
|
+
];
|
|
309
|
+
checks.push(...harnessInstallChecks(commandAvailable, tryExec));
|
|
310
|
+
checks.push(...commandHarnessChecks(configured, commandAvailable));
|
|
311
|
+
checks.push(...harnessSignInChecks(probes));
|
|
312
|
+
return checks;
|
|
313
|
+
}
|
|
21
314
|
export function runDoctorChecks(probes) {
|
|
22
315
|
const { commandAvailable, envPresent, tryExec, pathExists, distFreshness } = probes;
|
|
23
316
|
const checks = [];
|
|
@@ -28,37 +321,40 @@ export function runDoctorChecks(probes) {
|
|
|
28
321
|
name: 'git repository',
|
|
29
322
|
ok: false,
|
|
30
323
|
optional: true,
|
|
31
|
-
detail: 'not inside a git repository
|
|
324
|
+
detail: 'not inside a git repository; repo checks skipped',
|
|
32
325
|
fix: NOT_IN_REPO_HINT,
|
|
33
326
|
});
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
checks.push({ name: 'git identity', ok: true, detail: `${userName} <${userEmail}>` });
|
|
327
|
+
const repoSection = repoRoot === null ? {} : { section: REPO_SECTION };
|
|
328
|
+
if (repoRoot === null) {
|
|
329
|
+
checks.push({ name: 'git identity', ok: false, optional: true, detail: NOT_IN_REPO_SKIP_DETAIL });
|
|
38
330
|
}
|
|
39
331
|
else {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
ok:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
332
|
+
const userName = tryExec('git config user.name');
|
|
333
|
+
const userEmail = tryExec('git config user.email');
|
|
334
|
+
if (userName && userEmail) {
|
|
335
|
+
checks.push({ name: 'git identity', ok: true, detail: `${userName} <${userEmail}>`, ...repoSection });
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const missing = [!userName && 'user.name', !userEmail && 'user.email'].filter(Boolean).join(' and ');
|
|
339
|
+
checks.push({
|
|
340
|
+
name: 'git identity',
|
|
341
|
+
ok: false,
|
|
342
|
+
detail: `${missing} not set`,
|
|
343
|
+
next: !userName
|
|
344
|
+
? 'git config --global user.name "Your Name"'
|
|
345
|
+
: 'git config --global user.email you@example.com',
|
|
346
|
+
...repoSection,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
47
349
|
}
|
|
48
|
-
checks.push(
|
|
49
|
-
? { name: 'claude CLI', ok: true, detail: 'found on PATH' }
|
|
50
|
-
: {
|
|
51
|
-
name: 'claude CLI',
|
|
52
|
-
ok: false,
|
|
53
|
-
detail: 'not found on PATH',
|
|
54
|
-
fix: `install Claude Code first: ${CLAUDE_CODE_URL}`,
|
|
55
|
-
});
|
|
350
|
+
checks.push(...agentCliChecks(probes));
|
|
56
351
|
if (!commandAvailable('gh')) {
|
|
57
352
|
checks.push({
|
|
58
353
|
name: 'gh auth',
|
|
59
354
|
ok: false,
|
|
60
355
|
detail: 'gh CLI not found on PATH',
|
|
61
|
-
fix: 'install GitHub CLI
|
|
356
|
+
fix: 'install GitHub CLI: https://cli.github.com',
|
|
357
|
+
next: 'gh auth login',
|
|
62
358
|
});
|
|
63
359
|
}
|
|
64
360
|
else if (tryExec('gh auth status') === null) {
|
|
@@ -66,12 +362,13 @@ export function runDoctorChecks(probes) {
|
|
|
66
362
|
name: 'gh auth',
|
|
67
363
|
ok: false,
|
|
68
364
|
detail: 'gh is not authenticated',
|
|
69
|
-
|
|
365
|
+
next: 'gh auth login',
|
|
70
366
|
});
|
|
71
367
|
}
|
|
72
368
|
else {
|
|
73
369
|
checks.push({ name: 'gh auth', ok: true, detail: 'authenticated' });
|
|
74
370
|
}
|
|
371
|
+
checks.push({ ...gitAuthCheck(repoRoot, tryExec), ...repoSection });
|
|
75
372
|
if (envPresent('GITHUB_TOKEN')) {
|
|
76
373
|
checks.push({ name: 'GitHub token', ok: true, detail: 'GITHUB_TOKEN set' });
|
|
77
374
|
}
|
|
@@ -89,6 +386,7 @@ export function runDoctorChecks(probes) {
|
|
|
89
386
|
ok: false,
|
|
90
387
|
detail: 'no GITHUB_TOKEN, GH_TOKEN, or gh auth token',
|
|
91
388
|
fix: `create a token at ${GITHUB_TOKENS_URL} and export it`,
|
|
389
|
+
next: 'gh auth login',
|
|
92
390
|
});
|
|
93
391
|
}
|
|
94
392
|
}
|
|
@@ -105,11 +403,11 @@ export function runDoctorChecks(probes) {
|
|
|
105
403
|
name: 'git worktree clean',
|
|
106
404
|
ok: false,
|
|
107
405
|
optional: true,
|
|
108
|
-
detail:
|
|
406
|
+
detail: NOT_IN_REPO_SKIP_DETAIL,
|
|
109
407
|
});
|
|
110
408
|
}
|
|
111
409
|
else if (tryExec('git status --porcelain') === '') {
|
|
112
|
-
checks.push({ name: 'git worktree clean', ok: true, detail: 'clean' });
|
|
410
|
+
checks.push({ name: 'git worktree clean', ok: true, detail: 'clean', ...repoSection });
|
|
113
411
|
}
|
|
114
412
|
else {
|
|
115
413
|
checks.push({
|
|
@@ -118,6 +416,7 @@ export function runDoctorChecks(probes) {
|
|
|
118
416
|
optional: true,
|
|
119
417
|
detail: 'uncommitted changes present',
|
|
120
418
|
fix: 'commit or stash changes before running the factory',
|
|
419
|
+
...repoSection,
|
|
121
420
|
});
|
|
122
421
|
}
|
|
123
422
|
const sandboxRuntime = detectSandboxRuntime(probes.platform ?? process.platform, commandAvailable);
|
|
@@ -127,14 +426,14 @@ export function runDoctorChecks(probes) {
|
|
|
127
426
|
ok: true,
|
|
128
427
|
optional: true,
|
|
129
428
|
detail: sandboxRuntime === 'none'
|
|
130
|
-
? 'codex CLI found
|
|
429
|
+
? 'codex CLI found; no sandbox runtime, so codex runs with danger-full-access'
|
|
131
430
|
: 'codex CLI found (sandboxed builds available)',
|
|
132
431
|
}
|
|
133
432
|
: {
|
|
134
433
|
name: 'codex sandbox',
|
|
135
434
|
ok: false,
|
|
136
435
|
optional: true,
|
|
137
|
-
detail: 'codex CLI not found
|
|
436
|
+
detail: 'codex CLI not found; codex build routes unavailable',
|
|
138
437
|
fix: 'install the codex CLI, or rely on claude routes',
|
|
139
438
|
});
|
|
140
439
|
checks.push(sandboxRuntime !== 'none'
|
|
@@ -143,7 +442,7 @@ export function runDoctorChecks(probes) {
|
|
|
143
442
|
name: 'sandbox runtime',
|
|
144
443
|
ok: false,
|
|
145
444
|
optional: true,
|
|
146
|
-
detail: 'no sandbox-exec (macOS) or firejail (Linux) on PATH
|
|
445
|
+
detail: 'no sandbox-exec (macOS) or firejail (Linux) on PATH; agents will run uncontained',
|
|
147
446
|
fix: 'install firejail (Linux) or run on macOS; see README Safety',
|
|
148
447
|
});
|
|
149
448
|
if (repoRoot === null) {
|
|
@@ -151,19 +450,19 @@ export function runDoctorChecks(probes) {
|
|
|
151
450
|
name: '.factory initialized',
|
|
152
451
|
ok: false,
|
|
153
452
|
optional: true,
|
|
154
|
-
detail:
|
|
453
|
+
detail: NOT_IN_REPO_SKIP_DETAIL,
|
|
155
454
|
});
|
|
156
455
|
}
|
|
157
456
|
else if (pathExists(resolve(repoRoot, '.factory'))) {
|
|
158
|
-
checks.push({ name: '.factory initialized', ok: true,
|
|
457
|
+
checks.push({ name: '.factory initialized', ok: true, detail: '.factory/ present', ...repoSection });
|
|
159
458
|
}
|
|
160
459
|
else {
|
|
161
460
|
checks.push({
|
|
162
461
|
name: '.factory initialized',
|
|
163
462
|
ok: false,
|
|
164
|
-
optional: true,
|
|
165
463
|
detail: '.factory/ missing',
|
|
166
|
-
|
|
464
|
+
next: 'factory init',
|
|
465
|
+
...repoSection,
|
|
167
466
|
});
|
|
168
467
|
}
|
|
169
468
|
if (distFreshness) {
|
|
@@ -175,16 +474,32 @@ export function runDoctorChecks(probes) {
|
|
|
175
474
|
ok: false,
|
|
176
475
|
detail,
|
|
177
476
|
fix: "run 'npm run build' at the monorepo root",
|
|
477
|
+
next: 'npm run build',
|
|
178
478
|
});
|
|
179
479
|
}
|
|
180
480
|
return checks;
|
|
181
481
|
}
|
|
182
|
-
|
|
482
|
+
/** Row icon (#2550): ❌ only for a failure (counted in "N failed"), ➖ for a skipped row, ⚠️ for an optional warning. */
|
|
483
|
+
export function doctorCheckIcon(check) {
|
|
484
|
+
if (!check.ok && !check.optional)
|
|
485
|
+
return '❌';
|
|
486
|
+
if (/^skipped\b/.test(check.detail))
|
|
487
|
+
return '➖';
|
|
488
|
+
return check.ok ? '✅' : '⚠️';
|
|
489
|
+
}
|
|
490
|
+
/** Outside a repo the models hint is skipped because `factory models --doctor` needs a repo (#2472). */
|
|
491
|
+
export function formatDoctorChecks(checks, opts = {}) {
|
|
183
492
|
const lines = ['== factory doctor =='];
|
|
184
493
|
let passed = 0;
|
|
185
494
|
let failed = 0;
|
|
186
495
|
let warnings = 0;
|
|
187
|
-
|
|
496
|
+
const sections = [...new Set(checks.flatMap((c) => (c.section === undefined ? [] : [c.section])))];
|
|
497
|
+
const ordered = [
|
|
498
|
+
...checks.filter((c) => c.section === undefined),
|
|
499
|
+
...sections.flatMap((s) => checks.filter((c) => c.section === s)),
|
|
500
|
+
];
|
|
501
|
+
let currentSection;
|
|
502
|
+
for (const check of ordered) {
|
|
188
503
|
if (check.ok) {
|
|
189
504
|
passed++;
|
|
190
505
|
}
|
|
@@ -194,15 +509,24 @@ export function formatDoctorChecks(checks) {
|
|
|
194
509
|
else {
|
|
195
510
|
failed++;
|
|
196
511
|
}
|
|
197
|
-
|
|
198
|
-
|
|
512
|
+
if (check.section !== undefined && check.section !== currentSection) {
|
|
513
|
+
lines.push(`-- ${check.section} --`);
|
|
514
|
+
}
|
|
515
|
+
currentSection = check.section;
|
|
516
|
+
const icon = doctorCheckIcon(check);
|
|
517
|
+
lines.push(` ${icon} ${check.name} - ${check.detail}`);
|
|
199
518
|
if (!check.ok && check.fix) {
|
|
200
519
|
lines.push(` fix: ${check.fix}`);
|
|
201
520
|
}
|
|
521
|
+
if (!check.ok && check.next) {
|
|
522
|
+
lines.push(` next: ${check.next}`);
|
|
523
|
+
}
|
|
202
524
|
}
|
|
203
525
|
lines.push(`${passed} passed, ${failed} failed, ${warnings} warnings`);
|
|
204
526
|
lines.push('');
|
|
205
|
-
lines.push(
|
|
527
|
+
lines.push(opts.inRepo === false
|
|
528
|
+
? `Model reachability: ${NOT_IN_REPO_SKIP_DETAIL}`
|
|
529
|
+
: 'Model reachability: factory models --doctor');
|
|
206
530
|
return lines.join('\n');
|
|
207
531
|
}
|
|
208
532
|
export function doctorFailed(checks) {
|
|
@@ -230,15 +554,15 @@ export function claudeKeychainCheck(probe) {
|
|
|
230
554
|
return {
|
|
231
555
|
name: 'claude keychain (macOS)',
|
|
232
556
|
ok: false,
|
|
233
|
-
detail: `security find-generic-password -s '${service}'${profile} failed inside tmux (SecKeychainSearchCopyNext)
|
|
234
|
-
fix: 'run the factory from a context with login-keychain access
|
|
557
|
+
detail: `security find-generic-password -s '${service}'${profile} failed inside tmux (SecKeychainSearchCopyNext); Claude OAuth refresh will fail with local_auth`,
|
|
558
|
+
fix: 'run the factory from a context with login-keychain access, such as a LaunchAgent in the gui domain (launchctl bootstrap gui/$(id -u) …) or a login Terminal, not a raw tmux pane; see docs/runbooks/macos-keychain-launchagent.md',
|
|
235
559
|
};
|
|
236
560
|
}
|
|
237
561
|
return {
|
|
238
562
|
name: 'claude keychain (macOS)',
|
|
239
563
|
ok: false,
|
|
240
564
|
optional: true,
|
|
241
|
-
detail: `security find-generic-password -s '${service}'${profile} failed
|
|
565
|
+
detail: `security find-generic-password -s '${service}'${profile} failed; either never logged in, or this launch context cannot read the login keychain`,
|
|
242
566
|
fix: `run ${loginFix(probe)} and complete /login (or export ANTHROPIC_API_KEY); if already logged in, see docs/runbooks/macos-keychain-launchagent.md`,
|
|
243
567
|
};
|
|
244
568
|
}
|
|
@@ -248,7 +572,7 @@ export function keychainPreflightError(probe) {
|
|
|
248
572
|
if (probe.status !== 'unreadable' || !probe.inTmux)
|
|
249
573
|
return null;
|
|
250
574
|
const service = probe.service ?? DEFAULT_KEYCHAIN_SERVICE;
|
|
251
|
-
return `Claude Code credentials are not readable from this tmux session (security find-generic-password -s '${service}' failed / SecKeychainSearchCopyNext). OAuth refresh will fail and every claimed issue would spuriously park as local_auth. Launch the factory from a context with login-keychain access
|
|
575
|
+
return `Claude Code credentials are not readable from this tmux session (security find-generic-password -s '${service}' failed / SecKeychainSearchCopyNext). OAuth refresh will fail and every claimed issue would spuriously park as local_auth. Launch the factory from a context with login-keychain access, such as a LaunchAgent in the gui domain (launchctl bootstrap gui/$(id -u) …) or a login Terminal, or export ANTHROPIC_API_KEY. See docs/runbooks/macos-keychain-launchagent.md.`;
|
|
252
576
|
}
|
|
253
577
|
const HOST_AUTH_CHECK = 'claude auth (host)';
|
|
254
578
|
const SANDBOX_AUTH_CHECK = 'claude auth (sandboxed)';
|
|
@@ -277,7 +601,7 @@ export function sandboxClaudeAuthChecks(result) {
|
|
|
277
601
|
sandboxed = {
|
|
278
602
|
name: SANDBOX_AUTH_CHECK,
|
|
279
603
|
ok: false,
|
|
280
|
-
detail: `${result.sandboxDetail}
|
|
604
|
+
detail: `${result.sandboxDetail}; host claude auth is healthy, so the sandbox is blocking Claude credential refresh`,
|
|
281
605
|
fix: 'check the sandbox write allowlist (~/.claude.json*, $CLAUDE_CONFIG_DIR when set, ~/Library/Keychains); as a stopgap run with FACTORY_SANDBOX=0',
|
|
282
606
|
};
|
|
283
607
|
}
|
|
@@ -286,12 +610,27 @@ export function sandboxClaudeAuthChecks(result) {
|
|
|
286
610
|
name: SANDBOX_AUTH_CHECK,
|
|
287
611
|
ok: false,
|
|
288
612
|
optional: true,
|
|
289
|
-
detail: `${result.sandboxDetail}
|
|
613
|
+
detail: `${result.sandboxDetail}; host claude auth is not healthy either, so this is not attributable to the sandbox`,
|
|
290
614
|
fix: 'fix host claude auth first, then re-run `factory doctor`',
|
|
291
615
|
};
|
|
292
616
|
}
|
|
293
617
|
return [host, sandboxed];
|
|
294
618
|
}
|
|
619
|
+
const AGENT_AUTH_CHECKS = new Set([...HARNESS_SETUP.map(({ cli }) => `${cli} signed in`), HOST_AUTH_CHECK]);
|
|
620
|
+
/** #2496: doctor fails on agent sign-in only when no installed agent is signed in. A logged-out
|
|
621
|
+
* agent is a warning while another one is signed in, and a hard failure when none is. Skipped
|
|
622
|
+
* probes (optional passes, e.g. a `<cli> signed in` row for a CLI not on PATH) never count as signed in. */
|
|
623
|
+
export function reconcileAgentAuthChecks(checks) {
|
|
624
|
+
const auth = checks.filter((c) => AGENT_AUTH_CHECKS.has(c.name));
|
|
625
|
+
const anySignedIn = auth.some((c) => c.ok && c.optional !== true);
|
|
626
|
+
return checks.map((c) => {
|
|
627
|
+
if (!AGENT_AUTH_CHECKS.has(c.name) || c.ok)
|
|
628
|
+
return c;
|
|
629
|
+
return anySignedIn
|
|
630
|
+
? { ...c, optional: true, detail: `${c.detail} (another agent is signed in, so runs can still proceed)` }
|
|
631
|
+
: { ...c, optional: false };
|
|
632
|
+
});
|
|
633
|
+
}
|
|
295
634
|
export function leaseChecks(rows) {
|
|
296
635
|
if (rows.length === 0) {
|
|
297
636
|
return [{ name: 'port leases', ok: true, optional: true, detail: 'no active leases' }];
|
|
@@ -302,7 +641,7 @@ export function leaseChecks(rows) {
|
|
|
302
641
|
name: `port lease :${row.port}`,
|
|
303
642
|
ok: true,
|
|
304
643
|
optional: true,
|
|
305
|
-
detail: `${row.worktreeId}
|
|
644
|
+
detail: `${row.worktreeId}: port ${row.port}, pid ${row.pid}, live`,
|
|
306
645
|
};
|
|
307
646
|
}
|
|
308
647
|
const squatted = row.portSquatted ? '; port still in use by another process' : '';
|
|
@@ -310,7 +649,7 @@ export function leaseChecks(rows) {
|
|
|
310
649
|
name: `port lease :${row.port}`,
|
|
311
650
|
ok: false,
|
|
312
651
|
optional: true,
|
|
313
|
-
detail: `${row.worktreeId}
|
|
652
|
+
detail: `${row.worktreeId}: port ${row.port}, pid ${row.pid}, stale (${row.reason})${squatted}`,
|
|
314
653
|
fix: 'run `factory doctor --reconcile` to reclaim stale leases',
|
|
315
654
|
};
|
|
316
655
|
});
|
|
@@ -349,7 +688,7 @@ export function eventLogCheck(integrity) {
|
|
|
349
688
|
ok: false,
|
|
350
689
|
optional: true,
|
|
351
690
|
detail: `${integrity.unparseable} of ${integrity.total} lines unparseable (${pct}%)`,
|
|
352
|
-
fix: 'historical corruption from pre-lock parallel writes; appends are now serialized
|
|
691
|
+
fix: 'historical corruption from pre-lock parallel writes; appends are now serialized; metrics over this file carry this error bar',
|
|
353
692
|
};
|
|
354
693
|
}
|
|
355
694
|
export function orphanContainerChecks(names) {
|
|
@@ -380,7 +719,7 @@ export function formatContainerReconcileReport(reaped) {
|
|
|
380
719
|
return reaped
|
|
381
720
|
.map((r) => r.removed
|
|
382
721
|
? `reconcile: removed container ${r.name}`
|
|
383
|
-
: `reconcile: failed to remove container ${r.name}
|
|
722
|
+
: `reconcile: failed to remove container ${r.name}: ${r.detail}`)
|
|
384
723
|
.join('\n');
|
|
385
724
|
}
|
|
386
725
|
export function formatMicroVmReconcileReport(reaped) {
|
|
@@ -389,7 +728,7 @@ export function formatMicroVmReconcileReport(reaped) {
|
|
|
389
728
|
return reaped
|
|
390
729
|
.map((r) => r.removed
|
|
391
730
|
? `reconcile: removed sbx VM ${r.name} (sbx rm --force)`
|
|
392
|
-
: `reconcile: failed to remove sbx VM ${r.name}
|
|
731
|
+
: `reconcile: failed to remove sbx VM ${r.name}: ${r.detail}`)
|
|
393
732
|
.join('\n');
|
|
394
733
|
}
|
|
395
734
|
export function formatReconcileReport(reaped) {
|
|
@@ -416,7 +755,7 @@ export function formatClaimReconcileReport(released) {
|
|
|
416
755
|
return released
|
|
417
756
|
.map((c) => c.released
|
|
418
757
|
? `reconcile: released issue #${c.issue} back to factory:queued (lease expired at ${c.expiresAt})`
|
|
419
|
-
: `reconcile: failed to release issue #${c.issue} (lease expired at ${c.expiresAt})
|
|
758
|
+
: `reconcile: failed to release issue #${c.issue} (lease expired at ${c.expiresAt}): ${c.detail ?? 'unknown error'}`)
|
|
420
759
|
.join('\n');
|
|
421
760
|
}
|
|
422
761
|
const GREEN_PR_CHECK = 'green PRs awaiting merge';
|
|
@@ -430,7 +769,7 @@ export function unmergedGreenPrChecks(result) {
|
|
|
430
769
|
name: GREEN_PR_CHECK,
|
|
431
770
|
ok: false,
|
|
432
771
|
optional: true,
|
|
433
|
-
detail: `scan failed
|
|
772
|
+
detail: `scan failed: ${result.detail}`,
|
|
434
773
|
fix: 'check `gh auth status` and network access, then re-run `factory doctor`',
|
|
435
774
|
},
|
|
436
775
|
];
|
|
@@ -442,8 +781,8 @@ export function unmergedGreenPrChecks(result) {
|
|
|
442
781
|
name: `green PR #${row.number}`,
|
|
443
782
|
ok: false,
|
|
444
783
|
optional: true,
|
|
445
|
-
detail: `${row.issue === null ? 'owning issue unknown' : `issue #${row.issue}`}
|
|
446
|
-
fix: `review and merge #${row.number} (or close it)
|
|
784
|
+
detail: `${row.issue === null ? 'owning issue unknown' : `issue #${row.issue}`}: green and ready on ${row.branch}, open ${row.ageHours}h, not merged`,
|
|
785
|
+
fix: `review and merge #${row.number} (or close it); the factory never auto-merges an orphaned PR`,
|
|
447
786
|
}));
|
|
448
787
|
}
|
|
449
788
|
/** One doctor check per user-declared harness, from its `probe` result. */
|
|
@@ -456,7 +795,7 @@ export function declaredHarnessChecks(probes) {
|
|
|
456
795
|
return {
|
|
457
796
|
name,
|
|
458
797
|
ok: false,
|
|
459
|
-
detail: `unavailable
|
|
798
|
+
detail: `unavailable: probe \`${p.probe}\` failed: ${p.detail}`,
|
|
460
799
|
fix: 'install the agent CLI on PATH or fix the "probe" command in the models config harnesses section',
|
|
461
800
|
};
|
|
462
801
|
}
|
|
@@ -464,9 +803,107 @@ export function declaredHarnessChecks(probes) {
|
|
|
464
803
|
name,
|
|
465
804
|
ok: false,
|
|
466
805
|
optional: true,
|
|
467
|
-
detail: 'unverified
|
|
806
|
+
detail: 'unverified: no probe configured',
|
|
468
807
|
fix: 'add a "probe" command (exit 0 = reachable) to the harness in the models config',
|
|
469
808
|
};
|
|
470
809
|
});
|
|
471
810
|
}
|
|
811
|
+
const REGISTRY_CHECK = 'models.registry';
|
|
812
|
+
/** One failed check per models.registry entry whose harness is missing or unknown;
|
|
813
|
+
* a single ok row when every entry is valid; nothing when there is no registry. */
|
|
814
|
+
export function modelsRegistryChecks(configPath, registry, validHarnesses) {
|
|
815
|
+
const entries = Object.entries(registry ?? {});
|
|
816
|
+
if (entries.length === 0)
|
|
817
|
+
return [];
|
|
818
|
+
const failed = [];
|
|
819
|
+
for (const [id, def] of entries) {
|
|
820
|
+
const field = `models.registry.${id}.harness`;
|
|
821
|
+
const name = `${REGISTRY_CHECK} ${id}`;
|
|
822
|
+
if (def.harness === undefined || def.harness.trim() === '') {
|
|
823
|
+
failed.push({
|
|
824
|
+
name,
|
|
825
|
+
ok: false,
|
|
826
|
+
detail: `${configPath}: ${field} is missing`,
|
|
827
|
+
fix: `set "harness" to one of: ${validHarnesses.join(', ')}`,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
else if (!validHarnesses.includes(def.harness)) {
|
|
831
|
+
failed.push({
|
|
832
|
+
name,
|
|
833
|
+
ok: false,
|
|
834
|
+
detail: `${configPath}: ${field} '${def.harness}' is not a known harness (valid harnesses: ${validHarnesses.join(', ')})`,
|
|
835
|
+
fix: 'use a built-in harness id or declare it under "harnesses" in the models config',
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (failed.length > 0)
|
|
840
|
+
return failed;
|
|
841
|
+
const n = entries.length;
|
|
842
|
+
return [{ name: REGISTRY_CHECK, ok: true, detail: `${n} entr${n === 1 ? 'y' : 'ies'} valid (${configPath})` }];
|
|
843
|
+
}
|
|
844
|
+
/** `config` rows from the shared pre-PLAN lint (#2612): one failing row per finding, so doctor fails exactly when
|
|
845
|
+
* the run preflight would escalate or park. A single ok row when clean. Read-only. */
|
|
846
|
+
export function configLintChecks(input, loadError) {
|
|
847
|
+
const schemaRows = (input.configFile ? findConfigSchemaErrors(input.configFile) : []).map((f) => ({
|
|
848
|
+
name: 'config',
|
|
849
|
+
ok: false,
|
|
850
|
+
section: REPO_SECTION,
|
|
851
|
+
detail: `${f.file}${f.line ? `:${f.line}` : ''}: schema error at '${f.path}': ${f.message}`,
|
|
852
|
+
fix: f.fix,
|
|
853
|
+
}));
|
|
854
|
+
if (loadError !== undefined) {
|
|
855
|
+
return [
|
|
856
|
+
{
|
|
857
|
+
name: 'config',
|
|
858
|
+
ok: false,
|
|
859
|
+
section: REPO_SECTION,
|
|
860
|
+
detail: loadError,
|
|
861
|
+
fix: 'fix the constitution frontmatter YAML',
|
|
862
|
+
},
|
|
863
|
+
...schemaRows,
|
|
864
|
+
];
|
|
865
|
+
}
|
|
866
|
+
const { findings } = lintFactoryConfig(input);
|
|
867
|
+
if (findings.length > 0) {
|
|
868
|
+
const names = [...listBuiltInCheckers().map((c) => c.name), 'custom_*'].join(', ');
|
|
869
|
+
const lintRows = findings.map((f) => {
|
|
870
|
+
const where = `${f.file}${f.line ? `:${f.line}` : ''}`;
|
|
871
|
+
if (f.rule === 'unknown-checker') {
|
|
872
|
+
const kind = isTemplateToken(f.token) ? 'placeholder' : 'unknown';
|
|
873
|
+
return {
|
|
874
|
+
name: 'config',
|
|
875
|
+
ok: false,
|
|
876
|
+
section: REPO_SECTION,
|
|
877
|
+
detail: `${where}: ${kind} checker '${f.token}'`,
|
|
878
|
+
fix: `replace '${f.token}' with a real checker name (${names}) or delete the line`,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
name: 'config',
|
|
883
|
+
ok: false,
|
|
884
|
+
section: REPO_SECTION,
|
|
885
|
+
detail: `${where}: unfilled template token '${f.token}'`,
|
|
886
|
+
fix: `replace '${f.token}' with a real value`,
|
|
887
|
+
};
|
|
888
|
+
});
|
|
889
|
+
return [...lintRows, ...schemaRows];
|
|
890
|
+
}
|
|
891
|
+
if (schemaRows.length > 0)
|
|
892
|
+
return schemaRows;
|
|
893
|
+
const scanned = [];
|
|
894
|
+
if (input.constitution?.repoInstance)
|
|
895
|
+
scanned.push(input.constitution.repoInstance.relPath);
|
|
896
|
+
if (input.configFile && existsSync(input.configFile.absPath))
|
|
897
|
+
scanned.push(input.configFile.relPath);
|
|
898
|
+
return [
|
|
899
|
+
{
|
|
900
|
+
name: 'config',
|
|
901
|
+
ok: true,
|
|
902
|
+
section: REPO_SECTION,
|
|
903
|
+
detail: scanned.length > 0
|
|
904
|
+
? `no unfilled placeholders, unknown checkers or schema errors in ${scanned.join(', ')}`
|
|
905
|
+
: 'no constitution or config file to scan',
|
|
906
|
+
},
|
|
907
|
+
];
|
|
908
|
+
}
|
|
472
909
|
//# sourceMappingURL=doctor.js.map
|