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.
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.VALID_DEPLOY_TARGETS = void 0;
37
+ exports.lintOverlay = lintOverlay;
38
+ /**
39
+ * `canary overlay lint` — validate an overlay against the authoring contract
40
+ * (#332). Overlay quality otherwise depends on each author's discipline;
41
+ * downstream audits found frontmatter chaos, dead `cli:` paths, and invalid
42
+ * doctor manifests that nothing caught mechanically.
43
+ *
44
+ * Checks (per skill under `<overlay>/.canary/skills/<name>/SKILL.md`):
45
+ * 1. frontmatter floor — `name` and `description` present and non-empty
46
+ * (modeled on harness's `skill validate`);
47
+ * 2. `deploy_to` values resolve to known migration targets;
48
+ * 3. `cli:` script paths exist inside the skill dir (no escape);
49
+ * plus one overlay-level check:
50
+ * 4. `.canary/doctor.json` (if present) passes manifest validation — reuses
51
+ * `loadManifest` so the lint and `canary doctor` never disagree.
52
+ */
53
+ const fs = __importStar(require("node:fs"));
54
+ const path = __importStar(require("node:path"));
55
+ const doctor_manifest_js_1 = require("./doctor-manifest.js");
56
+ /** Migration target shapes a `deploy_to` entry may name, plus the `all` sentinel. */
57
+ exports.VALID_DEPLOY_TARGETS = new Set([
58
+ 'api',
59
+ 'e2e_ui',
60
+ 'frontend_unit',
61
+ 'load',
62
+ 'performance',
63
+ 'all',
64
+ ]);
65
+ /** Parse the tiny-YAML subset canary uses (mirrors the Python loader). */
66
+ function parseFrontmatter(md) {
67
+ const fm = {};
68
+ if (!md.startsWith('---'))
69
+ return fm;
70
+ for (const line of md.split('\n').slice(1)) {
71
+ if (line.trim() === '---')
72
+ break;
73
+ const idx = line.indexOf(':');
74
+ if (idx === -1)
75
+ continue;
76
+ const key = line.slice(0, idx).trim();
77
+ const value = line.slice(idx + 1).trim();
78
+ if (key === 'deploy_to') {
79
+ fm.deploy_to =
80
+ value.startsWith('[') && value.endsWith(']')
81
+ ? value
82
+ .slice(1, -1)
83
+ .split(',')
84
+ .map((s) => s.trim())
85
+ .filter(Boolean)
86
+ : value
87
+ ? [value]
88
+ : [];
89
+ }
90
+ else if (key === 'name' ||
91
+ key === 'description' ||
92
+ key === 'cli' ||
93
+ key === 'entry') {
94
+ fm[key] = value;
95
+ }
96
+ }
97
+ return fm;
98
+ }
99
+ /** True when `cli` resolves to a real file inside `skillDir` (no escape). */
100
+ function cliFinding(skill, skillDir, cli) {
101
+ const resolvedDir = path.resolve(skillDir);
102
+ const target = path.resolve(resolvedDir, cli);
103
+ if (target !== resolvedDir && !target.startsWith(resolvedDir + path.sep)) {
104
+ return {
105
+ skill,
106
+ level: 'error',
107
+ message: `cli: path "${cli}" escapes the skill directory`,
108
+ };
109
+ }
110
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
111
+ return {
112
+ skill,
113
+ level: 'error',
114
+ message: `cli: path "${cli}" is missing (no file at ${path.relative(skillDir, target)})`,
115
+ };
116
+ }
117
+ return null;
118
+ }
119
+ function lintSkill(name, skillDir) {
120
+ const findings = [];
121
+ const mdPath = path.join(skillDir, 'SKILL.md');
122
+ let text;
123
+ try {
124
+ text = fs.readFileSync(mdPath, 'utf8');
125
+ }
126
+ catch {
127
+ return [{ skill: name, level: 'error', message: 'SKILL.md is unreadable' }];
128
+ }
129
+ const fm = parseFrontmatter(text);
130
+ // 1. Frontmatter floor.
131
+ if (!fm.name) {
132
+ findings.push({
133
+ skill: name,
134
+ level: 'error',
135
+ message: 'frontmatter is missing `name`',
136
+ });
137
+ }
138
+ if (!fm.description) {
139
+ findings.push({
140
+ skill: name,
141
+ level: 'error',
142
+ message: 'frontmatter is missing a non-empty `description`',
143
+ });
144
+ }
145
+ // 2. deploy_to targets.
146
+ for (const target of fm.deploy_to ?? []) {
147
+ if (!exports.VALID_DEPLOY_TARGETS.has(target)) {
148
+ findings.push({
149
+ skill: name,
150
+ level: 'error',
151
+ message: `deploy_to value "${target}" is not a known target (${[...exports.VALID_DEPLOY_TARGETS].join(', ')})`,
152
+ });
153
+ }
154
+ }
155
+ // 3. cli path (entry is a module ref, not a filesystem path — not checked here).
156
+ if (fm.cli) {
157
+ const f = cliFinding(name, skillDir, fm.cli);
158
+ if (f)
159
+ findings.push(f);
160
+ }
161
+ return findings;
162
+ }
163
+ /**
164
+ * Lint an overlay clone at `overlayPath`. Returns every finding; the caller
165
+ * decides how to render/exit. Never throws on a malformed overlay — a missing
166
+ * skills dir is itself an error finding.
167
+ */
168
+ function lintOverlay(overlayPath) {
169
+ const findings = [];
170
+ const skillsDir = path.join(overlayPath, '.canary', 'skills');
171
+ let skillNames = [];
172
+ try {
173
+ skillNames = fs
174
+ .readdirSync(skillsDir, { withFileTypes: true })
175
+ .filter((d) => d.isDirectory())
176
+ .map((d) => d.name)
177
+ .sort();
178
+ }
179
+ catch {
180
+ findings.push({
181
+ skill: '(overlay)',
182
+ level: 'error',
183
+ message: `no .canary/skills directory at ${skillsDir}`,
184
+ });
185
+ }
186
+ for (const name of skillNames) {
187
+ const skillDir = path.join(skillsDir, name);
188
+ if (!fs.existsSync(path.join(skillDir, 'SKILL.md'))) {
189
+ findings.push({
190
+ skill: name,
191
+ level: 'warning',
192
+ message: 'directory has no SKILL.md (not a skill)',
193
+ });
194
+ continue;
195
+ }
196
+ findings.push(...lintSkill(name, skillDir));
197
+ }
198
+ // 4. Overlay-level doctor.json validation (reuse loadManifest).
199
+ const manifestPath = path.join(overlayPath, '.canary', 'doctor.json');
200
+ if (fs.existsSync(manifestPath)) {
201
+ const load = (0, doctor_manifest_js_1.loadManifest)(overlayPath);
202
+ if (!load.ok) {
203
+ findings.push({
204
+ skill: '(overlay)',
205
+ level: 'error',
206
+ message: `.canary/doctor.json is invalid: ${load.failure.remedy ?? load.failure.label}`,
207
+ });
208
+ }
209
+ }
210
+ return { overlay: overlayPath, skillsChecked: skillNames.length, findings };
211
+ }
@@ -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);
@@ -62,18 +62,18 @@ exports.SCHEMA_VERSION = 1;
62
62
  class RegistryError extends Error {
63
63
  constructor(message) {
64
64
  super(message);
65
- this.name = "RegistryError";
65
+ this.name = 'RegistryError';
66
66
  }
67
67
  }
