canary-test-cli 5.11.0 → 5.13.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);
@@ -40,6 +40,8 @@ exports.checkGit = checkGit;
40
40
  exports.checkOverlays = checkOverlays;
41
41
  exports.checkProjectConfig = checkProjectConfig;
42
42
  exports.checkMcpConfig = checkMcpConfig;
43
+ exports.checkOverlayConflicts = checkOverlayConflicts;
44
+ exports.checkSkillRequirements = checkSkillRequirements;
43
45
  exports.runEngineChecks = runEngineChecks;
44
46
  /**
45
47
  * Built-in `canary doctor` engine checks (Phase 2, tier 1). Each returns a
@@ -51,23 +53,34 @@ const os = __importStar(require("node:os"));
51
53
  const path = __importStar(require("node:path"));
52
54
  const overlay_commands_js_1 = require("./overlay-commands.js");
53
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");
54
58
  /** The published npm package name (mirrors the install remedy in the shim). */
55
- const PKG = "canary-test-cli";
59
+ const PKG = 'canary-test-cli';
56
60
  const DEFAULT_TIMEOUT_MS = 5000;
57
61
  const realGit = (args, opts = {}) => {
58
- const { spawnSync } = require("node:child_process");
59
- 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' });
60
64
  if (r.error) {
61
65
  const code = r.error.code;
62
- 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
+ };
63
71
  }
64
- 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
+ };
65
77
  };
66
78
  /** Read this package's own version from its package.json (best effort). */
67
79
  function ownVersion() {
68
80
  try {
69
- const pkgPath = path.join(__dirname, "..", "package.json");
70
- 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);
71
84
  }
72
85
  catch {
73
86
  return null;
@@ -77,7 +90,7 @@ function ownVersion() {
77
90
  function parseRegistryVersion(rawBody) {
78
91
  try {
79
92
  const v = JSON.parse(rawBody)?.version;
80
- return typeof v === "string" ? v : null;
93
+ return typeof v === 'string' ? v : null;
81
94
  }
82
95
  catch {
83
96
  return null;
@@ -92,18 +105,18 @@ function fetchLatestVersion(timeoutMs) {
92
105
  resolve(null);
93
106
  return;
94
107
  }
95
- let body = "";
96
- res.on("data", (c) => (body += c));
97
- res.on("end", () => resolve(parseRegistryVersion(body)));
108
+ let body = '';
109
+ res.on('data', (c) => (body += c));
110
+ res.on('end', () => resolve(parseRegistryVersion(body)));
98
111
  });
99
- req.on("error", () => resolve(null));
112
+ req.on('error', () => resolve(null));
100
113
  req.setTimeout(timeoutMs, () => req.destroy());
101
114
  });
102
115
  }
103
116
  /** True when semver `a` is strictly older than `b` (numeric compare, no prerelease). */
104
117
  function isOlder(a, b) {
105
- const pa = a.split(".").map((n) => Number.parseInt(n, 10));
106
- 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));
107
120
  for (let i = 0; i < 3; i += 1) {
108
121
  const x = pa[i] ?? 0;
109
122
  const y = pb[i] ?? 0;
@@ -117,35 +130,52 @@ function isOlder(a, b) {
117
130
  async function checkVersion(deps = {}) {
118
131
  const current = deps.currentVersion ?? ownVersion();
119
132
  if (!current) {
120
- 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
+ };
121
138
  }
122
- const getLatest = deps.getLatestVersion ?? (() => fetchLatestVersion(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS));
139
+ const getLatest = deps.getLatestVersion ??
140
+ (() => fetchLatestVersion(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS));
123
141
  const latest = await getLatest();
124
142
  if (!latest) {
125
- 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
+ };
126
148
  }
127
149
  if (isOlder(current, latest)) {
128
150
  return {
129
- id: "engine:version",
130
- status: "fail",
151
+ id: 'engine:version',
152
+ status: 'fail',
131
153
  label: `CLI ${current} is behind latest ${latest}`,
132
154
  remedy: `Upgrade: npm install -g ${PKG}@latest`,
133
155
  };
134
156
  }
135
- 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
+ };
136
162
  }
137
163
  /** git present on PATH. */
138
164
  function checkGit(deps = {}) {
139
165
  const git = deps.git ?? realGit;
140
- const res = git(["--version"]);
166
+ const res = git(['--version']);
141
167
  if (res.status === 0) {
142
- 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
+ };
143
173
  }
144
174
  return {
145
- id: "engine:git",
146
- status: "fail",
147
- label: "git not found on PATH",
148
- 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.',
149
179
  };
150
180
  }
151
181
  /** Registered overlays present, fresh, and free of local modifications. */
@@ -159,48 +189,62 @@ function checkOverlays(deps = {}) {
159
189
  catch (e) {
160
190
  return [
161
191
  {
162
- id: "engine:overlays",
163
- status: "fail",
164
- label: "overlays registry unreadable",
192
+ id: 'engine:overlays',
193
+ status: 'fail',
194
+ label: 'overlays registry unreadable',
165
195
  remedy: e.message,
166
196
  },
167
197
  ];
168
198
  }
169
199
  if (reg.overlays.length === 0) {
170
- 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
+ ];
171
207
  }
172
208
  const results = [];
