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.
@@ -1,4 +1,4 @@
1
- "use strict";
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,12 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseRegistryVersion = parseRegistryVersion;
36
37
  exports.isOlder = isOlder;
37
38
  exports.checkVersion = checkVersion;
38
39
  exports.checkGit = checkGit;
39
40
  exports.checkOverlays = checkOverlays;
40
41
  exports.checkProjectConfig = checkProjectConfig;
41
42
  exports.checkMcpConfig = checkMcpConfig;
43
+ exports.checkOverlayConflicts = checkOverlayConflicts;
44
+ exports.checkSkillRequirements = checkSkillRequirements;
42
45
  exports.runEngineChecks = runEngineChecks;
43
46
  /**
44
47
  * Built-in `canary doctor` engine checks (Phase 2, tier 1). Each returns a
@@ -50,32 +53,44 @@ const os = __importStar(require("node:os"));
50
53
  const path = __importStar(require("node:path"));
51
54
  const overlay_commands_js_1 = require("./overlay-commands.js");
52
55
  const registry = __importStar(require("./overlays-registry.js"));
56
+ const overlay_conflicts_js_1 = require("./overlay-conflicts.js");
57
+ const skill_requirements_js_1 = require("./skill-requirements.js");
53
58
  /** The published npm package name (mirrors the install remedy in the shim). */
54
- const PKG = "canary-test-cli";
59
+ const PKG = 'canary-test-cli';
55
60
  const DEFAULT_TIMEOUT_MS = 5000;
56
61
  const realGit = (args, opts = {}) => {
57
- const { spawnSync } = require("node:child_process");
58
- const r = spawnSync("git", args, { cwd: opts.cwd, encoding: "utf8" });
62
+ const { spawnSync } = require('node:child_process');
63
+ const r = spawnSync('git', args, { cwd: opts.cwd, encoding: 'utf8' });
59
64
  if (r.error) {
60
65
  const code = r.error.code;
61
- return { status: code === "ENOENT" ? 127 : 1, stdout: "", stderr: String(r.error.message) };
66
+ return {
67
+ status: code === 'ENOENT' ? 127 : 1,
68
+ stdout: '',
69
+ stderr: String(r.error.message),
70
+ };
62
71
  }
63
- return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
72
+ return {
73
+ status: r.status ?? 1,
74
+ stdout: r.stdout ?? '',
75
+ stderr: r.stderr ?? '',
76
+ };
64
77
  };
65
78
  /** Read this package's own version from its package.json (best effort). */
66
79
  function ownVersion() {
67
80
  try {
68
- const pkgPath = path.join(__dirname, "..", "package.json");
69
- return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version ?? null;
81
+ const pkgPath = path.join(__dirname, '..', 'package.json');
82
+ return (JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
83
+ .version ?? null);
70
84
  }
71
85
  catch {
72
86
  return null;
73
87
  }
74
88
  }
75
- /** Extract `version` from a registry dist-tag body; null on any parse error. */
76
- function parseRegistryVersion(body) {
89
+ /** String `version` from a registry body; null on parse error or non-string shape (SEC-DES-001: registry JSON is untrusted). */
90
+ function parseRegistryVersion(rawBody) {
77
91
  try {
78
- return JSON.parse(body).version ?? null;
92
+ const v = JSON.parse(rawBody)?.version;
93
+ return typeof v === 'string' ? v : null;
79
94
  }
80
95
  catch {
81
96
  return null;
@@ -90,18 +105,18 @@ function fetchLatestVersion(timeoutMs) {
90
105
  resolve(null);
91
106
  return;
92
107
  }
93
- let body = "";
94
- res.on("data", (c) => (body += c));
95
- res.on("end", () => resolve(parseRegistryVersion(body)));
108
+ let body = '';
109
+ res.on('data', (c) => (body += c));
110
+ res.on('end', () => resolve(parseRegistryVersion(body)));
96
111
  });
97
- req.on("error", () => resolve(null));
112
+ req.on('error', () => resolve(null));
98
113
  req.setTimeout(timeoutMs, () => req.destroy());
99
114
  });