68
68
  exports.RegistryError = RegistryError;
69
69
  function canaryHome(homeDir = os.homedir()) {
70
- return path.join(homeDir, ".canary");
70
+ return path.join(homeDir, '.canary');
71
71
  }
72
72
  function registryPath(homeDir = os.homedir()) {
73
- return path.join(canaryHome(homeDir), "overlays.json");
73
+ return path.join(canaryHome(homeDir), 'overlays.json');
74
74
  }
75
75
  function overlaysDir(homeDir = os.homedir()) {
76
- return path.join(canaryHome(homeDir), "overlays");
76
+ return path.join(canaryHome(homeDir), 'overlays');
77
77
  }
78
78
  function clonePath(name, homeDir = os.homedir()) {
79
79
  return path.join(overlaysDir(homeDir), name);
@@ -90,10 +90,10 @@ function emptyRegistry() {
90
90
  function parseRegistryFile(file) {
91
91
  let raw;
92
92
  try {
93
- raw = fs.readFileSync(file, "utf8");
93
+ raw = fs.readFileSync(file, 'utf8');
94
94
  }
95
95
  catch (err) {
96
- if (err.code === "ENOENT") {
96
+ if (err.code === 'ENOENT') {
97
97
  return emptyRegistry();
98
98
  }
99
99
  throw new RegistryError(`cannot read ${file}: ${err.message}`);
@@ -106,19 +106,28 @@ function parseRegistryFile(file) {
106
106
  throw new RegistryError(`malformed ${file}: ${err.message}`);
107
107
  }
108
108
  const overlays = parsed?.overlays;
109
- if (typeof parsed !== "object" || parsed === null || !Array.isArray(overlays)) {
109
+ if (typeof parsed !== 'object' ||
110
+ parsed === null ||
111
+ !Array.isArray(overlays)) {
110
112
  throw new RegistryError(`malformed ${file}: expected { schemaVersion, overlays: [] }`);
111
113
  }
112
114
  return parsed;
113
115
  }
114
116
  /** Normalize forward-added optional fields so callers never see `undefined`. */
115
117
  function normalizeEntry(o) {
116
- return { ...o, consent: o.consent ?? null, consentCommandsHash: o.consentCommandsHash ?? null };
118
+ return {
119
+ ...o,
120
+ consent: o.consent ?? null,
121
+ consentCommandsHash: o.consentCommandsHash ?? null,
122
+ precedence: typeof o.precedence === 'number' ? o.precedence : null,
123
+ };
117
124
  }
118
125
  function read(homeDir = os.homedir()) {
119
126
  const reg = parseRegistryFile(registryPath(homeDir));
120
127
  return {
121
- schemaVersion: typeof reg.schemaVersion === "number" ? reg.schemaVersion : exports.SCHEMA_VERSION,
128
+ schemaVersion: typeof reg.schemaVersion === 'number'
129
+ ? reg.schemaVersion
130
+ : exports.SCHEMA_VERSION,
122
131
  overlays: reg.overlays.map(normalizeEntry),
123
132
  };
124
133
  }
@@ -129,14 +138,16 @@ function read(homeDir = os.homedir()) {
129
138
  * mismatch) revokes consent until it is re-confirmed at `overlay add`.
130
139
  */
131
140
  function consentGranted(entry, liveHash) {
132
- return entry.consent === true && liveHash !== null && entry.consentCommandsHash === liveHash;
141
+ return (entry.consent === true &&
142
+ liveHash !== null &&
143
+ entry.consentCommandsHash === liveHash);
133
144
  }
134
145
  /** Write the registry atomically (temp file + rename), creating `~/.canary`. */
135
146
  function write(registry, homeDir = os.homedir()) {
136
147
  fs.mkdirSync(canaryHome(homeDir), { recursive: true });
137
148
  const file = registryPath(homeDir);
138
149
  const tmp = `${file}.tmp`;
139
- fs.writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, "utf8");
150
+ fs.writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
140
151
  fs.renameSync(tmp, file);
141
152
  }
142
153
  function get(registry, name) {
package/dist/router.js CHANGED
@@ -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);
@@ -49,7 +49,7 @@ exports.route = route;
49
49
  const overlay = __importStar(require("./overlay-commands.js"));
50
50
  const doctor_js_1 = require("./doctor.js");
51
51
  /** Subcommands handled in TypeScript rather than forwarded to the binary. */
52
- exports.TS_COMMANDS = ["overlay", "doctor"];
52
+ exports.TS_COMMANDS = ['overlay', 'doctor'];
53
53
  /** True when `argv` (process.argv.slice(2)) targets a TS-handled command. */
54
54
  function isTsCommand(argv) {
55
55
  return argv.length > 0 && exports.TS_COMMANDS.includes(argv[0]);
@@ -60,15 +60,15 @@ function parseArgs(args) {
60
60
  const flags = {};
61
61
  for (let i = 0; i < args.length; i += 1) {
62
62
  const a = args[i];
63
- if (a.startsWith("--")) {
64
- const eq = a.indexOf("=");
63
+ if (a.startsWith('--')) {
64
+ const eq = a.indexOf('=');
65
65
  if (eq !== -1) {
66
66
  flags[a.slice(2, eq)] = a.slice(eq + 1);
67
67
  }
68
68
  else {
69
69
  const key = a.slice(2);
70
70
  const next = args[i + 1];
71
- if (next !== undefined && !next.startsWith("--")) {
71
+ if (next !== undefined && !next.startsWith('--')) {
72
72
  flags[key] = next;
73
73
  i += 1;
74
74
  }
@@ -85,26 +85,27 @@ function parseArgs(args) {
85
85
  }
86
86
  function refFrom(flags) {
87
87
  const ref = flags.ref;
88
- return typeof ref === "string" ? ref : null;
88
+ return typeof ref === 'string' ? ref : null;
89
89
  }
90
- const OVERLAY_USAGE = "usage: canary overlay <add|list|update|remove> [args]\n" +
91
- " add <source> [--ref <tag>] list update [name] remove <name>\n";
90
+ const OVERLAY_USAGE = 'usage: canary overlay <add|list|lint|update|remove> [args]\n' +
91
+ ' add <source> [--ref <tag>] list [--conflicts] lint <name|path> [--json] update [name] remove <name>\n';
92
92
  /** Overlay subcommand handlers, keyed by name. Each returns a process exit code. */
93
93
  const OVERLAY_SUBCOMMANDS = {
94
94
  add: ({ positionals, flags, deps, err }) => {
95
95
  if (positionals.length < 1) {
96
- err.write("usage: canary overlay add <source> [--ref <tag>] [--yes]\n");
96
+ err.write('usage: canary overlay add <source> [--ref <tag>] [--yes]\n');
97
97
  return 1;
98
98
  }
99
99
  // `--yes` grants command-check consent non-interactively (CI); default prompts.
100
100
  const addDeps = flags.yes === true ? { ...deps, confirm: () => true } : deps;
101
101
  return overlay.add(positionals[0], { ref: refFrom(flags) }, addDeps);
102
102
  },
103
- list: ({ deps }) => overlay.list(deps),
103
+ list: ({ flags, deps }) => overlay.list(deps, { conflicts: flags.conflicts === true }),
104
+ lint: ({ positionals, flags, deps }) => overlay.lint(positionals[0], deps, { json: flags.json === true }),
104
105
  update: ({ positionals, deps }) => overlay.update(positionals[0] ?? null, deps),
105
106
  remove: ({ positionals, deps, err }) => {
106
107
  if (positionals.length < 1) {
107
- err.write("usage: canary overlay remove <name>\n");
108
+ err.write('usage: canary overlay remove <name>\n');
108
109
  return 1;
109
110
  }
110
111
  return overlay.remove(positionals[0], deps);
@@ -114,7 +115,7 @@ function runOverlay(args, deps) {
114
115
  const err = deps.err ?? process.stderr;
115
116
  const handler = OVERLAY_SUBCOMMANDS[args[0]];
116
117
  if (!handler) {
117
- err.write(`canary overlay: unknown subcommand ${args[0] ? `'${args[0]}'` : "(none)"}\n${OVERLAY_USAGE}`);
118
+ err.write(`canary overlay: unknown subcommand ${args[0] ? `'${args[0]}'` : '(none)'}\n${OVERLAY_USAGE}`);
118
119
  return 1;
119
120
  }
120
121
  const { positionals, flags } = parseArgs(args.slice(1));
@@ -130,10 +131,10 @@ function route(argv, deps = {}) {
130
131
  if (!isTsCommand(argv)) {
131
132
  return null;
132
133
  }
133
- if (argv[0] === "overlay") {
134
+ if (argv[0] === 'overlay') {
134
135
  return runOverlay(argv.slice(1), deps);
135
136
  }
136
- if (argv[0] === "doctor") {
137
+ if (argv[0] === 'doctor') {
137
138
  return (0, doctor_js_1.runDoctor)(argv.slice(1), deps);
138
139
  }
139
140
  return null;
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseRequirement = parseRequirement;
4
+ exports.checkRequirement = checkRequirement;
5
+ exports.parseRequiresField = parseRequiresField;
6
+ const OP_RE = /^(.+?)\s*(>=|==|>)\s*([0-9][0-9.]*)\s*$/;
7
+ const BARE_RE = /^[A-Za-z0-9._+-]+$/;
8
+ /** Parse one requirement token into its command + optional version constraint. */
9
+ function parseRequirement(token) {
10
+ const m = token.match(OP_RE);
11
+ if (m) {
12
+ return {
13
+ raw: token,
14
+ command: m[1].trim(),
15
+ op: m[2],
16
+ version: m[3],
17
+ };
18
+ }
19
+ const bare = token.trim();
20
+ if (BARE_RE.test(bare)) {
21
+ return { raw: token, command: bare, op: null, version: null };
22
+ }
23
+ return { raw: token, command: null, op: null, version: null };
24
+ }
25
+ /** Numeric tuple from a dotted version; non-numeric parts stop the parse. */
26
+ function toParts(v) {
27
+ const parts = [];
28
+ for (const seg of v.split('.')) {
29
+ const n = Number.parseInt(seg, 10);
30
+ if (Number.isNaN(n))
31
+ break;
32
+ parts.push(n);
33
+ }
34
+ return parts;
35
+ }
36
+ /** Compare a against b over the length of `len` segments. -1 / 0 / 1. */
37
+ function cmpParts(a, b, len) {
38
+ for (let i = 0; i < len; i += 1) {
39
+ const av = a[i] ?? 0;
40
+ const bv = b[i] ?? 0;
41
+ if (av !== bv)
42
+ return av < bv ? -1 : 1;
43
+ }
44
+ return 0;
45
+ }
46
+ /** Does `have` satisfy `op want`? Compares over the constraint's precision. */
47
+ function satisfies(have, op, want) {
48
+ const h = toParts(have);
49
+ const w = toParts(want);
50
+ if (w.length === 0)
51
+ return true;
52
+ // `==` compares only the segments the constraint pins (1.22 matches 1.22.4).
53
+ const c = cmpParts(h, w, op === '==' ? w.length : Math.max(h.length, w.length));
54
+ if (op === '==')
55
+ return c === 0;
56
+ if (op === '>')
57
+ return c > 0;
58
+ return c >= 0; // '>='
59
+ }
60
+ /** Verify one requirement against a command probe. Never throws. */
61
+ function checkRequirement(req, probe) {
62
+ if (req.command === null) {
63
+ return {
64
+ requirement: req,
65
+ status: 'unverifiable',
66
+ detail: `cannot parse requirement "${req.raw}"`,
67
+ };
68
+ }
69
+ const p = probe(req.command);
70
+ if (!p.present) {
71
+ return {
72
+ requirement: req,
73
+ status: 'missing',
74
+ detail: `${req.command} not found on PATH`,
75
+ };
76
+ }
77
+ if (req.version === null) {
78
+ return { requirement: req, status: 'ok', detail: `${req.command} present` };
79
+ }
80
+ if (p.version === null) {
81
+ return {
82
+ requirement: req,
83
+ status: 'unverifiable',
84
+ detail: `${req.command} present but its version could not be read`,
85
+ };
86
+ }
87
+ if (satisfies(p.version, req.op ?? '>=', req.version)) {
88
+ return {
89
+ requirement: req,
90
+ status: 'ok',
91
+ detail: `${req.command} ${p.version} satisfies ${req.op}${req.version}`,
92
+ };
93
+ }
94
+ return {
95
+ requirement: req,
96
+ status: 'too-old',
97
+ detail: `${req.command} ${p.version} does not satisfy ${req.op}${req.version}`,
98
+ };
99
+ }
100
+ /**
101
+ * Extract the `requires:` flow list from SKILL.md frontmatter — the same
102
+ * tiny-YAML subset the Python loader parses (`requires: [a, b]`). Returns []
103
+ * when absent or when there is no frontmatter. Block-sequence form is not
104
+ * supported (kept in lockstep with the Python parser).
105
+ */
106
+ function parseRequiresField(md) {
107
+ if (!md.startsWith('---'))
108
+ return [];
109
+ const lines = md.split('\n');
110
+ for (const line of lines.slice(1)) {
111
+ if (line.trim() === '---')
112
+ break;
113
+ const idx = line.indexOf(':');
114
+ if (idx === -1)
115
+ continue;
116
+ if (line.slice(0, idx).trim() !== 'requires')
117
+ continue;
118
+ const value = line.slice(idx + 1).trim();
119
+ if (value.startsWith('[') && value.endsWith(']')) {
120
+ return value
121
+ .slice(1, -1)
122
+ .split(',')
123
+ .map((s) => s.trim())
124
+ .filter(Boolean);
125
+ }
126
+ return value ? [value] : [];
127
+ }
128
+ return [];
129
+ }
@@ -1,4 +1,4 @@
1
- "use strict";
1
+ 'use strict';
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SourceSpecError = void 0;
4
4
  exports.parseSource = parseSource;
@@ -6,14 +6,14 @@ exports.parseSource = parseSource;
6
6
  class SourceSpecError extends Error {
7
7
  constructor(message) {
8
8
  super(message);
9
- this.name = "SourceSpecError";
9
+ this.name = 'SourceSpecError';
10
10
  }
11
11
  }
12
12
  exports.SourceSpecError = SourceSpecError;
13
- const ACCEPTED_FORMS = "accepted forms: github:owner/repo, an https/git@ URL, or a local path";
13
+ const ACCEPTED_FORMS = 'accepted forms: github:owner/repo, an https/git@ URL, or a local path';
14
14
  /** Strip a trailing `.git` and any trailing slashes from a path segment. */
15
15
  function stripRepoSuffix(segment) {
16
- return segment.replace(/\.git$/i, "").replace(/\/+$/, "");
16
+ return segment.replace(/\.git$/i, '').replace(/\/+$/, '');
17
17
  }
18
18
  /** Sanitize a raw `owner/repo` pair into `owner-repo`, rejecting empties. */
19
19
  function nameFromOwnerRepo(owner, repo, raw) {
@@ -26,7 +26,7 @@ function nameFromOwnerRepo(owner, repo, raw) {
26
26
  }
27
27
  /** Extract the last two path segments (owner, repo) from a URL-ish path. */
28
28
  function lastTwoSegments(pathPart) {
29
- const segments = pathPart.split("/").filter(Boolean);
29
+ const segments = pathPart.split('/').filter(Boolean);
30
30
  if (segments.length < 2) {
31
31
  return null;
32
32
  }
@@ -34,10 +34,10 @@ function lastTwoSegments(pathPart) {
34
34
  }
35
35
  /** github:owner/repo shorthand. Returns null when `spec` is not this form. */
36
36
  function parseGithubShorthand(spec, raw) {
37
- if (!spec.startsWith("github:")) {
37
+ if (!spec.startsWith('github:')) {
38
38
  return null;
39
39
  }
40
- const parts = spec.slice("github:".length).split("/").filter(Boolean);
40
+ const parts = spec.slice('github:'.length).split('/').filter(Boolean);
41
41
  if (parts.length !== 2) {
42
42
  throw new SourceSpecError(`"${raw}" is not github:owner/repo (${ACCEPTED_FORMS})`);
43
43
  }
@@ -79,10 +79,13 @@ function parseFullUrl(spec, raw) {
79
79
  }
80
80
  /** Local filesystem path (absolute, ./relative, ../relative, or ~-relative). */
81
81
  function parseLocalPath(spec, raw) {
82
- if (!(spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("~"))) {
82
+ if (!(spec.startsWith('/') ||
83
+ spec.startsWith('./') ||
84
+ spec.startsWith('../') ||
85
+ spec.startsWith('~'))) {
83
86
  return null;
84
87
  }
85
- const basename = stripRepoSuffix(spec.split("/").filter(Boolean).pop() ?? "");
88
+ const basename = stripRepoSuffix(spec.split('/').filter(Boolean).pop() ?? '');
86
89
  if (!basename) {
87
90
  throw new SourceSpecError(`cannot derive a name from path "${raw}" (${ACCEPTED_FORMS})`);
88
91
  }
@@ -93,11 +96,16 @@ function parseLocalPath(spec, raw) {
93
96
  * {@link SourceSpecError} on anything that does not match an accepted form.
94
97
  */
95
98
  function parseSource(raw) {
96
- const spec = (raw ?? "").trim();
99
+ const spec = (raw ?? '').trim();
97
100
  if (!spec) {
98
101
  throw new SourceSpecError(`empty source (${ACCEPTED_FORMS})`);
99
102
  }
100
- for (const handler of [parseGithubShorthand, parseScpUrl, parseFullUrl, parseLocalPath]) {
103
+ for (const handler of [
104
+ parseGithubShorthand,
105
+ parseScpUrl,
106
+ parseFullUrl,
107
+ parseLocalPath,
108
+ ]) {
101
109
  const parsed = handler(spec, raw);
102
110
  if (parsed) {
103
111
  return parsed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canary-test-cli",
3
- "version": "5.10.1",
3
+ "version": "5.12.0",
4
4
  "description": "Canary — AI-powered test automation agent",
5
5
  "license": "MIT",
6
6
  "repository": {