wyvrnpm 2.0.4 → 2.3.2
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/README.md +639 -5
- package/bin/wyvrn.js +220 -10
- package/claude/skills/wyvrnpm.skill +0 -0
- package/cmake/cpp.cmake +9 -0
- package/cmake/functions.cmake +224 -0
- package/cmake/macros.cmake +233 -0
- package/cmake/options.cmake +23 -0
- package/cmake/variables.cmake +171 -0
- package/package.json +3 -1
- package/src/build/cache.js +148 -0
- package/src/build/clone.js +170 -0
- package/src/build/cmake.js +342 -0
- package/src/build/index.js +275 -0
- package/src/build/msvc-env.js +217 -0
- package/src/build/recipe.js +155 -0
- package/src/commands/build.js +283 -0
- package/src/commands/clean.js +56 -16
- package/src/commands/configure.js +6 -5
- package/src/commands/init.js +3 -2
- package/src/commands/install-skill.js +107 -0
- package/src/commands/install.js +262 -19
- package/src/commands/link.js +18 -15
- package/src/commands/profile.js +15 -12
- package/src/commands/publish.js +216 -65
- package/src/commands/show.js +237 -0
- package/src/compat.js +261 -0
- package/src/config.js +3 -1
- package/src/download.js +431 -87
- package/src/ignore.js +118 -0
- package/src/logger.js +94 -0
- package/src/manifest.js +12 -7
- package/src/options.js +303 -0
- package/src/profile.js +56 -4
- package/src/providers/base.js +16 -1
- package/src/providers/file.js +12 -6
- package/src/providers/http.js +15 -10
- package/src/providers/s3.js +14 -9
- package/src/resolve.js +179 -19
- package/src/toolchain/deps.js +164 -0
- package/src/toolchain/index.js +141 -0
- package/src/toolchain/presets.js +263 -0
- package/src/toolchain/template.cmake +66 -0
- package/src/upload-built.js +256 -0
- package/src/version-range.js +301 -0
package/src/ignore.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// .wyvrnignore matcher — follows .gitignore semantics as closely as is
|
|
7
|
+
// practical for a static-archive filter. Supported:
|
|
8
|
+
//
|
|
9
|
+
// - Blank lines and lines starting with `#` are ignored.
|
|
10
|
+
// - A leading `!` negates the match — re-includes a path a prior pattern
|
|
11
|
+
// excluded. Last matching pattern wins.
|
|
12
|
+
// - A trailing `/` makes the pattern directory-only: it matches directory
|
|
13
|
+
// entries only, never files. This is the single most common gitignore
|
|
14
|
+
// idiom (`build/`, `.git/`) so we honour it rather than treating the
|
|
15
|
+
// slash as a literal character.
|
|
16
|
+
// - A leading `/` anchors the pattern to the project root.
|
|
17
|
+
// - A `/` anywhere else in the pattern (excluding the trailing one) also
|
|
18
|
+
// anchors it to the root — same rule as gitignore.
|
|
19
|
+
// - Otherwise the pattern floats: it matches at any directory depth.
|
|
20
|
+
// - Globs: `*` is single-segment, `**` crosses segments, `?` is one char.
|
|
21
|
+
// - Literal leading `!` or `#` can be escaped with `\!` / `\#`.
|
|
22
|
+
//
|
|
23
|
+
// Not supported (unlike gitignore): pattern re-evaluation after a parent
|
|
24
|
+
// directory has been excluded (gitignore can't re-include a file whose
|
|
25
|
+
// parent directory was excluded — we don't recurse into excluded dirs
|
|
26
|
+
// either, so the behaviour lines up in practice).
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function parsePattern(raw) {
|
|
30
|
+
let p = raw;
|
|
31
|
+
if (p == null) return null;
|
|
32
|
+
p = p.replace(/\s+$/, ''); // trim trailing whitespace (gitignore rule)
|
|
33
|
+
if (p.length === 0) return null;
|
|
34
|
+
if (p.startsWith('#')) return null;
|
|
35
|
+
|
|
36
|
+
let negate = false;
|
|
37
|
+
if (p.startsWith('!')) {
|
|
38
|
+
negate = true;
|
|
39
|
+
p = p.slice(1);
|
|
40
|
+
} else if (p.startsWith('\\!') || p.startsWith('\\#')) {
|
|
41
|
+
// escaped literal leading ! or #
|
|
42
|
+
p = p.slice(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let dirOnly = false;
|
|
46
|
+
if (p.length > 1 && p.endsWith('/')) {
|
|
47
|
+
dirOnly = true;
|
|
48
|
+
p = p.slice(0, -1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const leadingSlash = p.startsWith('/');
|
|
52
|
+
if (leadingSlash) p = p.slice(1);
|
|
53
|
+
|
|
54
|
+
// A `/` anywhere in the (remaining) pattern anchors it to the root.
|
|
55
|
+
// Bare names like `build` or `*.log` float — they match at any depth.
|
|
56
|
+
const anchored = leadingSlash || p.includes('/');
|
|
57
|
+
|
|
58
|
+
const re = p
|
|
59
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
60
|
+
.replace(/\*\*/g, '\x00')
|
|
61
|
+
.replace(/\*/g, '[^/]*')
|
|
62
|
+
.replace(/\?/g, '[^/]')
|
|
63
|
+
.replace(/\x00/g, '.*');
|
|
64
|
+
|
|
65
|
+
const regex = anchored
|
|
66
|
+
? new RegExp(`^${re}(/|$)`)
|
|
67
|
+
: new RegExp(`(^|/)${re}(/|$)`);
|
|
68
|
+
|
|
69
|
+
return { raw, regex, negate, dirOnly };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadIgnorePatterns(ignoreFile) {
|
|
73
|
+
if (!fs.existsSync(ignoreFile)) return [];
|
|
74
|
+
return fs
|
|
75
|
+
.readFileSync(ignoreFile, 'utf8')
|
|
76
|
+
.split(/\r?\n/)
|
|
77
|
+
.map(parsePattern)
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Determine whether a path should be excluded.
|
|
83
|
+
* @param {string} relPath Forward-slash-separated path relative to the root.
|
|
84
|
+
* @param {boolean} isDir True if the path is a directory.
|
|
85
|
+
* @param {Array} patterns Parsed patterns (from loadIgnorePatterns).
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*
|
|
88
|
+
* Semantics follow gitignore: a dir-only pattern (`build/`) matches the
|
|
89
|
+
* directory itself AND every descendant. We honour this by testing each
|
|
90
|
+
* ancestor-prefix of the path against dir-only patterns — matters for
|
|
91
|
+
* callers who probe arbitrary paths (tests, library users) rather than
|
|
92
|
+
* walking the tree top-down (where the walker naturally stops recursing
|
|
93
|
+
* once it sees the excluded parent).
|
|
94
|
+
*/
|
|
95
|
+
function isIgnored(relPath, isDir, patterns) {
|
|
96
|
+
let ignored = false;
|
|
97
|
+
for (const { regex, negate, dirOnly } of patterns) {
|
|
98
|
+
let matched;
|
|
99
|
+
if (dirOnly) {
|
|
100
|
+
// Check the path itself (if it's a directory) and every ancestor dir.
|
|
101
|
+
matched = isDir && regex.test(relPath);
|
|
102
|
+
if (!matched) {
|
|
103
|
+
const parts = relPath.split('/');
|
|
104
|
+
let prefix = '';
|
|
105
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
106
|
+
prefix = prefix ? `${prefix}/${parts[i]}` : parts[i];
|
|
107
|
+
if (regex.test(prefix)) { matched = true; break; }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
matched = regex.test(relPath);
|
|
112
|
+
}
|
|
113
|
+
if (matched) ignored = !negate;
|
|
114
|
+
}
|
|
115
|
+
return ignored;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { parsePattern, loadIgnorePatterns, isIgnored };
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ANSI-coloured, level-tagged logger for wyvrnpm CLI output.
|
|
4
|
+
//
|
|
5
|
+
// All user-facing lines go through this module so the `[wyvrn]` prefix and
|
|
6
|
+
// level markers are applied consistently. Callers pass the message content
|
|
7
|
+
// only — the prefix is never written at call sites.
|
|
8
|
+
//
|
|
9
|
+
// Colour is emitted when the target stream is a TTY, unless overridden by
|
|
10
|
+
// the NO_COLOR or FORCE_COLOR environment variables (see https://no-color.org).
|
|
11
|
+
|
|
12
|
+
const PREFIX = '[wyvrn]';
|
|
13
|
+
|
|
14
|
+
const ANSI = {
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
bold: '\x1b[1m',
|
|
17
|
+
dim: '\x1b[2m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
cyan: '\x1b[36m',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// When a command is emitting a JSON payload on stdout (F7 `--format=json`),
|
|
25
|
+
// stdout must be reserved exclusively for that payload. In that mode we
|
|
26
|
+
// route info/success to stderr too and drop colour so stderr stays
|
|
27
|
+
// log-ingestion-friendly.
|
|
28
|
+
let jsonMode = false;
|
|
29
|
+
|
|
30
|
+
function setJsonMode(enabled) {
|
|
31
|
+
jsonMode = Boolean(enabled);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isJsonMode() {
|
|
35
|
+
return jsonMode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function colorEnabled(stream) {
|
|
39
|
+
if (jsonMode) return false;
|
|
40
|
+
if (process.env.NO_COLOR) return false;
|
|
41
|
+
if (process.env.FORCE_COLOR) return true;
|
|
42
|
+
return !!(stream && stream.isTTY);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function paint(text, code, stream) {
|
|
46
|
+
return colorEnabled(stream) ? `${code}${text}${ANSI.reset}` : text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function emit(stream, consoleFn, coloredPrefix, message) {
|
|
50
|
+
// Every line in a multi-line message gets the prefix — so multi-line errors
|
|
51
|
+
// line up visually without the caller having to repeat `[wyvrn]` per line.
|
|
52
|
+
const out = String(message)
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((line) => `${coloredPrefix} ${line}`)
|
|
55
|
+
.join('\n');
|
|
56
|
+
consoleFn(out);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function info(message) {
|
|
60
|
+
// In JSON mode stdout is reserved for the final payload — route info to stderr.
|
|
61
|
+
const stream = jsonMode ? process.stderr : process.stdout;
|
|
62
|
+
const consoleFn = jsonMode ? console.error : console.log;
|
|
63
|
+
emit(stream, consoleFn, paint(PREFIX, ANSI.dim, stream), message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function warn(message) {
|
|
67
|
+
const p = paint(PREFIX, ANSI.yellow, process.stderr);
|
|
68
|
+
const tag = paint('warn', ANSI.yellow + ANSI.bold, process.stderr);
|
|
69
|
+
emit(process.stderr, console.warn, `${p} ${tag}`, message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function error(message) {
|
|
73
|
+
const p = paint(PREFIX, ANSI.red, process.stderr);
|
|
74
|
+
const tag = paint('error', ANSI.red + ANSI.bold, process.stderr);
|
|
75
|
+
emit(process.stderr, console.error, `${p} ${tag}`, message);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function success(message) {
|
|
79
|
+
// In JSON mode stdout is reserved for the final payload — route success to stderr.
|
|
80
|
+
const stream = jsonMode ? process.stderr : process.stdout;
|
|
81
|
+
const consoleFn = jsonMode ? console.error : console.log;
|
|
82
|
+
const p = paint(PREFIX, ANSI.green, stream);
|
|
83
|
+
const tag = paint('ok', ANSI.green + ANSI.bold, stream);
|
|
84
|
+
emit(stream, consoleFn, `${p} ${tag}`, message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function debug(message) {
|
|
88
|
+
if (!process.env.WYVRNPM_DEBUG) return;
|
|
89
|
+
const p = paint(PREFIX, ANSI.dim, process.stderr);
|
|
90
|
+
const tag = paint('debug', ANSI.cyan, process.stderr);
|
|
91
|
+
emit(process.stderr, console.error, `${p} ${tag}`, message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { info, warn, error, success, debug, setJsonMode, isJsonMode };
|
package/src/manifest.js
CHANGED
|
@@ -52,14 +52,18 @@ function defaultManifest(name) {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Normalise the raw `dependencies` field from a manifest into a consistent
|
|
55
|
-
* `{ name → { version, settings } }` map, regardless of
|
|
55
|
+
* `{ name → { version, settings, options } }` map, regardless of input format:
|
|
56
56
|
*
|
|
57
|
-
* • String value : "1.0.0"
|
|
58
|
-
* • Object value : { version, settings? } → kept (
|
|
59
|
-
* • Array format : [{ Name, Version }]
|
|
57
|
+
* • String value : "1.0.0" → { version: "1.0.0", settings: {}, options: {} }
|
|
58
|
+
* • Object value : { version, settings?, options? } → kept (missing keys default to {})
|
|
59
|
+
* • Array format : [{ Name, Version }] → converted (no settings/options)
|
|
60
|
+
*
|
|
61
|
+
* `settings` merges on top of the active profile for this dep only.
|
|
62
|
+
* `options` maps to the recipe's declared options; validated against
|
|
63
|
+
* the recipe's `allowed` list at resolve time (see src/options.js).
|
|
60
64
|
*
|
|
61
65
|
* @param {object|Array|undefined} rawDeps
|
|
62
|
-
* @returns {Record<string, { version: string, settings: object }>}
|
|
66
|
+
* @returns {Record<string, { version: string, settings: object, options: object }>}
|
|
63
67
|
*/
|
|
64
68
|
function normalizeDependencies(rawDeps) {
|
|
65
69
|
if (!rawDeps || typeof rawDeps !== 'object') return {};
|
|
@@ -69,7 +73,7 @@ function normalizeDependencies(rawDeps) {
|
|
|
69
73
|
for (const d of rawDeps) {
|
|
70
74
|
const n = d.Name ?? d.name;
|
|
71
75
|
const v = d.Version ?? d.version;
|
|
72
|
-
if (n && v) result[n] = { version: v, settings: {} };
|
|
76
|
+
if (n && v) result[n] = { version: v, settings: {}, options: {} };
|
|
73
77
|
}
|
|
74
78
|
return result;
|
|
75
79
|
}
|
|
@@ -77,11 +81,12 @@ function normalizeDependencies(rawDeps) {
|
|
|
77
81
|
const result = {};
|
|
78
82
|
for (const [n, value] of Object.entries(rawDeps)) {
|
|
79
83
|
if (typeof value === 'string') {
|
|
80
|
-
result[n] = { version: value, settings: {} };
|
|
84
|
+
result[n] = { version: value, settings: {}, options: {} };
|
|
81
85
|
} else if (value && typeof value === 'object') {
|
|
82
86
|
result[n] = {
|
|
83
87
|
version: value.version ?? value.Version ?? '',
|
|
84
88
|
settings: value.settings ?? {},
|
|
89
|
+
options: value.options ?? {},
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
92
|
}
|
package/src/options.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recipe-declared options: `wyvrn.json` gains a top-level `options` block
|
|
5
|
+
* that declares feature toggles the package exposes (shared/static,
|
|
6
|
+
* `minizip` on zlib, FIPS on OpenSSL, etc.). Consumers override per-dep
|
|
7
|
+
* via `dependencies.<name>.options` or on the CLI via `-o <name>:<key>=<value>`.
|
|
8
|
+
*
|
|
9
|
+
* Options fold into `profileHash` (via `hashProfile`), so every distinct
|
|
10
|
+
* option combination gets its own artefact on the registry.
|
|
11
|
+
*
|
|
12
|
+
* Scope:
|
|
13
|
+
* - Recipe declares options with `default` + `allowed` per name.
|
|
14
|
+
* - Consumer can override any declared option to any `allowed` value.
|
|
15
|
+
* - No transitive propagation for MVP (see PLAN-OPTIONS §2).
|
|
16
|
+
* - Values are `boolean` or string-enum; no integers/lists.
|
|
17
|
+
*
|
|
18
|
+
* See claude/PLAN-OPTIONS.md for the full design and
|
|
19
|
+
* claude/PLAN-OPTIONS-EXAMPLE.md for a worked zlib walkthrough.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Option names are conservative — lower-case, start with a letter. Prevents
|
|
23
|
+
// collisions with future reserved keywords (`options.schemaVersion` etc.)
|
|
24
|
+
const OPTION_NAME_RE = /^[a-z][a-z0-9_-]*$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate and normalise a recipe's `options` block. Returns a canonical
|
|
28
|
+
* form (a fresh object with the declared options preserved, `allowed`
|
|
29
|
+
* arrays duplicated defensively), or null if the recipe has no options.
|
|
30
|
+
*
|
|
31
|
+
* Throws with a precise, user-facing message on any structural error.
|
|
32
|
+
*
|
|
33
|
+
* @param {object|null|undefined} rawOptions - the `options` field from a recipe wyvrn.json
|
|
34
|
+
* @param {string} [pkgContext] - e.g. "zlib@1.3.0.0" — included in error messages
|
|
35
|
+
* @returns {Record<string, { default: any, allowed: any[] }>|null}
|
|
36
|
+
*/
|
|
37
|
+
function normalizeOptionsDeclaration(rawOptions, pkgContext = '') {
|
|
38
|
+
if (rawOptions === null || rawOptions === undefined) return null;
|
|
39
|
+
const where = pkgContext ? ` in ${pkgContext}` : '';
|
|
40
|
+
|
|
41
|
+
if (typeof rawOptions !== 'object' || Array.isArray(rawOptions)) {
|
|
42
|
+
throw new Error(`\`options\`${where} must be an object mapping option names to { default, allowed } specs`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result = {};
|
|
46
|
+
for (const [name, spec] of Object.entries(rawOptions)) {
|
|
47
|
+
if (!OPTION_NAME_RE.test(name)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`option name "${name}"${where} is invalid — must match /^[a-z][a-z0-9_-]*$/`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
53
|
+
throw new Error(`options.${name}${where} must be an object of the form { default: <value>, allowed: [<value>, ...] }`);
|
|
54
|
+
}
|
|
55
|
+
if (!('default' in spec) || !('allowed' in spec)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`options.${name}${where} is missing the \`default\` or \`allowed\` field\n` +
|
|
58
|
+
` shape expected: { "default": <value>, "allowed": [<value>, ...] }`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(spec.allowed) || spec.allowed.length === 0) {
|
|
62
|
+
throw new Error(`options.${name}.allowed${where} must be a non-empty array`);
|
|
63
|
+
}
|
|
64
|
+
for (const v of spec.allowed) {
|
|
65
|
+
const t = typeof v;
|
|
66
|
+
if (t !== 'boolean' && t !== 'string') {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`options.${name}.allowed${where} values must be boolean or string (got ${t}: ${JSON.stringify(v)})`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!spec.allowed.includes(spec.default)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`options.${name}.default = ${JSON.stringify(spec.default)}${where} is not in allowed: ${JSON.stringify(spec.allowed)}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
result[name] = { default: spec.default, allowed: spec.allowed.slice() };
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute the effective (resolved) options for a dep by starting from the
|
|
84
|
+
* recipe's declared defaults, overlaying the consumer's wyvrn.json
|
|
85
|
+
* overrides, then overlaying CLI `-o` overrides. Each override is
|
|
86
|
+
* validated against the recipe's `allowed` list.
|
|
87
|
+
*
|
|
88
|
+
* Returns null when the recipe has no declared options. Returning null
|
|
89
|
+
* (vs an empty object) is how `hashProfile` distinguishes "no options"
|
|
90
|
+
* from "options declared but all at defaults" — the former produces the
|
|
91
|
+
* pre-F1 hash for backward compatibility.
|
|
92
|
+
*
|
|
93
|
+
* @param {object|null} declaration — normalised recipe options (from `normalizeOptionsDeclaration`)
|
|
94
|
+
* @param {object} [consumerOverrides] — dependencies.<name>.options from consumer wyvrn.json
|
|
95
|
+
* @param {object} [cliOverrides] — scoped to this package, extracted from `-o` flags
|
|
96
|
+
* @param {string} [pkgContext] — "zlib@1.3.0.0" style, for error messages
|
|
97
|
+
* @returns {Record<string, any>|null}
|
|
98
|
+
*/
|
|
99
|
+
function resolveEffectiveOptions(declaration, consumerOverrides = {}, cliOverrides = {}, pkgContext = '') {
|
|
100
|
+
if (!declaration) {
|
|
101
|
+
// Recipe didn't declare options → consumer must not supply any
|
|
102
|
+
const extra = new Set([
|
|
103
|
+
...Object.keys(consumerOverrides ?? {}),
|
|
104
|
+
...Object.keys(cliOverrides ?? {}),
|
|
105
|
+
]);
|
|
106
|
+
if (extra.size > 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${pkgContext || 'package'} has no declared options, but received overrides: ${[...extra].join(', ')}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const declaredNames = Object.keys(declaration);
|
|
115
|
+
|
|
116
|
+
const validateSource = (overrides, sourceLabel) => {
|
|
117
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
118
|
+
if (!(key in declaration)) {
|
|
119
|
+
const suggestion = findClosest(key, declaredNames);
|
|
120
|
+
const hint = suggestion ? `\n Did you mean "${suggestion}"?` : '';
|
|
121
|
+
throw new Error(
|
|
122
|
+
`unknown option "${key}" for ${pkgContext || 'package'} (from ${sourceLabel})\n` +
|
|
123
|
+
` declared options: ${declaredNames.join(', ')}${hint}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!declaration[key].allowed.includes(value)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`option ${pkgContext ? pkgContext + '.' : ''}${key} = ${JSON.stringify(value)} is not allowed (from ${sourceLabel})\n` +
|
|
129
|
+
` allowed values: ${declaration[key].allowed.map((v) => JSON.stringify(v)).join(', ')}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
validateSource(consumerOverrides, 'wyvrn.json');
|
|
135
|
+
validateSource(cliOverrides, '--option CLI flag');
|
|
136
|
+
|
|
137
|
+
const effective = {};
|
|
138
|
+
for (const [name, spec] of Object.entries(declaration)) {
|
|
139
|
+
effective[name] = spec.default;
|
|
140
|
+
if (consumerOverrides && name in consumerOverrides) effective[name] = consumerOverrides[name];
|
|
141
|
+
if (cliOverrides && name in cliOverrides) effective[name] = cliOverrides[name];
|
|
142
|
+
}
|
|
143
|
+
return effective;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse the `--option`/`-o` CLI flag values into a nested map.
|
|
148
|
+
*
|
|
149
|
+
* ["zlib:minizip=false", "openssl:fips=true"]
|
|
150
|
+
* ↓
|
|
151
|
+
* { zlib: { minizip: false }, openssl: { fips: true } }
|
|
152
|
+
*
|
|
153
|
+
* Rules:
|
|
154
|
+
* - Split once on ":" (package name cannot contain ":", enforced by
|
|
155
|
+
* manifest validation elsewhere).
|
|
156
|
+
* - Split the right side once on "=" so string values may contain "=".
|
|
157
|
+
* - Coerce "true"/"false" (case-insensitive) to booleans; everything
|
|
158
|
+
* else stays a string. Downstream validation against the recipe's
|
|
159
|
+
* `allowed` list catches illegal values.
|
|
160
|
+
* - Empty package name, option name, or value → usage error.
|
|
161
|
+
*
|
|
162
|
+
* @param {string|string[]|undefined} rawFlags
|
|
163
|
+
* @returns {Record<string, Record<string, any>>}
|
|
164
|
+
*/
|
|
165
|
+
function parseCliOptions(rawFlags) {
|
|
166
|
+
if (!rawFlags) return {};
|
|
167
|
+
const arr = Array.isArray(rawFlags) ? rawFlags : [rawFlags];
|
|
168
|
+
const result = {};
|
|
169
|
+
for (const entry of arr) {
|
|
170
|
+
if (typeof entry !== 'string' || entry.length === 0) continue;
|
|
171
|
+
const colonIdx = entry.indexOf(':');
|
|
172
|
+
if (colonIdx <= 0) {
|
|
173
|
+
throw new Error(`--option "${entry}" is malformed — expected <pkg>:<name>=<value>`);
|
|
174
|
+
}
|
|
175
|
+
const pkg = entry.slice(0, colonIdx);
|
|
176
|
+
const rest = entry.slice(colonIdx + 1);
|
|
177
|
+
const eqIdx = rest.indexOf('=');
|
|
178
|
+
if (eqIdx <= 0 || eqIdx === rest.length - 1) {
|
|
179
|
+
throw new Error(`--option "${entry}" is malformed — expected <pkg>:<name>=<value>`);
|
|
180
|
+
}
|
|
181
|
+
const name = rest.slice(0, eqIdx);
|
|
182
|
+
const value = rest.slice(eqIdx + 1);
|
|
183
|
+
if (!result[pkg]) result[pkg] = {};
|
|
184
|
+
result[pkg][name] = coerceCliValue(value);
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function coerceCliValue(raw) {
|
|
190
|
+
const lc = raw.toLowerCase();
|
|
191
|
+
if (lc === 'true') return true;
|
|
192
|
+
if (lc === 'false') return false;
|
|
193
|
+
return raw;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Template-expand `${options.<name>}` occurrences in a single string using
|
|
198
|
+
* the supplied `effective` options map. Bool values coerce to CMake's
|
|
199
|
+
* `ON`/`OFF`; string values expand verbatim.
|
|
200
|
+
*
|
|
201
|
+
* A missing variable (`${options.fips}` with no `fips` in effective) is
|
|
202
|
+
* a hard error — a typo in the recipe must fail fast at source-build
|
|
203
|
+
* configure time, not silently reach cmake as a literal unexpanded
|
|
204
|
+
* string. If the recipe declares no options at all, every template
|
|
205
|
+
* reference fails for the same reason.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} arg a single argv element (e.g. `-DSHARED=${options.shared}`)
|
|
208
|
+
* @param {Record<string, any>|null} effective
|
|
209
|
+
* @param {string} [context] — for the error message (`build.configure`, `build.buildArgs`)
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
function substituteTemplate(arg, effective, context = '') {
|
|
213
|
+
if (typeof arg !== 'string') return arg;
|
|
214
|
+
return arg.replace(/\$\{options\.([a-z][a-z0-9_-]*)\}/g, (_full, name) => {
|
|
215
|
+
if (!effective || !(name in effective)) {
|
|
216
|
+
const declared = effective ? Object.keys(effective) : [];
|
|
217
|
+
const declaredLine = declared.length > 0
|
|
218
|
+
? `\n declared options: ${declared.join(', ')}`
|
|
219
|
+
: '\n (no options declared on this recipe)';
|
|
220
|
+
throw new Error(
|
|
221
|
+
`${context ? context + ' ' : ''}references \${options.${name}} but no such option is declared${declaredLine}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const v = effective[name];
|
|
225
|
+
if (v === true) return 'ON';
|
|
226
|
+
if (v === false) return 'OFF';
|
|
227
|
+
return String(v);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Apply `substituteTemplate` across an array of argv elements.
|
|
233
|
+
*
|
|
234
|
+
* @param {string[]} args
|
|
235
|
+
* @param {Record<string, any>|null} effective
|
|
236
|
+
* @param {string} [context]
|
|
237
|
+
* @returns {string[]}
|
|
238
|
+
*/
|
|
239
|
+
function substituteTemplateAll(args, effective, context = '') {
|
|
240
|
+
if (!Array.isArray(args)) return args;
|
|
241
|
+
return args.map((a) => substituteTemplate(a, effective, context));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Return a new object with the same keys sorted alphabetically. Used as
|
|
246
|
+
* the canonical form for hashing.
|
|
247
|
+
*
|
|
248
|
+
* @param {object|null} options
|
|
249
|
+
* @returns {object|null}
|
|
250
|
+
*/
|
|
251
|
+
function sortAndNormaliseOptions(options) {
|
|
252
|
+
if (!options) return null;
|
|
253
|
+
return Object.fromEntries(
|
|
254
|
+
Object.entries(options).sort(([a], [b]) => a.localeCompare(b)),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// "Did you mean" suggestion helper — Levenshtein with a small-distance cap.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
function findClosest(needle, haystack) {
|
|
263
|
+
if (!Array.isArray(haystack) || haystack.length === 0) return null;
|
|
264
|
+
const cap = Math.max(2, Math.floor(needle.length / 3));
|
|
265
|
+
let best = null;
|
|
266
|
+
let bestDist = Infinity;
|
|
267
|
+
for (const candidate of haystack) {
|
|
268
|
+
const d = levenshtein(needle.toLowerCase(), candidate.toLowerCase());
|
|
269
|
+
if (d < bestDist && d <= cap) {
|
|
270
|
+
best = candidate;
|
|
271
|
+
bestDist = d;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return best;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function levenshtein(a, b) {
|
|
278
|
+
const m = a.length;
|
|
279
|
+
const n = b.length;
|
|
280
|
+
if (m === 0) return n;
|
|
281
|
+
if (n === 0) return m;
|
|
282
|
+
let prev = new Array(n + 1);
|
|
283
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
284
|
+
for (let i = 1; i <= m; i++) {
|
|
285
|
+
const curr = [i];
|
|
286
|
+
for (let j = 1; j <= n; j++) {
|
|
287
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
288
|
+
curr.push(Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost));
|
|
289
|
+
}
|
|
290
|
+
prev = curr;
|
|
291
|
+
}
|
|
292
|
+
return prev[n];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
normalizeOptionsDeclaration,
|
|
297
|
+
resolveEffectiveOptions,
|
|
298
|
+
parseCliOptions,
|
|
299
|
+
sortAndNormaliseOptions,
|
|
300
|
+
substituteTemplate,
|
|
301
|
+
substituteTemplateAll,
|
|
302
|
+
OPTION_NAME_RE,
|
|
303
|
+
};
|
package/src/profile.js
CHANGED
|
@@ -163,25 +163,49 @@ function detectProfile() {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
|
-
* Compute a stable 16-char SHA256 prefix
|
|
167
|
-
*
|
|
166
|
+
* Compute a stable 16-char SHA256 prefix identifying a unique build.
|
|
167
|
+
* Inputs:
|
|
168
|
+
* - `profile`: the toolchain/ABI fields (os, arch, compiler, etc.).
|
|
169
|
+
* - `options`: (optional) recipe-declared feature toggles resolved to
|
|
170
|
+
* their effective values for this build.
|
|
171
|
+
*
|
|
172
|
+
* Backward-compat contract: when `options` is null / undefined / empty,
|
|
173
|
+
* the hash is byte-identical to what pre-F1 wyvrnpm produced. Packages
|
|
174
|
+
* without an `options` block keep their existing registry addresses.
|
|
175
|
+
* The caller therefore passes `null` (not `{}`) to mean "this recipe
|
|
176
|
+
* has no options declared" — consistent with what
|
|
177
|
+
* `resolveEffectiveOptions` returns in that case.
|
|
178
|
+
*
|
|
179
|
+
* See claude/PLAN-OPTIONS.md §3.2 for the full design.
|
|
168
180
|
*
|
|
169
181
|
* @param {WyvrnProfile} profile
|
|
182
|
+
* @param {Record<string, any>|null} [options] effective options (after defaults + overrides)
|
|
170
183
|
* @returns {string} 16-character lowercase hex string
|
|
171
184
|
*/
|
|
172
|
-
function hashProfile(profile) {
|
|
185
|
+
function hashProfile(profile, options = null) {
|
|
173
186
|
const significant = Object.fromEntries(
|
|
174
187
|
Object.entries(profile)
|
|
175
188
|
.filter(([, v]) => v !== null && v !== undefined && v !== '*' && v !== '')
|
|
176
189
|
.sort(([a], [b]) => a.localeCompare(b)),
|
|
177
190
|
);
|
|
191
|
+
|
|
192
|
+
const payload = (options && Object.keys(options).length > 0)
|
|
193
|
+
? { ...significant, options: sortOptionsForHashing(options) }
|
|
194
|
+
: significant;
|
|
195
|
+
|
|
178
196
|
return crypto
|
|
179
197
|
.createHash('sha256')
|
|
180
|
-
.update(JSON.stringify(
|
|
198
|
+
.update(JSON.stringify(payload))
|
|
181
199
|
.digest('hex')
|
|
182
200
|
.slice(0, 16);
|
|
183
201
|
}
|
|
184
202
|
|
|
203
|
+
function sortOptionsForHashing(options) {
|
|
204
|
+
return Object.fromEntries(
|
|
205
|
+
Object.entries(options).sort(([a], [b]) => a.localeCompare(b)),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
/**
|
|
186
210
|
* Compute the SHA256 hash of a file or Buffer.
|
|
187
211
|
*
|
|
@@ -214,6 +238,33 @@ function getGitRepo(cwd) {
|
|
|
214
238
|
return tryExec(`git -C "${cwd || '.'}" remote get-url origin`) || null;
|
|
215
239
|
}
|
|
216
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Check whether `sha` is reachable from any remote tracking branch or tag.
|
|
243
|
+
* Used at publish time to warn when the publisher's HEAD hasn't been pushed
|
|
244
|
+
* — a commit that isn't reachable from origin cannot be retrieved by
|
|
245
|
+
* downstream `--build=missing` consumers.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} sha
|
|
248
|
+
* @param {string} [cwd]
|
|
249
|
+
* @returns {boolean} true if the commit is reachable on origin
|
|
250
|
+
*/
|
|
251
|
+
function isShaOnRemote(sha, cwd) {
|
|
252
|
+
if (!sha) return false;
|
|
253
|
+
// --remotes limits to remote-tracking branches; include tags too so
|
|
254
|
+
// tagged release commits on deleted branches still count as reachable.
|
|
255
|
+
const branches = tryExec(`git -C "${cwd || '.'}" branch -r --contains ${sha}`);
|
|
256
|
+
if (branches && branches.trim().length > 0) return true;
|
|
257
|
+
const tags = tryExec(`git -C "${cwd || '.'}" tag --contains ${sha}`);
|
|
258
|
+
if (tags && tags.trim().length > 0) {
|
|
259
|
+
// A local tag is only useful if it's also on origin. Check.
|
|
260
|
+
const remoteTags = tryExec(`git -C "${cwd || '.'}" ls-remote --tags origin`);
|
|
261
|
+
if (remoteTags && tags.split(/\s+/).some((t) => t && remoteTags.includes(`refs/tags/${t}`))) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
217
268
|
/**
|
|
218
269
|
* Format a profile for human-readable display (Conan-style).
|
|
219
270
|
*
|
|
@@ -354,6 +405,7 @@ module.exports = {
|
|
|
354
405
|
sha256Of,
|
|
355
406
|
getGitSha,
|
|
356
407
|
getGitRepo,
|
|
408
|
+
isShaOnRemote,
|
|
357
409
|
formatProfile,
|
|
358
410
|
mergeProfile,
|
|
359
411
|
// Profile file management
|
package/src/providers/base.js
CHANGED
|
@@ -69,7 +69,20 @@ class BaseProvider {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Publish to the v2 path.
|
|
72
|
-
* Writes wyvrn.json + wyvrn.zip under profileHash, updates versions.json and
|
|
72
|
+
* Writes wyvrn.json + wyvrn.zip under profileHash, updates versions.json and
|
|
73
|
+
* (when `updateLatest !== false`) latest.json.
|
|
74
|
+
*
|
|
75
|
+
* `updateLatest: false` is used by the `upload-built` flow (a consumer
|
|
76
|
+
* returning a source-built artefact — see PLAN-UPLOAD-BUILT.md). A consumer
|
|
77
|
+
* upload is NOT introducing a new package version, so `latest.json` must
|
|
78
|
+
* not be clobbered. For the same reason the `latest` pointer inside
|
|
79
|
+
* `versions.json` is also left alone. Defaults to `true` to preserve
|
|
80
|
+
* existing `publish` semantics.
|
|
81
|
+
*
|
|
82
|
+
* The optional `uploadedBy` field in `options` is written verbatim into
|
|
83
|
+
* the per-build `wyvrn.json` as provenance metadata. Absent by default
|
|
84
|
+
* (author publish); populated by the upload-built flow. Readers ignore
|
|
85
|
+
* the field — it is informational only.
|
|
73
86
|
*
|
|
74
87
|
* @param {{ manifest: string, zip: string }} files - local file paths
|
|
75
88
|
* @param {{
|
|
@@ -81,6 +94,8 @@ class BaseProvider {
|
|
|
81
94
|
* contentSha256: string,
|
|
82
95
|
* gitSha: string|null,
|
|
83
96
|
* gitRepo: string|null,
|
|
97
|
+
* updateLatest?: boolean, // default true; false for upload-built
|
|
98
|
+
* uploadedBy?: object, // optional provenance block, written into wyvrn.json
|
|
84
99
|
* awsProfile?: string,
|
|
85
100
|
* token?: string,
|
|
86
101
|
* }} options
|