100
115
  }
101
116
  /** True when semver `a` is strictly older than `b` (numeric compare, no prerelease). */
102
117
  function isOlder(a, b) {
103
- const pa = a.split(".").map((n) => Number.parseInt(n, 10));
104
- const pb = b.split(".").map((n) => Number.parseInt(n, 10));
118
+ const pa = a.split('.').map((n) => Number.parseInt(n, 10));
119
+ const pb = b.split('.').map((n) => Number.parseInt(n, 10));
105
120
  for (let i = 0; i < 3; i += 1) {
106
121
  const x = pa[i] ?? 0;
107
122
  const y = pb[i] ?? 0;
@@ -115,35 +130,52 @@ function isOlder(a, b) {
115
130
  async function checkVersion(deps = {}) {
116
131
  const current = deps.currentVersion ?? ownVersion();
117
132
  if (!current) {
118
- return { id: "engine:version", status: "info", label: "CLI version: unknown" };
133
+ return {
134
+ id: 'engine:version',
135
+ status: 'info',
136
+ label: 'CLI version: unknown',
137
+ };
119
138
  }
120
- const getLatest = deps.getLatestVersion ?? (() => fetchLatestVersion(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS));
139
+ const getLatest = deps.getLatestVersion ??
140
+ (() => fetchLatestVersion(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS));
121
141
  const latest = await getLatest();
122
142
  if (!latest) {
123
- return { id: "engine:version", status: "info", label: `CLI ${current} (could not check latest — offline?)` };
143
+ return {
144
+ id: 'engine:version',
145
+ status: 'info',
146
+ label: `CLI ${current} (could not check latest — offline?)`,
147
+ };
124
148
  }
125
149
  if (isOlder(current, latest)) {
126
150
  return {
127
- id: "engine:version",
128
- status: "fail",
151
+ id: 'engine:version',
152
+ status: 'fail',
129
153
  label: `CLI ${current} is behind latest ${latest}`,
130
154
  remedy: `Upgrade: npm install -g ${PKG}@latest`,
131
155
  };
132
156
  }
133
- return { id: "engine:version", status: "pass", label: `CLI ${current} (latest)` };
157
+ return {
158
+ id: 'engine:version',
159
+ status: 'pass',
160
+ label: `CLI ${current} (latest)`,
161
+ };
134
162
  }
135
163
  /** git present on PATH. */
136
164
  function checkGit(deps = {}) {
137
165
  const git = deps.git ?? realGit;
138
- const res = git(["--version"]);
166
+ const res = git(['--version']);
139
167
  if (res.status === 0) {
140
- return { id: "engine:git", status: "pass", label: `git present (${res.stdout.trim() || "ok"})` };
168
+ return {
169
+ id: 'engine:git',
170
+ status: 'pass',
171
+ label: `git present (${res.stdout.trim() || 'ok'})`,
172
+ };
141
173
  }
142
174
  return {
143
- id: "engine:git",
144
- status: "fail",
145
- label: "git not found on PATH",
146
- remedy: "Install git and ensure it is on your PATH — overlay add/update need it.",
175
+ id: 'engine:git',
176
+ status: 'fail',
177
+ label: 'git not found on PATH',
178
+ remedy: 'Install git and ensure it is on your PATH — overlay add/update need it.',
147
179
  };
148
180
  }
149
181
  /** Registered overlays present, fresh, and free of local modifications. */
@@ -157,48 +189,62 @@ function checkOverlays(deps = {}) {
157
189
  catch (e) {
158
190
  return [
159
191
  {
160
- id: "engine:overlays",
161
- status: "fail",
162
- label: "overlays registry unreadable",
192
+ id: 'engine:overlays',
193
+ status: 'fail',
194
+ label: 'overlays registry unreadable',
163
195
  remedy: e.message,
164
196
  },
165
197
  ];
166
198
  }
167
199
  if (reg.overlays.length === 0) {
168
- return [{ id: "engine:overlays", status: "info", label: "no overlays registered" }];
200
+ return [
201
+ {
202
+ id: 'engine:overlays',
203
+ status: 'info',
204
+ label: 'no overlays registered',
205
+ },
206
+ ];
169
207
  }
170
208
  const results = [];
171
209
  for (const o of reg.overlays) {
172
210
  const fresh = (0, overlay_commands_js_1.freshness)(o.path, o, git);
173
- if (fresh.startsWith("missing")) {
211
+ if (fresh.startsWith('missing')) {
174
212
  results.push({
175
213
  id: `overlay:${o.name}:present`,
176
- status: "fail",
214
+ status: 'fail',
177
215
  label: `overlay "${o.name}": clone missing`,
178
216
  remedy: `Re-add it: canary overlay remove ${o.name} (if needed) then canary overlay add ${o.source}`,
179
217
  });
180
218
  continue;
181
219
  }
182
- if (fresh.includes("behind")) {
220
+ if (fresh.includes('behind')) {
183
221
  results.push({
184
222
  id: `overlay:${o.name}:fresh`,
185
- status: "fail",
223
+ status: 'fail',
186
224
  label: `overlay "${o.name}": ${fresh}`,
187
225
  remedy: `Update it: canary overlay update ${o.name}`,
188
226
  });
189
227
  }
190
228
  else {
191
- results.push({ id: `overlay:${o.name}:fresh`, status: "pass", label: `overlay "${o.name}": ${fresh}` });
229
+ results.push({
230
+ id: `overlay:${o.name}:fresh`,
231
+ status: 'pass',
232
+ label: `overlay "${o.name}": ${fresh}`,
233
+ });
192
234
  }
193
235
  const clean = (0, overlay_commands_js_1.workingTreeStatus)(o.path, git);
194
- if (clean === "clean") {
195
- results.push({ id: `overlay:${o.name}:clean`, status: "pass", label: `overlay "${o.name}": no local changes` });
236
+ if (clean === 'clean') {
237
+ results.push({
238
+ id: `overlay:${o.name}:clean`,
239
+ status: 'pass',
240
+ label: `overlay "${o.name}": no local changes`,
241
+ });
196
242
  }
197
243
  else {
198
244
  results.push({
199
245
  id: `overlay:${o.name}:clean`,
200
- status: "fail",
201
- label: `overlay "${o.name}": ${clean === "dirty" ? "local modifications" : "git status unreadable"}`,
246
+ status: 'fail',
247
+ label: `overlay "${o.name}": ${clean === 'dirty' ? 'local modifications' : 'git status unreadable'}`,
202
248
  remedy: `Commit/stash changes in ${o.path}, or canary overlay remove ${o.name} and re-add.`,
203
249
  });
204
250
  }
@@ -209,10 +255,12 @@ function checkOverlays(deps = {}) {
209
255
  function parseErrorOrNull(file) {
210
256
  let raw;
211
257
  try {
212
- raw = fs.readFileSync(file, "utf8");
258
+ raw = fs.readFileSync(file, 'utf8');
213
259
  }
214
260
  catch (e) {
215
- return e.code === "ENOENT" ? null : e.message;
261
+ return e.code === 'ENOENT'
262
+ ? null
263
+ : e.message;
216
264
  }
217
265
  try {
218
266
  JSON.parse(raw);
@@ -225,38 +273,52 @@ function parseErrorOrNull(file) {
225
273
  /** Project `.canary/` config files parse as JSON. */
226
274
  function checkProjectConfig(deps = {}) {
227
275
  const cwd = deps.cwd ?? process.cwd();
228
- const dir = path.join(cwd, ".canary");
276
+ const dir = path.join(cwd, '.canary');
229
277
  let names;
230
278
  try {
231
- names = fs.readdirSync(dir).filter((n) => /^company(\.[\w-]+)?\.json$/.test(n));
279
+ names = fs
280
+ .readdirSync(dir)
281
+ .filter((n) => /^company(\.[\w-]+)?\.json$/.test(n));
232
282
  }
233
283
  catch {
234
- return { id: "engine:project-config", status: "skip", label: "no project .canary/ config" };
284
+ return {
285
+ id: 'engine:project-config',
286
+ status: 'skip',
287
+ label: 'no project .canary/ config',
288
+ };
235
289
  }
236
290
  if (names.length === 0) {
237
- return { id: "engine:project-config", status: "skip", label: "no project .canary/ config" };
291
+ return {
292
+ id: 'engine:project-config',
293
+ status: 'skip',
294
+ label: 'no project .canary/ config',
295
+ };
238
296
  }
239
297
  for (const name of names) {
240
298
  const err = parseErrorOrNull(path.join(dir, name));
241
299
  if (err) {
242
300
  return {
243
- id: "engine:project-config",
244
- status: "fail",
301
+ id: 'engine:project-config',
302
+ status: 'fail',
245
303
  label: `project .canary/${name} does not parse`,
246
304
  remedy: `Fix the JSON in .canary/${name}: ${err}`,
247
305
  };
248
306
  }
249
307
  }
250
- return { id: "engine:project-config", status: "pass", label: `project .canary/ config parses (${names.length} file(s))` };
308
+ return {
309
+ id: 'engine:project-config',
310
+ status: 'pass',
311
+ label: `project .canary/ config parses (${names.length} file(s))`,
312
+ };
251
313
  }
252
314
  /** Assert a single `.mcp.json` parses and each server entry is well-formed. */
253
315
  function inspectMcpFile(file) {
254
316
  let raw;
255
317
  try {
256
- raw = fs.readFileSync(file, "utf8");
318
+ raw = fs.readFileSync(file, 'utf8');
257
319
  }
258
320
  catch (e) {
259
- if (e.code === "ENOENT") {
321
+ if (e.code === 'ENOENT') {
260
322
  return { present: false };
261
323
  }
262
324
  return { present: true, error: e.message };
@@ -272,13 +334,16 @@ function inspectMcpFile(file) {
272
334
  if (servers === undefined) {
273
335
  return { present: true };
274
336
  }
275
- if (typeof servers !== "object" || servers === null) {
276
- return { present: true, error: "mcpServers is not an object" };
337
+ if (typeof servers !== 'object' || servers === null) {
338
+ return { present: true, error: 'mcpServers is not an object' };
277
339
  }
278
340
  for (const [key, entry] of Object.entries(servers)) {
279
341
  const e = entry;
280
- if (typeof e.command !== "string" && typeof e.url !== "string") {
281
- return { present: true, error: `server "${key}" has neither a command nor a url` };
342
+ if (typeof e.command !== 'string' && typeof e.url !== 'string') {
343
+ return {
344
+ present: true,
345
+ error: `server "${key}" has neither a command nor a url`,
346
+ };
282
347
  }
283
348
  }
284
349
  return { present: true };
@@ -287,24 +352,176 @@ function inspectMcpFile(file) {
287
352
  function checkMcpConfig(deps = {}) {
288
353
  const cwd = deps.cwd ?? process.cwd();
289
354
  const homeDir = deps.homeDir ?? os.homedir();
290
- const files = [path.join(cwd, ".mcp.json"), path.join(homeDir, ".mcp.json")];
355
+ const files = [path.join(cwd, '.mcp.json'), path.join(homeDir, '.mcp.json')];
291
356
  let anyPresent = false;
292
357
  for (const file of files) {
293
358
  const r = inspectMcpFile(file);
294
359
  anyPresent = anyPresent || r.present;
295
360
  if (r.error) {
296
361
  return {
297
- id: "engine:mcp",
298
- status: "fail",
362
+ id: 'engine:mcp',
363
+ status: 'fail',
299
364
  label: `MCP config ${file} is invalid`,
300
365
  remedy: `Fix ${file}: ${r.error}`,
301
366
  };
302
367
  }
303
368
  }
304
369
  if (!anyPresent) {
305
- return { id: "engine:mcp", status: "skip", label: "no .mcp.json found" };
370
+ return { id: 'engine:mcp', status: 'skip', label: 'no .mcp.json found' };
371
+ }
372
+ return { id: 'engine:mcp', status: 'pass', label: 'MCP config resolves' };
373
+ }
374
+ /**
375
+ * Skill-name collisions across registered overlays are resolved by a declared
376
+ * precedence (#333). A collision with no precedence winner is a `fail` — which
377
+ * definition wins is otherwise accidental (directory-name order). No overlays
378
+ * or no collisions → an informational/passing line, never a false alarm.
379
+ */
380
+ function checkOverlayConflicts(deps = {}) {
381
+ const homeDir = deps.homeDir ?? os.homedir();
382
+ let reg;
383
+ try {
384
+ reg = registry.read(homeDir);
385
+ }
386
+ catch {
387
+ // The dedicated engine:overlays check already reports an unreadable
388
+ // registry; don't double-fail here.
389
+ return {
390
+ id: 'engine:overlay-conflicts',
391
+ status: 'skip',
392
+ label: 'overlay skill conflicts: registry unreadable',
393
+ };
394
+ }
395
+ const conflicts = (0, overlay_conflicts_js_1.detectSkillConflicts)(reg);
396
+ const unresolved = conflicts.filter((c) => !c.resolved);
397
+ if (unresolved.length > 0) {
398
+ const names = unresolved.map((c) => c.skill).join(', ');
399
+ return {
400
+ id: 'engine:overlay-conflicts',
401
+ status: 'fail',
402
+ label: `overlay skill conflicts unresolved: ${names}`,
403
+ remedy: 'Two overlays ship these skill name(s) with equal precedence — the ' +
404
+ 'winner is accidental. Set a higher `precedence` on the overlay you ' +
405
+ 'want to win in ~/.canary/overlays.json, or run ' +
406
+ '`canary overlay list --conflicts` for details.',
407
+ };
408
+ }
409
+ if (conflicts.length > 0) {
410
+ return {
411
+ id: 'engine:overlay-conflicts',
412
+ status: 'pass',
413
+ label: `overlay skill conflicts: ${conflicts.length} resolved by precedence`,
414
+ };
415
+ }
416
+ return {
417
+ id: 'engine:overlay-conflicts',
418
+ status: 'pass',
419
+ label: 'no overlay skill conflicts',
420
+ };
421
+ }
422
+ /** Real command probe (#336): `<cmd> --version`, extract the first x.y[.z]. */
423
+ const realProbe = (command) => {
424
+ const { spawnSync } = require('node:child_process');
425
+ const r = spawnSync(command, ['--version'], {
426
+ encoding: 'utf8',
427
+ timeout: DEFAULT_TIMEOUT_MS,
428
+ });
429
+ // ENOENT (not on PATH) sets r.error; a non-zero exit does not — the command
430
+ // exists, it just may not print a parseable version.
431
+ if (r.error)
432
+ return { present: false, version: null };
433
+ const out = `${r.stdout ?? ''}${r.stderr ?? ''}`;
434
+ const m = out.match(/(\d+\.\d+(?:\.\d+)?)/);
435
+ return { present: true, version: m ? m[1] : null };
436
+ };
437
+ /** SKILL.md paths + source labels for skills installed in a consuming repo. */
438
+ function skillMdRoots(homeDir, cwd) {
439
+ const roots = [];
440
+ const overlays = path.join(homeDir, '.canary', 'overlays');
441
+ for (const ov of safeDirs(overlays)) {
442
+ roots.push([path.join(overlays, ov, '.canary', 'skills'), `overlay:${ov}`]);
443
+ }
444
+ roots.push([path.join(homeDir, '.canary', 'skills'), 'global']);
445
+ roots.push([path.join(cwd, '.canary', 'skills'), 'local']);
446
+ return roots;
447
+ }
448
+ /** Directory entries (names) under `dir`, or [] when it is absent/unreadable. */
449
+ function safeDirs(dir) {
450
+ try {
451
+ return fs
452
+ .readdirSync(dir, { withFileTypes: true })
453
+ .filter((d) => d.isDirectory())
454
+ .map((d) => d.name);
455
+ }
456
+ catch {
457
+ return [];
458
+ }
459
+ }
460
+ /** Collect `requires` declarations from every skill installed in this repo. */
461
+ function scanSkillRequirements(homeDir, cwd) {
462
+ const found = [];
463
+ for (const [skillsDir, source] of skillMdRoots(homeDir, cwd)) {
464
+ for (const name of safeDirs(skillsDir)) {
465
+ const md = path.join(skillsDir, name, 'SKILL.md');
466
+ let text;
467
+ try {
468
+ text = fs.readFileSync(md, 'utf8');
469
+ }
470
+ catch {
471
+ continue;
472
+ }
473
+ const requires = (0, skill_requirements_js_1.parseRequiresField)(text);
474
+ if (requires.length > 0)
475
+ found.push({ name, source, requires });
476
+ }
477
+ }
478
+ return found;
479
+ }
480
+ /**
481
+ * Verify the runtime requirements declared by installed skills (#336). Reads
482
+ * `requires:` from each overlay/global/local skill's SKILL.md and checks every
483
+ * declared command (and optional version) against the environment. A missing
484
+ * or too-old requirement is a `fail` naming the skill and command; a bare
485
+ * presence check that passes, or a token whose version cannot be read, never
486
+ * fails. No declarations at all → an informational line (not a false "all
487
+ * good"). Bundled skills ship inside the engine and are out of scope here.
488
+ */
489
+ function checkSkillRequirements(deps = {}) {
490
+ const homeDir = deps.homeDir ?? os.homedir();
491
+ const cwd = deps.cwd ?? process.cwd();
492
+ const probe = deps.skillProbe ?? realProbe;
493
+ const skills = scanSkillRequirements(homeDir, cwd);
494
+ if (skills.length === 0) {
495
+ return {
496
+ id: 'engine:skill-requirements',
497
+ status: 'info',
498
+ label: 'no installed skill declares runtime requirements',
499
+ };
500
+ }
501
+ const failures = [];
502
+ let checked = 0;
503
+ for (const skill of skills) {
504
+ for (const token of skill.requires) {
505
+ checked += 1;
506
+ const r = (0, skill_requirements_js_1.checkRequirement)((0, skill_requirements_js_1.parseRequirement)(token), probe);
507
+ if (r.status === 'missing' || r.status === 'too-old') {
508
+ failures.push(`${skill.source}/${skill.name} needs ${token}: ${r.detail}`);
509
+ }
510
+ }
306
511
  }
307
- return { id: "engine:mcp", status: "pass", label: "MCP config resolves" };
512
+ if (failures.length > 0) {
513
+ return {
514
+ id: 'engine:skill-requirements',
515
+ status: 'fail',
516
+ label: `skill runtime requirements unmet: ${failures.length} of ${checked}`,
517
+ remedy: `Install/upgrade the missing tools — ${failures.join('; ')}`,
518
+ };
519
+ }
520
+ return {
521
+ id: 'engine:skill-requirements',
522
+ status: 'pass',
523
+ label: `skill runtime requirements: ${checked} satisfied across ${skills.length} skill(s)`,
524
+ };
308
525
  }
309
526
  /** Run every engine check, in display order. */
310
527
  async function runEngineChecks(deps = {}) {
@@ -312,6 +529,8 @@ async function runEngineChecks(deps = {}) {
312
529
  await checkVersion(deps),
313
530
  checkGit(deps),
314
531
  ...checkOverlays(deps),
532
+ checkOverlayConflicts(deps),
533
+ checkSkillRequirements(deps),
315
534
  checkProjectConfig(deps),
316
535
  checkMcpConfig(deps),
317
536
  ];