173
209
  for (const o of reg.overlays) {
174
210
  const fresh = (0, overlay_commands_js_1.freshness)(o.path, o, git);
175
- if (fresh.startsWith("missing")) {
211
+ if (fresh.startsWith('missing')) {
176
212
  results.push({
177
213
  id: `overlay:${o.name}:present`,
178
- status: "fail",
214
+ status: 'fail',
179
215
  label: `overlay "${o.name}": clone missing`,
180
216
  remedy: `Re-add it: canary overlay remove ${o.name} (if needed) then canary overlay add ${o.source}`,
181
217
  });
182
218
  continue;
183
219
  }
184
- if (fresh.includes("behind")) {
220
+ if (fresh.includes('behind')) {
185
221
  results.push({
186
222
  id: `overlay:${o.name}:fresh`,
187
- status: "fail",
223
+ status: 'fail',
188
224
  label: `overlay "${o.name}": ${fresh}`,
189
225
  remedy: `Update it: canary overlay update ${o.name}`,
190
226
  });
191
227
  }
192
228
  else {
193
- 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
+ });
194
234
  }
195
235
  const clean = (0, overlay_commands_js_1.workingTreeStatus)(o.path, git);
196
- if (clean === "clean") {
197
- 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
+ });
198
242
  }
199
243
  else {
200
244
  results.push({
201
245
  id: `overlay:${o.name}:clean`,
202
- status: "fail",
203
- 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'}`,
204
248
  remedy: `Commit/stash changes in ${o.path}, or canary overlay remove ${o.name} and re-add.`,
205
249
  });
206
250
  }
@@ -211,10 +255,12 @@ function checkOverlays(deps = {}) {
211
255
  function parseErrorOrNull(file) {
212
256
  let raw;
213
257
  try {
214
- raw = fs.readFileSync(file, "utf8");
258
+ raw = fs.readFileSync(file, 'utf8');
215
259
  }
216
260
  catch (e) {
217
- return e.code === "ENOENT" ? null : e.message;
261
+ return e.code === 'ENOENT'
262
+ ? null
263
+ : e.message;
218
264
  }
219
265
  try {
220
266
  JSON.parse(raw);
@@ -227,38 +273,52 @@ function parseErrorOrNull(file) {
227
273
  /** Project `.canary/` config files parse as JSON. */
228
274
  function checkProjectConfig(deps = {}) {
229
275
  const cwd = deps.cwd ?? process.cwd();
230
- const dir = path.join(cwd, ".canary");
276
+ const dir = path.join(cwd, '.canary');
231
277
  let names;
232
278
  try {
233
- 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));
234
282
  }
235
283
  catch {
236
- 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
+ };
237
289
  }
238
290
  if (names.length === 0) {
239
- 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
+ };
240
296
  }
241
297
  for (const name of names) {
242
298
  const err = parseErrorOrNull(path.join(dir, name));
243
299
  if (err) {
244
300
  return {
245
- id: "engine:project-config",
246
- status: "fail",
301
+ id: 'engine:project-config',
302
+ status: 'fail',
247
303
  label: `project .canary/${name} does not parse`,
248
304
  remedy: `Fix the JSON in .canary/${name}: ${err}`,
249
305
  };
250
306
  }
251
307
  }
252
- 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
+ };
253
313
  }
254
314
  /** Assert a single `.mcp.json` parses and each server entry is well-formed. */
255
315
  function inspectMcpFile(file) {
256
316
  let raw;
257
317
  try {
258
- raw = fs.readFileSync(file, "utf8");
318
+ raw = fs.readFileSync(file, 'utf8');
259
319
  }
260
320
  catch (e) {
261
- if (e.code === "ENOENT") {
321
+ if (e.code === 'ENOENT') {
262
322
  return { present: false };
263
323
  }
264
324
  return { present: true, error: e.message };
@@ -274,13 +334,16 @@ function inspectMcpFile(file) {
274
334
  if (servers === undefined) {
275
335
  return { present: true };
276
336
  }
277
- if (typeof servers !== "object" || servers === null) {
278
- 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' };
279
339
  }
280
340
  for (const [key, entry] of Object.entries(servers)) {
281
341
  const e = entry;
282
- if (typeof e.command !== "string" && typeof e.url !== "string") {
283
- 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
+ };
284
347
  }
285
348
  }
286
349
  return { present: true };
@@ -289,24 +352,176 @@ function inspectMcpFile(file) {
289
352
  function checkMcpConfig(deps = {}) {
290
353
  const cwd = deps.cwd ?? process.cwd();
291
354
  const homeDir = deps.homeDir ?? os.homedir();
292
- 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')];
293
356
  let anyPresent = false;
294
357
  for (const file of files) {
295
358
  const r = inspectMcpFile(file);
296
359
  anyPresent = anyPresent || r.present;
297
360
  if (r.error) {
298
361
  return {
299
- id: "engine:mcp",
300
- status: "fail",
362
+ id: 'engine:mcp',
363
+ status: 'fail',
301
364
  label: `MCP config ${file} is invalid`,
302
365
  remedy: `Fix ${file}: ${r.error}`,
303
366
  };
304
367
  }
305
368
  }
306
369
  if (!anyPresent) {
307
- 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
+ }
308
511
  }
309
- 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
+ };
310
525
  }
311
526
  /** Run every engine check, in display order. */
312
527
  async function runEngineChecks(deps = {}) {
@@ -314,6 +529,8 @@ async function runEngineChecks(deps = {}) {
314
529
  await checkVersion(deps),
315
530
  checkGit(deps),
316
531
  ...checkOverlays(deps),
532
+ checkOverlayConflicts(deps),
533
+ checkSkillRequirements(deps),
317
534
  checkProjectConfig(deps),
318
535
  checkMcpConfig(deps),
319
536
  ];