canary-test-cli 5.10.1 → 5.12.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/bin/canary +0 -0
- package/bin/canary.js +12 -10
- package/dist/doctor-manifest.js +99 -46
- package/dist/doctor.js +110 -29
- package/dist/engine-checks.js +281 -62
- package/dist/overlay-commands.js +155 -42
- package/dist/overlay-conflicts.js +121 -0
- package/dist/overlay-lint.js +211 -0
- package/dist/overlays-registry.js +23 -12
- package/dist/router.js +15 -14
- package/dist/skill-requirements.js +129 -0
- package/dist/source-spec.js +19 -11
- package/package.json +1 -1
- package/scripts/install.js +49 -32
package/bin/canary
CHANGED
|
Binary file
|
package/bin/canary.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
'use strict';
|
|
3
3
|
|
|
4
|
-
const { execFileSync } = require(
|
|
5
|
-
const path = require(
|
|
6
|
-
const fs = require(
|
|
7
|
-
const { isTsCommand, route } = require(
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const { isTsCommand, route } = require('../dist/router.js');
|
|
8
8
|
|
|
9
9
|
function getBinaryPath(platform) {
|
|
10
|
-
const name = platform ===
|
|
10
|
+
const name = platform === 'win32' ? 'canary.exe' : 'canary';
|
|
11
11
|
return path.join(__dirname, name);
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -23,18 +23,18 @@ function forwardToBinary(
|
|
|
23
23
|
existsSync = fs.existsSync,
|
|
24
24
|
platform = process.platform,
|
|
25
25
|
stderr = process.stderr,
|
|
26
|
-
} = {}
|
|
26
|
+
} = {},
|
|
27
27
|
) {
|
|
28
28
|
const binaryPath = getBinaryPath(platform);
|
|
29
29
|
if (!existsSync(binaryPath)) {
|
|
30
30
|
stderr.write(
|
|
31
31
|
`canary binary not found at ${binaryPath}.\n` +
|
|
32
|
-
`Try reinstalling: npm install -g canary-test-cli\n
|
|
32
|
+
`Try reinstalling: npm install -g canary-test-cli\n`,
|
|
33
33
|
);
|
|
34
34
|
return 1;
|
|
35
35
|
}
|
|
36
36
|
try {
|
|
37
|
-
execFile(binaryPath, argv, { stdio:
|
|
37
|
+
execFile(binaryPath, argv, { stdio: 'inherit' });
|
|
38
38
|
return 0;
|
|
39
39
|
} catch (err) {
|
|
40
40
|
return err.status ?? 1;
|
|
@@ -55,7 +55,9 @@ function run(argv, deps = {}) {
|
|
|
55
55
|
|
|
56
56
|
function main() {
|
|
57
57
|
// `run` may return a number (sync commands) or a Promise<number> (doctor).
|
|
58
|
-
Promise.resolve(run(process.argv.slice(2))).then((code) =>
|
|
58
|
+
Promise.resolve(run(process.argv.slice(2))).then((code) =>
|
|
59
|
+
process.exit(code),
|
|
60
|
+
);
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
module.exports = { getBinaryPath, forwardToBinary, run };
|
package/dist/doctor-manifest.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
2
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
3
|
if (k2 === undefined) k2 = k;
|
|
4
4
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -37,12 +37,13 @@ exports.DEFAULT_CHECK_TIMEOUT_MS = exports.CHECK_TYPES = void 0;
|
|
|
37
37
|
exports.manifestPath = manifestPath;
|
|
38
38
|
exports.commandSucceedsHash = commandSucceedsHash;
|
|
39
39
|
exports.loadManifest = loadManifest;
|
|
40
|
-
exports.
|
|
40
|
+
exports.collectAudiences = collectAudiences;
|
|
41
|
+
exports.filterByAudience = filterByAudience;
|
|
41
42
|
exports.runCheck = runCheck;
|
|
42
43
|
/**
|
|
43
44
|
* Overlay check manifest — `<clone>/.canary/doctor.json` (Phase 2, tier 2).
|
|
44
45
|
*
|
|
45
|
-
* This module loads and validates the manifest and filters checks by
|
|
46
|
+
* This module loads and validates the manifest and filters checks by audience.
|
|
46
47
|
* Execution of the individual check types lives in the runner (added in a
|
|
47
48
|
* later task). A malformed manifest never throws: {@link loadManifest} returns
|
|
48
49
|
* a single failing {@link CheckResult} so `doctor` can report it and carry on.
|
|
@@ -53,10 +54,14 @@ const http = __importStar(require("node:http"));
|
|
|
53
54
|
const https = __importStar(require("node:https"));
|
|
54
55
|
const path = __importStar(require("node:path"));
|
|
55
56
|
const node_child_process_1 = require("node:child_process");
|
|
56
|
-
exports.CHECK_TYPES = [
|
|
57
|
+
exports.CHECK_TYPES = [
|
|
58
|
+
'file-exists',
|
|
59
|
+
'url-reachable',
|
|
60
|
+
'command-succeeds',
|
|
61
|
+
];
|
|
57
62
|
/** Path to an overlay's manifest. */
|
|
58
63
|
function manifestPath(cloneDir) {
|
|
59
|
-
return path.join(cloneDir,
|
|
64
|
+
return path.join(cloneDir, '.canary', 'doctor.json');
|
|
60
65
|
}
|
|
61
66
|
/**
|
|
62
67
|
* Stable fingerprint of a manifest's `command-succeeds` checks — the (id,
|
|
@@ -65,53 +70,60 @@ function manifestPath(cloneDir) {
|
|
|
65
70
|
*/
|
|
66
71
|
function commandSucceedsHash(checks) {
|
|
67
72
|
const cmds = checks
|
|
68
|
-
.filter((c) => c.type ===
|
|
73
|
+
.filter((c) => c.type === 'command-succeeds')
|
|
69
74
|
.map((c) => ({ id: c.id, command: c.command ?? [] }))
|
|
70
75
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
71
76
|
if (cmds.length === 0) {
|
|
72
77
|
return null;
|
|
73
78
|
}
|
|
74
|
-
return (0, node_crypto_1.createHash)(
|
|
79
|
+
return (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(cmds)).digest('hex');
|
|
75
80
|
}
|
|
76
81
|
/** True when `v` is a non-empty array of strings. */
|
|
77
82
|
function isStringArray(v) {
|
|
78
|
-
return Array.isArray(v) && v.every((a) => typeof a ===
|
|
83
|
+
return Array.isArray(v) && v.every((a) => typeof a === 'string');
|
|
79
84
|
}
|
|
80
85
|
/**
|
|
81
86
|
* Validate the type-specific field of a check. Returns an error string, or
|
|
82
87
|
* null when the field is well-formed for the given type.
|
|
83
88
|
*/
|
|
84
89
|
function validateTypeField(type, c) {
|
|
85
|
-
if (type ===
|
|
90
|
+
if (type === 'file-exists' && typeof c.path !== 'string') {
|
|
86
91
|
return `check "${c.id}" (file-exists) is missing a string "path"`;
|
|
87
92
|
}
|
|
88
|
-
if (type ===
|
|
93
|
+
if (type === 'url-reachable' && typeof c.url !== 'string') {
|
|
89
94
|
return `check "${c.id}" (url-reachable) is missing a string "url"`;
|
|
90
95
|
}
|
|
91
|
-
if (type ===
|
|
96
|
+
if (type === 'command-succeeds' &&
|
|
97
|
+
!(isStringArray(c.command) && c.command.length > 0)) {
|
|
92
98
|
return `check "${c.id}" (command-succeeds) needs a non-empty string[] "command"`;
|
|
93
99
|
}
|
|
94
100
|
return null;
|
|
95
101
|
}
|
|
96
|
-
/**
|
|
102
|
+
/** The audience tags on a raw check: `audience:` (canonical) or `persona:` (legacy). */
|
|
103
|
+
function rawAudience(c) {
|
|
104
|
+
return c.audience !== undefined ? c.audience : c.persona;
|
|
105
|
+
}
|
|
106
|
+
/** Validate the id/type/remedy/audience fields common to every check type. */
|
|
97
107
|
function validateCommonFields(c, index) {
|
|
98
|
-
if (typeof c.id !==
|
|
108
|
+
if (typeof c.id !== 'string' || c.id === '') {
|
|
99
109
|
return `check[${index}] is missing a string "id"`;
|
|
100
110
|
}
|
|
101
|
-
if (typeof c.type !==
|
|
102
|
-
|
|
111
|
+
if (typeof c.type !== 'string' ||
|
|
112
|
+
!exports.CHECK_TYPES.includes(c.type)) {
|
|
113
|
+
return `check "${c.id}" has an unknown type (expected one of: ${exports.CHECK_TYPES.join(', ')})`;
|
|
103
114
|
}
|
|
104
|
-
if (typeof c.remedy !==
|
|
115
|
+
if (typeof c.remedy !== 'string' || c.remedy === '') {
|
|
105
116
|
return `check "${c.id}" is missing a string "remedy"`;
|
|
106
117
|
}
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
const audience = rawAudience(c);
|
|
119
|
+
if (audience !== undefined && !isStringArray(audience)) {
|
|
120
|
+
return `check "${c.id}" has a non-string audience list`;
|
|
109
121
|
}
|
|
110
122
|
return null;
|
|
111
123
|
}
|
|
112
124
|
/** Validate one raw check entry. Returns the typed check or an error string. */
|
|
113
125
|
function validateCheck(raw, index) {
|
|
114
|
-
if (typeof raw !==
|
|
126
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
115
127
|
return `check[${index}] is not an object`;
|
|
116
128
|
}
|
|
117
129
|
const c = raw;
|
|
@@ -128,7 +140,7 @@ function validateCheck(raw, index) {
|
|
|
128
140
|
id: c.id,
|
|
129
141
|
type,
|
|
130
142
|
remedy: c.remedy,
|
|
131
|
-
|
|
143
|
+
audience: rawAudience(c),
|
|
132
144
|
path: c.path,
|
|
133
145
|
url: c.url,
|
|
134
146
|
command: c.command,
|
|
@@ -141,7 +153,7 @@ function manifestFailure(cloneDir, detail) {
|
|
|
141
153
|
ok: false,
|
|
142
154
|
failure: {
|
|
143
155
|
id: `manifest:${file}`,
|
|
144
|
-
status:
|
|
156
|
+
status: 'fail',
|
|
145
157
|
label: `doctor.json is invalid (${file})`,
|
|
146
158
|
remedy: detail,
|
|
147
159
|
},
|
|
@@ -156,10 +168,10 @@ function loadManifest(cloneDir) {
|
|
|
156
168
|
const file = manifestPath(cloneDir);
|
|
157
169
|
let raw;
|
|
158
170
|
try {
|
|
159
|
-
raw = fs.readFileSync(file,
|
|
171
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
160
172
|
}
|
|
161
173
|
catch (e) {
|
|
162
|
-
if (e.code ===
|
|
174
|
+
if (e.code === 'ENOENT') {
|
|
163
175
|
return { ok: true, checks: [] };
|
|
164
176
|
}
|
|
165
177
|
return manifestFailure(cloneDir, e.message);
|
|
@@ -178,7 +190,7 @@ function loadManifest(cloneDir) {
|
|
|
178
190
|
const checks = [];
|
|
179
191
|
for (let i = 0; i < checksRaw.length; i += 1) {
|
|
180
192
|
const result = validateCheck(checksRaw[i], i);
|
|
181
|
-
if (typeof result ===
|
|
193
|
+
if (typeof result === 'string') {
|
|
182
194
|
return manifestFailure(cloneDir, result);
|
|
183
195
|
}
|
|
184
196
|
checks.push(result);
|
|
@@ -186,16 +198,39 @@ function loadManifest(cloneDir) {
|
|
|
186
198
|
return { ok: true, checks };
|
|
187
199
|
}
|
|
188
200
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* the
|
|
201
|
+
* The distinct audience tags declared across a set of checks, in first-seen
|
|
202
|
+
* order and de-duplicated case-insensitively (original casing preserved for
|
|
203
|
+
* display). This is the discoverable audience *vocabulary* — the engine ships
|
|
204
|
+
* none of its own, so it is derived entirely from overlay manifests. Used to
|
|
205
|
+
* tell a user which `--audience` values actually mean something (issue #294).
|
|
206
|
+
*/
|
|
207
|
+
function collectAudiences(checks) {
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const check of checks) {
|
|
211
|
+
for (const tag of check.audience ?? []) {
|
|
212
|
+
const key = tag.toLowerCase();
|
|
213
|
+
if (!seen.has(key)) {
|
|
214
|
+
seen.add(key);
|
|
215
|
+
out.push(tag);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Keep checks that should run for `audience`: a null audience runs everything;
|
|
223
|
+
* otherwise keep checks with no audience plus those whose audience list
|
|
224
|
+
* contains the tag (case-insensitive).
|
|
192
225
|
*/
|
|
193
|
-
function
|
|
194
|
-
if (
|
|
226
|
+
function filterByAudience(checks, audience) {
|
|
227
|
+
if (audience === null) {
|
|
195
228
|
return [...checks];
|
|
196
229
|
}
|
|
197
|
-
const want =
|
|
198
|
-
return checks.filter((c) => !c.
|
|
230
|
+
const want = audience.toLowerCase();
|
|
231
|
+
return checks.filter((c) => !c.audience ||
|
|
232
|
+
c.audience.length === 0 ||
|
|
233
|
+
c.audience.some((p) => p.toLowerCase() === want));
|
|
199
234
|
}
|
|
200
235
|
/** Default per-check timeout for url and command checks. */
|
|
201
236
|
exports.DEFAULT_CHECK_TIMEOUT_MS = 10000;
|
|
@@ -203,7 +238,7 @@ function defaultProbeUrl(url, timeoutMs) {
|
|
|
203
238
|
return new Promise((resolve) => {
|
|
204
239
|
let mod;
|
|
205
240
|
try {
|
|
206
|
-
mod = new URL(url).protocol ===
|
|
241
|
+
mod = new URL(url).protocol === 'http:' ? http : https;
|
|
207
242
|
}
|
|
208
243
|
catch {
|
|
209
244
|
resolve(false);
|
|
@@ -214,7 +249,7 @@ function defaultProbeUrl(url, timeoutMs) {
|
|
|
214
249
|
res.resume();
|
|
215
250
|
resolve(s >= 200 && s < 400);
|
|
216
251
|
});
|
|
217
|
-
req.on(
|
|
252
|
+
req.on('error', () => resolve(false));
|
|
218
253
|
req.setTimeout(timeoutMs, () => {
|
|
219
254
|
req.destroy();
|
|
220
255
|
resolve(false);
|
|
@@ -222,50 +257,68 @@ function defaultProbeUrl(url, timeoutMs) {
|
|
|
222
257
|
});
|
|
223
258
|
}
|
|
224
259
|
const defaultRunCommand = (command, cwd, timeoutMs) => {
|
|
225
|
-
const r = (0, node_child_process_1.spawnSync)(command[0], command.slice(1), {
|
|
260
|
+
const r = (0, node_child_process_1.spawnSync)(command[0], command.slice(1), {
|
|
261
|
+
cwd,
|
|
262
|
+
timeout: timeoutMs,
|
|
263
|
+
encoding: 'utf8',
|
|
264
|
+
});
|
|
226
265
|
if (r.error) {
|
|
227
266
|
const code = r.error.code;
|
|
228
|
-
return {
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
timedOut: code === 'ETIMEDOUT' || r.signal === 'SIGTERM',
|
|
270
|
+
detail: String(r.error.message),
|
|
271
|
+
};
|
|
229
272
|
}
|
|
230
|
-
if (r.signal ===
|
|
273
|
+
if (r.signal === 'SIGTERM') {
|
|
231
274
|
return { ok: false, timedOut: true };
|
|
232
275
|
}
|
|
233
|
-
return {
|
|
276
|
+
return {
|
|
277
|
+
ok: r.status === 0,
|
|
278
|
+
timedOut: false,
|
|
279
|
+
detail: r.status === 0 ? undefined : `exit ${r.status}`,
|
|
280
|
+
};
|
|
234
281
|
};
|
|
235
282
|
function pass(check, label) {
|
|
236
|
-
return { id: check.id, status:
|
|
283
|
+
return { id: check.id, status: 'pass', label };
|
|
237
284
|
}
|
|
238
285
|
function fail(check, label) {
|
|
239
|
-
return { id: check.id, status:
|
|
286
|
+
return { id: check.id, status: 'fail', label, remedy: check.remedy };
|
|
240
287
|
}
|
|
241
288
|
function runFileExists(check, cloneDir) {
|
|
242
|
-
const target = path.join(cloneDir, check.path ??
|
|
289
|
+
const target = path.join(cloneDir, check.path ?? '');
|
|
243
290
|
return fs.existsSync(target)
|
|
244
291
|
? pass(check, `${check.id}: ${check.path} exists`)
|
|
245
292
|
: fail(check, `${check.id}: ${check.path} is missing`);
|
|
246
293
|
}
|
|
247
294
|
async function runUrlReachable(check, ctx, timeoutMs) {
|
|
248
295
|
const probe = ctx.probeUrl ?? defaultProbeUrl;
|
|
249
|
-
const reachable = await probe(check.url ??
|
|
296
|
+
const reachable = await probe(check.url ?? '', timeoutMs);
|
|
250
297
|
return reachable
|
|
251
298
|
? pass(check, `${check.id}: ${check.url} reachable`)
|
|
252
299
|
: fail(check, `${check.id}: ${check.url} unreachable`);
|
|
253
300
|
}
|
|
254
301
|
function skipped(check, reason) {
|
|
255
|
-
return {
|
|
302
|
+
return {
|
|
303
|
+
id: check.id,
|
|
304
|
+
status: 'skip',
|
|
305
|
+
label: `${check.id}: skipped (${reason})`,
|
|
306
|
+
};
|
|
256
307
|
}
|
|
257
308
|
function runCommandSucceeds(check, ctx, timeoutMs) {
|
|
258
309
|
if (!ctx.consentGranted) {
|
|
259
310
|
return skipped(check, "command checks need consent — re-run 'canary overlay add'");
|
|
260
311
|
}
|
|
261
312
|
const command = check.command ?? [];
|
|
262
|
-
const cmd = `\`${command.join(
|
|
313
|
+
const cmd = `\`${command.join(' ')}\``;
|
|
263
314
|
const runner = ctx.runCommand ?? defaultRunCommand;
|
|
264
315
|
const r = runner(command, ctx.cloneDir, timeoutMs);
|
|
265
316
|
if (r.ok) {
|
|
266
317
|
return pass(check, `${check.id}: ${cmd} succeeded`);
|
|
267
318
|
}
|
|
268
|
-
const why = r.timedOut
|
|
319
|
+
const why = r.timedOut
|
|
320
|
+
? `timed out after ${timeoutMs}ms`
|
|
321
|
+
: (r.detail ?? 'failed');
|
|
269
322
|
return fail(check, `${check.id}: ${cmd} ${why}`);
|
|
270
323
|
}
|
|
271
324
|
/**
|
|
@@ -275,10 +328,10 @@ function runCommandSucceeds(check, ctx, timeoutMs) {
|
|
|
275
328
|
*/
|
|
276
329
|
async function runCheck(check, ctx) {
|
|
277
330
|
const timeoutMs = ctx.timeoutMs ?? exports.DEFAULT_CHECK_TIMEOUT_MS;
|
|
278
|
-
if (check.type ===
|
|
331
|
+
if (check.type === 'file-exists') {
|
|
279
332
|
return runFileExists(check, ctx.cloneDir);
|
|
280
333
|
}
|
|
281
|
-
if (check.type ===
|
|
334
|
+
if (check.type === 'url-reachable') {
|
|
282
335
|
return runUrlReachable(check, ctx, timeoutMs);
|
|
283
336
|
}
|
|
284
337
|
return runCommandSucceeds(check, ctx, timeoutMs);
|
package/dist/doctor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
2
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
3
|
if (k2 === undefined) k2 = k;
|
|
4
4
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
@@ -33,46 +33,97 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseJsonFlag = parseJsonFlag;
|
|
37
|
+
exports.unknownAudienceHint = unknownAudienceHint;
|
|
36
38
|
exports.runDoctor = runDoctor;
|
|
37
39
|
/**
|
|
38
40
|
* `canary doctor` — environment self-check (Phase 2).
|
|
39
41
|
*
|
|
40
42
|
* Runs two tiers of checks and prints each as pass/fail with a remedy line
|
|
41
|
-
* under every failure
|
|
42
|
-
*
|
|
43
|
-
*
|
|
43
|
+
* under every failure; the process exits non-zero when any check fails. Tier 1
|
|
44
|
+
* is built-in engine checks; tier 2 is data-driven checks read from each
|
|
45
|
+
* tracked overlay's `.canary/doctor.json`.
|
|
46
|
+
*
|
|
47
|
+
* Output shape (issue #318): the human text is loosely modeled on
|
|
48
|
+
* `harness doctor`, but the two are NOT a shared contract and have diverged —
|
|
49
|
+
* canary uses richer per-check fields (`id`/`label`/`remedy`) and a `skip`
|
|
50
|
+
* status tier that harness lacks. `--json` therefore emits a *canary-owned*
|
|
51
|
+
* machine contract, `{ version, checks, allPassed, warnings }`, documented in
|
|
52
|
+
* `JsonReport` below. Only the top-level `allPassed` boolean intentionally
|
|
53
|
+
* matches `harness doctor --json`; per-check fields are canary's own. Do not
|
|
54
|
+
* build a parser that assumes the two JSON shapes are interchangeable.
|
|
44
55
|
*/
|
|
45
56
|
const os = __importStar(require("node:os"));
|
|
46
57
|
const engine_checks_js_1 = require("./engine-checks.js");
|
|
47
58
|
const doctor_manifest_js_1 = require("./doctor-manifest.js");
|
|
48
59
|
const registry = __importStar(require("./overlays-registry.js"));
|
|
49
|
-
const SYMBOL = {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
const SYMBOL = {
|
|
61
|
+
pass: '✓',
|
|
62
|
+
fail: '✗',
|
|
63
|
+
skip: '-',
|
|
64
|
+
info: 'ℹ',
|
|
65
|
+
};
|
|
66
|
+
/** `--json` requests machine output on stdout instead of the human report. */
|
|
67
|
+
function parseJsonFlag(args) {
|
|
68
|
+
return args.includes('--json');
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `--audience <tag>` / `--audience=<tag>`, or null when unset (#319 B). The
|
|
72
|
+
* former name `--persona` is accepted as a legacy alias so existing invocations
|
|
73
|
+
* keep working; `--audience` is canonical. (Renamed to end the collision with
|
|
74
|
+
* harness's unrelated persona system.)
|
|
75
|
+
*/
|
|
76
|
+
function parseAudience(args) {
|
|
77
|
+
for (const flag of ['--audience', '--persona']) {
|
|
78
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
79
|
+
const a = args[i];
|
|
80
|
+
if (a === flag) {
|
|
81
|
+
return args[i + 1] ?? null;
|
|
82
|
+
}
|
|
83
|
+
if (a.startsWith(`${flag}=`)) {
|
|
84
|
+
return a.slice(flag.length + 1);
|
|
85
|
+
}
|
|
59
86
|
}
|
|
60
87
|
}
|
|
61
88
|
return null;
|
|
62
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Fail-loud hint for an unrecognized `--audience` value (issue #294). Returns
|
|
92
|
+
* null when there is nothing to say — no audience was passed, or the passed
|
|
93
|
+
* audience is part of the known vocabulary. Otherwise returns a one-line,
|
|
94
|
+
* actionable message: the engine ships no audience vocabulary, so this lists
|
|
95
|
+
* the tags overlays actually declared (or says none are defined) instead of
|
|
96
|
+
* silently running only the audience-less checks and leaving the user to
|
|
97
|
+
* guess why their filter matched nothing.
|
|
98
|
+
*/
|
|
99
|
+
function unknownAudienceHint(audience, known) {
|
|
100
|
+
if (audience === null) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const want = audience.toLowerCase();
|
|
104
|
+
if (known.some((p) => p.toLowerCase() === want)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (known.length === 0) {
|
|
108
|
+
return `--audience '${audience}' matched no checks: no overlay defines any audiences, so every check already runs. Drop the flag.`;
|
|
109
|
+
}
|
|
110
|
+
return `--audience '${audience}' is not a known audience. Valid options: ${known.join(', ')}. (Omit --audience to run every check.)`;
|
|
111
|
+
}
|
|
63
112
|
function renderCheck(r) {
|
|
64
113
|
const line = ` ${SYMBOL[r.status]} ${r.label}\n`;
|
|
65
|
-
return r.status ===
|
|
114
|
+
return r.status === 'fail' && r.remedy
|
|
115
|
+
? `${line} → ${r.remedy}\n`
|
|
116
|
+
: line;
|
|
66
117
|
}
|
|
67
|
-
/** Load,
|
|
68
|
-
async function overlayResults(entry, deps,
|
|
118
|
+
/** Load, audience-filter, and run one overlay's manifest checks. */
|
|
119
|
+
async function overlayResults(entry, deps, audience) {
|
|
69
120
|
const header = `Overlay: ${entry.name}`;
|
|
70
121
|
const load = (0, doctor_manifest_js_1.loadManifest)(entry.path);
|
|
71
122
|
if (!load.ok) {
|
|
72
|
-
return { header, results: [load.failure] };
|
|
123
|
+
return { group: { header, results: [load.failure] }, loadedChecks: [] };
|
|
73
124
|
}
|
|
74
125
|
const granted = registry.consentGranted(entry, (0, doctor_manifest_js_1.commandSucceedsHash)(load.checks));
|
|
75
|
-
const checks = (0, doctor_manifest_js_1.
|
|
126
|
+
const checks = (0, doctor_manifest_js_1.filterByAudience)(load.checks, audience);
|
|
76
127
|
const ctx = {
|
|
77
128
|
cloneDir: entry.path,
|
|
78
129
|
consentGranted: granted,
|
|
@@ -84,7 +135,9 @@ async function overlayResults(entry, deps, persona) {
|
|
|
84
135
|
for (const check of checks) {
|
|
85
136
|
results.push(await (0, doctor_manifest_js_1.runCheck)(check, ctx));
|
|
86
137
|
}
|
|
87
|
-
|
|
138
|
+
// Return the full (pre-filter) check set so the caller can build the
|
|
139
|
+
// audience vocabulary for a fail-loud hint on an unknown --audience.
|
|
140
|
+
return { group: { header, results }, loadedChecks: load.checks };
|
|
88
141
|
}
|
|
89
142
|
/**
|
|
90
143
|
* Run `canary doctor`. Returns a process exit code: 0 when every check passed
|
|
@@ -93,7 +146,7 @@ async function overlayResults(entry, deps, persona) {
|
|
|
93
146
|
*/
|
|
94
147
|
async function runDoctor(args, deps = {}) {
|
|
95
148
|
const out = deps.out ?? process.stdout;
|
|
96
|
-
const
|
|
149
|
+
const audience = parseAudience(args);
|
|
97
150
|
const homeDir = deps.homeDir ?? os.homedir();
|
|
98
151
|
const engineDeps = {
|
|
99
152
|
git: deps.git,
|
|
@@ -103,7 +156,9 @@ async function runDoctor(args, deps = {}) {
|
|
|
103
156
|
getLatestVersion: deps.getLatestVersion,
|
|
104
157
|
timeoutMs: deps.timeoutMs,
|
|
105
158
|
};
|
|
106
|
-
const groups = [
|
|
159
|
+
const groups = [
|
|
160
|
+
{ header: 'Engine', results: await (0, engine_checks_js_1.runEngineChecks)(engineDeps) },
|
|
161
|
+
];
|
|
107
162
|
let reg;
|
|
108
163
|
try {
|
|
109
164
|
reg = registry.read(homeDir);
|
|
@@ -111,20 +166,46 @@ async function runDoctor(args, deps = {}) {
|
|
|
111
166
|
catch {
|
|
112
167
|
reg = registry.emptyRegistry();
|
|
113
168
|
}
|
|
169
|
+
const allChecks = [];
|
|
114
170
|
for (const entry of reg.overlays) {
|
|
115
|
-
|
|
171
|
+
const { group, loadedChecks } = await overlayResults(entry, deps, audience);
|
|
172
|
+
groups.push(group);
|
|
173
|
+
allChecks.push(...loadedChecks);
|
|
174
|
+
}
|
|
175
|
+
// Issue #294: if the user passed a --audience that no overlay declares,
|
|
176
|
+
// tell them the valid vocabulary instead of silently filtering to only
|
|
177
|
+
// the audience-less checks and leaving them to wonder why.
|
|
178
|
+
const audienceHint = unknownAudienceHint(audience, (0, doctor_manifest_js_1.collectAudiences)(allChecks));
|
|
179
|
+
const failures = groups.reduce((n, g) => n + g.results.filter((r) => r.status === 'fail').length, 0);
|
|
180
|
+
// Issue #318: `--json` emits the canary-owned machine contract instead of
|
|
181
|
+
// the human report — nothing else is written to stdout, so the whole stream
|
|
182
|
+
// parses as one JSON object.
|
|
183
|
+
if (parseJsonFlag(args)) {
|
|
184
|
+
const report = {
|
|
185
|
+
version: 1,
|
|
186
|
+
checks: groups.flatMap((g) => g.results.map((r) => ({
|
|
187
|
+
id: r.id,
|
|
188
|
+
status: r.status,
|
|
189
|
+
label: r.label,
|
|
190
|
+
...(r.remedy !== undefined ? { remedy: r.remedy } : {}),
|
|
191
|
+
group: g.header,
|
|
192
|
+
}))),
|
|
193
|
+
allPassed: failures === 0,
|
|
194
|
+
warnings: audienceHint ? [audienceHint] : [],
|
|
195
|
+
};
|
|
196
|
+
out.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
197
|
+
return failures === 0 ? 0 : 1;
|
|
198
|
+
}
|
|
199
|
+
out.write('canary doctor\n');
|
|
200
|
+
if (audienceHint) {
|
|
201
|
+
out.write(`\n! ${audienceHint}\n`);
|
|
116
202
|
}
|
|
117
|
-
out.write("canary doctor\n");
|
|
118
|
-
let failures = 0;
|
|
119
203
|
for (const group of groups) {
|
|
120
204
|
out.write(`\n${group.header}\n`);
|
|
121
205
|
for (const result of group.results) {
|
|
122
|
-
if (result.status === "fail") {
|
|
123
|
-
failures += 1;
|
|
124
|
-
}
|
|
125
206
|
out.write(renderCheck(result));
|
|
126
207
|
}
|
|
127
208
|
}
|
|
128
|
-
out.write(`\n${failures === 0 ?
|
|
209
|
+
out.write(`\n${failures === 0 ? 'All checks passed.' : `${failures} check(s) failed.`}\n`);
|
|
129
210
|
return failures === 0 ? 0 : 1;
|
|
130
211
|
}
|