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