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.
- 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
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
70
|
+
return path.join(homeDir, '.canary');
|
|
71
71
|
}
|
|
72
72
|
function registryPath(homeDir = os.homedir()) {
|
|
73
|
-
return path.join(canaryHome(homeDir),
|
|
73
|
+
return path.join(canaryHome(homeDir), 'overlays.json');
|
|
74
74
|
}
|
|
75
75
|
function overlaysDir(homeDir = os.homedir()) {
|
|
76
|
-
return path.join(canaryHome(homeDir),
|
|
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,
|
|
93
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
94
94
|
}
|
|
95
95
|
catch (err) {
|
|
96
|
-
if (err.code ===
|
|
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 !==
|
|
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 {
|
|
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 ===
|
|
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 &&
|
|
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`,
|
|
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
|
-
|
|
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 = [
|
|
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 ===
|
|
88
|
+
return typeof ref === 'string' ? ref : null;
|
|
89
89
|
}
|
|
90
|
-
const OVERLAY_USAGE =
|
|
91
|
-
|
|
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(
|
|
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(
|
|
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]}'` :
|
|
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] ===
|
|
134
|
+
if (argv[0] === 'overlay') {
|
|
134
135
|
return runOverlay(argv.slice(1), deps);
|
|
135
136
|
}
|
|
136
|
-
if (argv[0] ===
|
|
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
|
+
}
|
package/dist/source-spec.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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 =
|
|
9
|
+
this.name = 'SourceSpecError';
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
exports.SourceSpecError = SourceSpecError;
|
|
13
|
-
const ACCEPTED_FORMS =
|
|
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,
|
|
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(
|
|
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(
|
|
37
|
+
if (!spec.startsWith('github:')) {
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
|
-
const parts = spec.slice(
|
|
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(
|
|
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(
|
|
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 ??
|
|
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 [
|
|
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;
|