wyvrnpm 2.4.1 → 2.9.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/README.md +414 -16
- package/bin/wyvrn.js +154 -11
- package/claude/skills/wyvrnpm.skill +0 -0
- package/cmake/variables.cmake +13 -3
- package/package.json +1 -1
- package/src/auth.js +66 -0
- package/src/build/cache.js +196 -0
- package/src/build/index.js +26 -4
- package/src/build/msvc-env.js +50 -7
- package/src/build/recipe.js +36 -3
- package/src/commands/add.js +2 -1
- package/src/commands/build.js +162 -36
- package/src/commands/cache.js +189 -0
- package/src/commands/configure.js +15 -4
- package/src/commands/init.js +36 -0
- package/src/commands/install-skill.js +8 -0
- package/src/commands/install.js +674 -400
- package/src/commands/link.js +320 -319
- package/src/commands/profile.js +237 -174
- package/src/commands/publish.js +435 -383
- package/src/commands/show.js +24 -9
- package/src/conf/index.js +391 -0
- package/src/conf/namespaces.js +94 -0
- package/src/context.js +230 -0
- package/src/download.js +135 -147
- package/src/http-fetch.js +53 -0
- package/src/logger.js +30 -2
- package/src/manifest.js +34 -7
- package/src/profile.js +148 -12
- package/src/providers/file.js +5 -19
- package/src/providers/http.js +26 -26
- package/src/providers/s3.js +29 -25
- package/src/resolve.js +47 -7
- package/src/settings-overrides.js +152 -0
- package/src/toolchain/presets.js +71 -31
- package/src/upload-built.js +11 -2
- package/src/zip-safe.js +52 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared `fetch` wrapper used by every resolver / downloader call site.
|
|
4
|
+
//
|
|
5
|
+
// 2.8.3 shipped with a split between the HTTP provider (which uses
|
|
6
|
+
// node:https directly and sends `User-Agent: wyvrnpm/<ver>` + no
|
|
7
|
+
// `Accept-Encoding`) and the resolver (which used the bare global
|
|
8
|
+
// `fetch` — undici — defaulting to `Accept-Encoding: gzip, deflate,
|
|
9
|
+
// br`). CloudFront caches a **separate entry per response encoding**,
|
|
10
|
+
// so a `versions.json` freshly updated at origin could be stale in the
|
|
11
|
+
// Brotli cache bucket for up to the full TTL while the identity bucket
|
|
12
|
+
// served fresh bytes. The provider calls (show) happened to land in
|
|
13
|
+
// the identity bucket (fresh); the resolver calls (install, add)
|
|
14
|
+
// landed in the Brotli bucket (stale).
|
|
15
|
+
//
|
|
16
|
+
// Mitigation: force `Accept-Encoding: identity`. Manifest JSONs are
|
|
17
|
+
// small (KB to tens of KB) so the lost compression is negligible, and
|
|
18
|
+
// one encoding means one cache bucket — no split-brain. User-Agent +
|
|
19
|
+
// `Accept: application/json` are included for good measure (CloudFront
|
|
20
|
+
// + some WAFs 403 UA-less requests).
|
|
21
|
+
//
|
|
22
|
+
// Defence-in-depth: src/providers/s3.js caps every manifest-JSON PUT
|
|
23
|
+
// at `CacheControl: public, max-age=300` (2.8.4), so the worst-case
|
|
24
|
+
// staleness for any future publish is 5 min regardless of which
|
|
25
|
+
// cache bucket a client lands in.
|
|
26
|
+
//
|
|
27
|
+
// See claude/PLAN-RESOLVER-HTTP-UNIFY.md for the follow-up plan to
|
|
28
|
+
// route all resolver HTTP through the provider API and retire this
|
|
29
|
+
// wrapper.
|
|
30
|
+
|
|
31
|
+
const USER_AGENT = `wyvrnpm/${require('../package.json').version}`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Drop-in replacement for the global `fetch`. Injects wyvrnpm's
|
|
35
|
+
* `User-Agent`, an `Accept: application/json` hint, and forces
|
|
36
|
+
* `Accept-Encoding: identity` to avoid CloudFront's per-encoding
|
|
37
|
+
* cache-key split. Caller-supplied headers win on collision.
|
|
38
|
+
*
|
|
39
|
+
* @param {string | URL} url
|
|
40
|
+
* @param {RequestInit} [init]
|
|
41
|
+
* @returns {Promise<Response>}
|
|
42
|
+
*/
|
|
43
|
+
function wyvrnFetch(url, init = {}) {
|
|
44
|
+
const headers = {
|
|
45
|
+
'User-Agent': USER_AGENT,
|
|
46
|
+
Accept: 'application/json',
|
|
47
|
+
'Accept-Encoding': 'identity',
|
|
48
|
+
...(init.headers || {}),
|
|
49
|
+
};
|
|
50
|
+
return fetch(url, { ...init, headers });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { wyvrnFetch, USER_AGENT };
|
package/src/logger.js
CHANGED
|
@@ -11,6 +11,32 @@
|
|
|
11
11
|
|
|
12
12
|
const PREFIX = '[wyvrn]';
|
|
13
13
|
|
|
14
|
+
// ── Sensitive-value redaction (EVALUATION.md S8) ──────────────────────────────
|
|
15
|
+
// Defense-in-depth against a token leaking into logs or error traces. Nothing
|
|
16
|
+
// in wyvrnpm deliberately logs tokens today, but callers sometimes paste full
|
|
17
|
+
// commands + error output into bug reports, and some future error path might
|
|
18
|
+
// stringify argv. Two narrow patterns cover every format we handle:
|
|
19
|
+
//
|
|
20
|
+
// `--token <value>` — CLI leak
|
|
21
|
+
// `Authorization: Bearer <value>` — HTTP header leak
|
|
22
|
+
// `Bearer <value>` (standalone) — header fragment leak
|
|
23
|
+
//
|
|
24
|
+
// Redaction is a last-line mitigation — the primary fix is `--token-env` and
|
|
25
|
+
// per-source `tokenEnv` so the literal value never lands in argv to begin with.
|
|
26
|
+
const TOKEN_PATTERNS = [
|
|
27
|
+
[/(--token(?:-env)?[= ]+)\S+/g, '$1***'],
|
|
28
|
+
[/(Authorization\s*:\s*Bearer\s+)\S+/gi, '$1***'],
|
|
29
|
+
[/(\bBearer\s+)[A-Za-z0-9._\-+/=]{16,}/g, '$1***'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function redactSensitive(text) {
|
|
33
|
+
let out = String(text);
|
|
34
|
+
for (const [pattern, replacement] of TOKEN_PATTERNS) {
|
|
35
|
+
out = out.replace(pattern, replacement);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
const ANSI = {
|
|
15
41
|
reset: '\x1b[0m',
|
|
16
42
|
bold: '\x1b[1m',
|
|
@@ -49,7 +75,9 @@ function paint(text, code, stream) {
|
|
|
49
75
|
function emit(stream, consoleFn, coloredPrefix, message) {
|
|
50
76
|
// Every line in a multi-line message gets the prefix — so multi-line errors
|
|
51
77
|
// line up visually without the caller having to repeat `[wyvrn]` per line.
|
|
52
|
-
|
|
78
|
+
// Redaction runs before the prefix/colour is applied so patterns like
|
|
79
|
+
// `--token xyz` match regardless of how the caller formatted the line.
|
|
80
|
+
const out = redactSensitive(message)
|
|
53
81
|
.split('\n')
|
|
54
82
|
.map((line) => `${coloredPrefix} ${line}`)
|
|
55
83
|
.join('\n');
|
|
@@ -91,4 +119,4 @@ function debug(message) {
|
|
|
91
119
|
emit(process.stderr, console.error, `${p} ${tag}`, message);
|
|
92
120
|
}
|
|
93
121
|
|
|
94
|
-
module.exports = { info, warn, error, success, debug, setJsonMode, isJsonMode };
|
|
122
|
+
module.exports = { info, warn, error, success, debug, setJsonMode, isJsonMode, redactSensitive };
|
package/src/manifest.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
+
const { readRecipeConf } = require('./conf');
|
|
7
|
+
|
|
6
8
|
/** Current manifest schema version emitted by v2 tooling. */
|
|
7
9
|
const SCHEMA_VERSION = 2;
|
|
8
10
|
|
|
@@ -52,18 +54,21 @@ function defaultManifest(name) {
|
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
56
|
* Normalise the raw `dependencies` field from a manifest into a consistent
|
|
55
|
-
* `{ name → { version, settings, options } }` map, regardless of
|
|
57
|
+
* `{ name → { version, settings, options, source? } }` map, regardless of
|
|
58
|
+
* input format:
|
|
56
59
|
*
|
|
57
|
-
* • String value : "1.0.0"
|
|
58
|
-
* • Object value : { version, settings?, options? } → kept (missing keys default to {})
|
|
59
|
-
* • Array format : [{ Name, Version }]
|
|
60
|
+
* • String value : "1.0.0" → { version: "1.0.0", settings: {}, options: {} }
|
|
61
|
+
* • Object value : { version, settings?, options?, source? } → kept (missing keys default to {})
|
|
62
|
+
* • Array format : [{ Name, Version }] → converted (no settings/options)
|
|
60
63
|
*
|
|
61
64
|
* `settings` merges on top of the active profile for this dep only.
|
|
62
65
|
* `options` maps to the recipe's declared options; validated against
|
|
63
66
|
* the recipe's `allowed` list at resolve time (see src/options.js).
|
|
67
|
+
* `source` (EVALUATION.md S4) pins the dep to a named install source
|
|
68
|
+
* (e.g. `"primary-s3"`); absent = fan out across all configured sources.
|
|
64
69
|
*
|
|
65
70
|
* @param {object|Array|undefined} rawDeps
|
|
66
|
-
* @returns {Record<string, { version: string, settings: object, options: object }>}
|
|
71
|
+
* @returns {Record<string, { version: string, settings: object, options: object, source?: string }>}
|
|
67
72
|
*/
|
|
68
73
|
function normalizeDependencies(rawDeps) {
|
|
69
74
|
if (!rawDeps || typeof rawDeps !== 'object') return {};
|
|
@@ -83,11 +88,17 @@ function normalizeDependencies(rawDeps) {
|
|
|
83
88
|
if (typeof value === 'string') {
|
|
84
89
|
result[n] = { version: value, settings: {}, options: {} };
|
|
85
90
|
} else if (value && typeof value === 'object') {
|
|
86
|
-
|
|
91
|
+
const entry = {
|
|
87
92
|
version: value.version ?? value.Version ?? '',
|
|
88
93
|
settings: value.settings ?? {},
|
|
89
94
|
options: value.options ?? {},
|
|
90
95
|
};
|
|
96
|
+
// Per-package source pin (S4). Only keep it if it's a non-empty
|
|
97
|
+
// string so {} / null / 0 don't silently pin to a nonsense value.
|
|
98
|
+
if (typeof value.source === 'string' && value.source.length > 0) {
|
|
99
|
+
entry.source = value.source;
|
|
100
|
+
}
|
|
101
|
+
result[n] = entry;
|
|
91
102
|
}
|
|
92
103
|
}
|
|
93
104
|
return result;
|
|
@@ -106,11 +117,27 @@ function readManifest(manifestPath) {
|
|
|
106
117
|
throw new Error(`Manifest not found: ${resolved}`);
|
|
107
118
|
}
|
|
108
119
|
const raw = fs.readFileSync(resolved, 'utf8');
|
|
120
|
+
let parsed;
|
|
109
121
|
try {
|
|
110
|
-
|
|
122
|
+
parsed = JSON.parse(raw);
|
|
111
123
|
} catch (err) {
|
|
112
124
|
throw new Error(`Failed to parse manifest at ${resolved}: ${err.message}`);
|
|
113
125
|
}
|
|
126
|
+
|
|
127
|
+
// Validate the `conf` block (PLAN-CONF.md phase 1.2). Only runs for
|
|
128
|
+
// local manifests — registry-side manifests fetched via resolve.js /
|
|
129
|
+
// download.js go through fetch().json() and deliberately skip this so
|
|
130
|
+
// a consumer with an older wyvrnpm doesn't fail on a publisher's
|
|
131
|
+
// forward-compatible conf keys.
|
|
132
|
+
if (parsed && typeof parsed === 'object' && parsed.conf !== undefined) {
|
|
133
|
+
try {
|
|
134
|
+
readRecipeConf(parsed);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw new Error(`Invalid \`conf\` block in ${resolved}: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return parsed;
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
/**
|
package/src/profile.js
CHANGED
|
@@ -162,6 +162,13 @@ function detectProfile() {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Profile fields that participate in profileHash — mirror Conan's
|
|
166
|
+
// `[settings]` strictly. Anything else at the top level (e.g. `conf`)
|
|
167
|
+
// is non-ABI and must never fold into the hash (PLAN-CONF.md constitution).
|
|
168
|
+
const HASHED_PROFILE_FIELDS = new Set([
|
|
169
|
+
'os', 'arch', 'compiler', 'compiler.version', 'compiler.cppstd', 'compiler.runtime',
|
|
170
|
+
]);
|
|
171
|
+
|
|
165
172
|
/**
|
|
166
173
|
* Compute a stable 16-char SHA256 prefix identifying a unique build.
|
|
167
174
|
* Inputs:
|
|
@@ -169,14 +176,19 @@ function detectProfile() {
|
|
|
169
176
|
* - `options`: (optional) recipe-declared feature toggles resolved to
|
|
170
177
|
* their effective values for this build.
|
|
171
178
|
*
|
|
172
|
-
* Backward-compat contract:
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
179
|
+
* Backward-compat contract:
|
|
180
|
+
* - When `options` is null / undefined / empty, the hash is
|
|
181
|
+
* byte-identical to what pre-F1 wyvrnpm produced. Packages
|
|
182
|
+
* without an `options` block keep their existing registry
|
|
183
|
+
* addresses. The caller passes `null` (not `{}`) to mean "this
|
|
184
|
+
* recipe has no options declared" — consistent with what
|
|
185
|
+
* `resolveEffectiveOptions` returns in that case.
|
|
186
|
+
* - When a profile carries a `conf` block (phase 2 — PLAN-CONF.md),
|
|
187
|
+
* the hash is byte-identical to the same profile WITHOUT `conf`.
|
|
188
|
+
* Build-time knobs are non-ABI by design (constitution).
|
|
178
189
|
*
|
|
179
|
-
* See claude/PLAN-OPTIONS.md §3.2 for the
|
|
190
|
+
* See claude/PLAN-OPTIONS.md §3.2 and claude/PLAN-CONF.md for the
|
|
191
|
+
* full design.
|
|
180
192
|
*
|
|
181
193
|
* @param {WyvrnProfile} profile
|
|
182
194
|
* @param {Record<string, any>|null} [options] effective options (after defaults + overrides)
|
|
@@ -185,7 +197,10 @@ function detectProfile() {
|
|
|
185
197
|
function hashProfile(profile, options = null) {
|
|
186
198
|
const significant = Object.fromEntries(
|
|
187
199
|
Object.entries(profile)
|
|
188
|
-
.filter(([, v]) =>
|
|
200
|
+
.filter(([k, v]) =>
|
|
201
|
+
HASHED_PROFILE_FIELDS.has(k) &&
|
|
202
|
+
v !== null && v !== undefined && v !== '*' && v !== '',
|
|
203
|
+
)
|
|
189
204
|
.sort(([a], [b]) => a.localeCompare(b)),
|
|
190
205
|
);
|
|
191
206
|
|
|
@@ -267,6 +282,8 @@ function isShaOnRemote(sha, cwd) {
|
|
|
267
282
|
|
|
268
283
|
/**
|
|
269
284
|
* Format a profile for human-readable display (Conan-style).
|
|
285
|
+
* Emits a `[conf]` block with flat dotted keys when the profile
|
|
286
|
+
* carries a non-empty `conf` block (PLAN-CONF.md phase 2).
|
|
270
287
|
*
|
|
271
288
|
* @param {WyvrnProfile} profile
|
|
272
289
|
* @returns {string}
|
|
@@ -282,6 +299,23 @@ function formatProfile(profile) {
|
|
|
282
299
|
lines.push(`${key}=${val}`);
|
|
283
300
|
}
|
|
284
301
|
}
|
|
302
|
+
|
|
303
|
+
// [conf] block, Conan-style. Rendered flat so it's clear these are
|
|
304
|
+
// dotted namespaced keys, not nested JSON. Alphabetical for stable diffs.
|
|
305
|
+
const confBlock = profile.conf;
|
|
306
|
+
if (confBlock && typeof confBlock === 'object' && !Array.isArray(confBlock)) {
|
|
307
|
+
// Lazy-require to avoid circular dep (src/conf/* → src/manifest → src/profile).
|
|
308
|
+
const { flattenConf } = require('./conf');
|
|
309
|
+
let flat;
|
|
310
|
+
try { flat = flattenConf(confBlock); } catch { flat = null; }
|
|
311
|
+
if (flat && Object.keys(flat).length > 0) {
|
|
312
|
+
lines.push('[conf]');
|
|
313
|
+
for (const key of Object.keys(flat).sort()) {
|
|
314
|
+
lines.push(`${key}=${flat[key]}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
285
319
|
return lines.join('\n');
|
|
286
320
|
}
|
|
287
321
|
|
|
@@ -386,15 +420,116 @@ function listProfiles() {
|
|
|
386
420
|
}
|
|
387
421
|
|
|
388
422
|
/**
|
|
389
|
-
*
|
|
390
|
-
*
|
|
423
|
+
* Resolve a profile-inheritance chain (F8). A profile may declare
|
|
424
|
+
*
|
|
425
|
+
* { "extends": "base-profile", ... }
|
|
426
|
+
* { "extends": ["base1", "base2"], ... } // later wins over earlier
|
|
427
|
+
*
|
|
428
|
+
* `extends` is resolved recursively; the child's fields overlay the
|
|
429
|
+
* merged parent(s). Cycle detection throws a clear error. Missing
|
|
430
|
+
* parents throw — we don't silently drop `extends`, that would hide
|
|
431
|
+
* typos.
|
|
432
|
+
*
|
|
433
|
+
* Merge rules:
|
|
434
|
+
* - `[settings]` fields: child overrides parent, per-key.
|
|
435
|
+
* - `conf` block: parent + child flattened with child wins per-leaf
|
|
436
|
+
* (same pattern as PLAN-CONF.md §4.2 within the single profile).
|
|
437
|
+
* - `extends` itself is stripped from the resolved output — the
|
|
438
|
+
* returned object carries no inheritance metadata, so existing
|
|
439
|
+
* consumers (formatProfile, hashProfile, readProfileConf) work
|
|
440
|
+
* unchanged.
|
|
441
|
+
*
|
|
442
|
+
* @param {string} name
|
|
443
|
+
* @param {string[]} [stack] visited names — for cycle detection
|
|
444
|
+
* @returns {WyvrnProfile|null} Fully-resolved profile, or null if `name`
|
|
445
|
+
* does not exist on disk at the root of the chain.
|
|
446
|
+
*/
|
|
447
|
+
function resolveProfileChain(name, stack = []) {
|
|
448
|
+
if (stack.includes(name)) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`profile inheritance cycle: ${[...stack, name].join(' → ')}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const raw = readProfile(name);
|
|
455
|
+
if (!raw) return null;
|
|
456
|
+
|
|
457
|
+
const rawExtends = raw.extends;
|
|
458
|
+
if (rawExtends === undefined || rawExtends === null) {
|
|
459
|
+
// Flat profile — just strip any accidental `extends` and return.
|
|
460
|
+
const { extends: _, ...rest } = raw;
|
|
461
|
+
return rest;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const parents = Array.isArray(rawExtends) ? rawExtends : [rawExtends];
|
|
465
|
+
for (const p of parents) {
|
|
466
|
+
if (typeof p !== 'string' || p.length === 0) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`profile "${name}": extends entries must be non-empty strings, got ${JSON.stringify(p)}`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Merge parents left-to-right, then overlay the child.
|
|
474
|
+
let merged = {};
|
|
475
|
+
for (const parentName of parents) {
|
|
476
|
+
const parent = resolveProfileChain(parentName, [...stack, name]);
|
|
477
|
+
if (!parent) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
`profile "${name}": extends parent "${parentName}" not found ` +
|
|
480
|
+
`at ${getProfilesDir()}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
merged = mergeResolvedProfiles(merged, parent);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const { extends: _, ...child } = raw;
|
|
487
|
+
return mergeResolvedProfiles(merged, child);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Merge two already-resolved profile objects. Top-level `[settings]`
|
|
492
|
+
* fields: right wins. `conf` block: per-leaf merge, right wins.
|
|
493
|
+
* @param {object} base
|
|
494
|
+
* @param {object} overlay
|
|
495
|
+
* @returns {object}
|
|
496
|
+
*/
|
|
497
|
+
function mergeResolvedProfiles(base, overlay) {
|
|
498
|
+
const out = { ...base, ...overlay };
|
|
499
|
+
// Nullable override semantics: `null` on the overlay explicitly
|
|
500
|
+
// unsets a field from the base (matches how Conan profiles
|
|
501
|
+
// handle `compiler.runtime=None`).
|
|
502
|
+
for (const k of Object.keys(overlay)) {
|
|
503
|
+
if (overlay[k] === null) delete out[k];
|
|
504
|
+
}
|
|
505
|
+
// Conf block is merged per-leaf, not per-branch.
|
|
506
|
+
if (base.conf || overlay.conf) {
|
|
507
|
+
const { flattenConf, unflattenConf } = require('./conf');
|
|
508
|
+
let baseFlat = {};
|
|
509
|
+
let overlayFlat = {};
|
|
510
|
+
try { if (base.conf) baseFlat = flattenConf(base.conf); } catch { /* leave empty */ }
|
|
511
|
+
try { if (overlay.conf) overlayFlat = flattenConf(overlay.conf); } catch { /* leave empty */ }
|
|
512
|
+
const merged = { ...baseFlat, ...overlayFlat };
|
|
513
|
+
if (Object.keys(merged).length > 0) {
|
|
514
|
+
out.conf = unflattenConf(merged);
|
|
515
|
+
} else {
|
|
516
|
+
delete out.conf;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Load the active build profile by name, resolving any `extends` chain.
|
|
524
|
+
* Falls back to auto-detection if the named profile file does not
|
|
525
|
+
* exist yet.
|
|
391
526
|
*
|
|
392
527
|
* @param {string} [name='default']
|
|
393
528
|
* @returns {{ profile: WyvrnProfile, fromFile: boolean }}
|
|
394
529
|
*/
|
|
395
530
|
function loadProfile(name = 'default') {
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
531
|
+
const resolved = resolveProfileChain(name);
|
|
532
|
+
if (resolved) return { profile: resolved, fromFile: true };
|
|
398
533
|
return { profile: detectProfile(), fromFile: false };
|
|
399
534
|
}
|
|
400
535
|
|
|
@@ -415,6 +550,7 @@ module.exports = {
|
|
|
415
550
|
deleteProfile,
|
|
416
551
|
listProfiles,
|
|
417
552
|
loadProfile,
|
|
553
|
+
resolveProfileChain,
|
|
418
554
|
};
|
|
419
555
|
|
|
420
556
|
/**
|
package/src/providers/file.js
CHANGED
|
@@ -16,21 +16,6 @@ const log = require('../logger');
|
|
|
16
16
|
* //server/share/path (SMB UNC — forward slash)
|
|
17
17
|
* file:///path (file URI)
|
|
18
18
|
*/
|
|
19
|
-
function pinDependencies(rawDeps, lockedDeps) {
|
|
20
|
-
if (!rawDeps || !lockedDeps || Object.keys(lockedDeps).length === 0) return rawDeps;
|
|
21
|
-
const result = {};
|
|
22
|
-
for (const [pkgName, value] of Object.entries(rawDeps)) {
|
|
23
|
-
const locked = lockedDeps[pkgName];
|
|
24
|
-
if (locked) {
|
|
25
|
-
result[pkgName] = typeof value === 'object' && value !== null
|
|
26
|
-
? { ...value, version: locked }
|
|
27
|
-
: locked;
|
|
28
|
-
} else {
|
|
29
|
-
result[pkgName] = value;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return result;
|
|
33
|
-
}
|
|
34
19
|
|
|
35
20
|
class FileProvider extends BaseProvider {
|
|
36
21
|
static canHandle(source) {
|
|
@@ -106,7 +91,7 @@ class FileProvider extends BaseProvider {
|
|
|
106
91
|
async v2Publish(files, options) {
|
|
107
92
|
const {
|
|
108
93
|
source, name, version, profileHash, buildSettings,
|
|
109
|
-
contentSha256, gitSha, gitRepo,
|
|
94
|
+
contentSha256, gitSha, gitRepo, publishedLock,
|
|
110
95
|
updateLatest = true, uploadedBy,
|
|
111
96
|
} = options;
|
|
112
97
|
|
|
@@ -118,12 +103,12 @@ class FileProvider extends BaseProvider {
|
|
|
118
103
|
this._copy(files.zip, path.join(buildDir, 'wyvrn.zip'));
|
|
119
104
|
|
|
120
105
|
// 2) enriched wyvrn.json (buildSettings stored for debugging only)
|
|
121
|
-
// Dependencies are
|
|
106
|
+
// Dependencies are published verbatim from wyvrn.json — ranges stay as
|
|
107
|
+
// ranges so consumers can intersect them with their own. The author's
|
|
108
|
+
// lockfile snapshot is preserved in `publishedLock` for audit.
|
|
122
109
|
const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
|
|
123
|
-
const pinnedDeps = pinDependencies(rawManifest.dependencies, lockedDependencies);
|
|
124
110
|
const v2Meta = {
|
|
125
111
|
...rawManifest,
|
|
126
|
-
...(pinnedDeps !== undefined ? { dependencies: pinnedDeps } : {}),
|
|
127
112
|
schemaVersion: 2,
|
|
128
113
|
profileHash,
|
|
129
114
|
buildSettings,
|
|
@@ -131,6 +116,7 @@ class FileProvider extends BaseProvider {
|
|
|
131
116
|
gitSha: gitSha ?? null,
|
|
132
117
|
gitRepo: gitRepo ?? null,
|
|
133
118
|
publishedAt: new Date().toISOString(),
|
|
119
|
+
...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
|
|
134
120
|
...(uploadedBy ? { uploadedBy } : {}),
|
|
135
121
|
};
|
|
136
122
|
this._writeJson(path.join(buildDir, 'wyvrn.json'), v2Meta);
|
package/src/providers/http.js
CHANGED
|
@@ -8,21 +8,11 @@ const { Readable } = require('stream');
|
|
|
8
8
|
const BaseProvider = require('./base');
|
|
9
9
|
const log = require('../logger');
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (locked) {
|
|
17
|
-
result[pkgName] = typeof value === 'object' && value !== null
|
|
18
|
-
? { ...value, version: locked }
|
|
19
|
-
: locked;
|
|
20
|
-
} else {
|
|
21
|
-
result[pkgName] = value;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return result;
|
|
25
|
-
}
|
|
11
|
+
// CloudFront / AWS WAF (and some other fronts) 403 requests that lack a
|
|
12
|
+
// User-Agent header, so we MUST send one on every outbound call. Failing to
|
|
13
|
+
// do so means `_getJson` swallows the 403 as `null` and the compat path
|
|
14
|
+
// silently treats the registry as empty.
|
|
15
|
+
const USER_AGENT = `wyvrnpm/${require('../../package.json').version}`;
|
|
26
16
|
|
|
27
17
|
class HttpProvider extends BaseProvider {
|
|
28
18
|
static canHandle(source) {
|
|
@@ -44,7 +34,7 @@ class HttpProvider extends BaseProvider {
|
|
|
44
34
|
return new Promise((resolve, reject) => {
|
|
45
35
|
const body = fs.readFileSync(filePath);
|
|
46
36
|
const parsed = new URL(url);
|
|
47
|
-
const headers = { 'Content-Type': contentType, 'Content-Length': body.length };
|
|
37
|
+
const headers = { 'User-Agent': USER_AGENT, 'Content-Type': contentType, 'Content-Length': body.length };
|
|
48
38
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
49
39
|
log.info(` → PUT ${url}`);
|
|
50
40
|
const req = this._lib(url).request(
|
|
@@ -65,7 +55,7 @@ class HttpProvider extends BaseProvider {
|
|
|
65
55
|
_putBuffer(url, buffer, contentType, token) {
|
|
66
56
|
return new Promise((resolve, reject) => {
|
|
67
57
|
const parsed = new URL(url);
|
|
68
|
-
const headers = { 'Content-Type': contentType, 'Content-Length': buffer.length };
|
|
58
|
+
const headers = { 'User-Agent': USER_AGENT, 'Content-Type': contentType, 'Content-Length': buffer.length };
|
|
69
59
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
70
60
|
log.info(` → PUT ${url}`);
|
|
71
61
|
const req = this._lib(url).request(
|
|
@@ -86,7 +76,7 @@ class HttpProvider extends BaseProvider {
|
|
|
86
76
|
_head(url, token) {
|
|
87
77
|
return new Promise((resolve, reject) => {
|
|
88
78
|
const parsed = new URL(url);
|
|
89
|
-
const headers = {};
|
|
79
|
+
const headers = { 'User-Agent': USER_AGENT };
|
|
90
80
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
91
81
|
const req = this._lib(url).request(
|
|
92
82
|
{ hostname: parsed.hostname, port: parsed.port || undefined, path: parsed.pathname, method: 'HEAD', headers },
|
|
@@ -104,13 +94,22 @@ class HttpProvider extends BaseProvider {
|
|
|
104
94
|
_getJson(url, token) {
|
|
105
95
|
return new Promise((resolve) => {
|
|
106
96
|
const parsed = new URL(url);
|
|
107
|
-
const headers = { Accept: 'application/json' };
|
|
97
|
+
const headers = { 'User-Agent': USER_AGENT, Accept: 'application/json' };
|
|
108
98
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
109
99
|
const req = this._lib(url).request(
|
|
110
100
|
{ hostname: parsed.hostname, port: parsed.port || undefined, path: parsed.pathname, method: 'GET', headers },
|
|
111
101
|
(res) => {
|
|
112
102
|
if (res.statusCode === 404) { res.resume(); resolve(null); return; }
|
|
113
|
-
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
103
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
104
|
+
// A 404 is "not found" — expected. Anything else (403 from a WAF,
|
|
105
|
+
// 5xx, etc.) is surprising and should NOT be swallowed silently,
|
|
106
|
+
// or the compat path will treat the registry as empty. See the
|
|
107
|
+
// CloudFront UA-block case that motivated the default User-Agent.
|
|
108
|
+
log.warn(`HTTP GET ${url} → ${res.statusCode}; treating as missing`);
|
|
109
|
+
res.resume();
|
|
110
|
+
resolve(null);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
114
113
|
const chunks = [];
|
|
115
114
|
res.on('data', (c) => chunks.push(c));
|
|
116
115
|
res.on('end', () => {
|
|
@@ -129,7 +128,7 @@ class HttpProvider extends BaseProvider {
|
|
|
129
128
|
const controller = new AbortController();
|
|
130
129
|
const timer = setTimeout(() => controller.abort(), timeoutMs ?? 300_000);
|
|
131
130
|
try {
|
|
132
|
-
const headers = {};
|
|
131
|
+
const headers = { 'User-Agent': USER_AGENT };
|
|
133
132
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
134
133
|
const response = await fetch(url, { signal: controller.signal, headers });
|
|
135
134
|
if (!response.ok) return false;
|
|
@@ -175,7 +174,7 @@ class HttpProvider extends BaseProvider {
|
|
|
175
174
|
async v2Publish(files, options) {
|
|
176
175
|
const {
|
|
177
176
|
source, name, version, profileHash, buildSettings,
|
|
178
|
-
contentSha256, gitSha, gitRepo,
|
|
177
|
+
contentSha256, gitSha, gitRepo, publishedLock, token,
|
|
179
178
|
updateLatest = true, uploadedBy,
|
|
180
179
|
} = options;
|
|
181
180
|
const base = source.replace(/\/$/, '');
|
|
@@ -185,14 +184,14 @@ class HttpProvider extends BaseProvider {
|
|
|
185
184
|
// 1) Upload the binary zip
|
|
186
185
|
await this._put(`${buildRoot}/wyvrn.zip`, files.zip, 'application/zip', token);
|
|
187
186
|
|
|
188
|
-
// 2) Upload enriched wyvrn.json (original manifest + v2 metadata)
|
|
187
|
+
// 2) Upload enriched wyvrn.json (original manifest + v2 metadata).
|
|
189
188
|
// buildSettings is stored for debugging purposes only — not for configuration.
|
|
190
|
-
// Dependencies are
|
|
189
|
+
// Dependencies are published verbatim from wyvrn.json — ranges stay as
|
|
190
|
+
// ranges so consumers can intersect them with their own. The author's
|
|
191
|
+
// lockfile snapshot is preserved in `publishedLock` for audit.
|
|
191
192
|
const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
|
|
192
|
-
const pinnedDeps = pinDependencies(rawManifest.dependencies, lockedDependencies);
|
|
193
193
|
const v2Meta = {
|
|
194
194
|
...rawManifest,
|
|
195
|
-
...(pinnedDeps !== undefined ? { dependencies: pinnedDeps } : {}),
|
|
196
195
|
schemaVersion: 2,
|
|
197
196
|
profileHash,
|
|
198
197
|
buildSettings, // copy of the profile used — for debugging
|
|
@@ -200,6 +199,7 @@ class HttpProvider extends BaseProvider {
|
|
|
200
199
|
gitSha: gitSha ?? null,
|
|
201
200
|
gitRepo: gitRepo ?? null,
|
|
202
201
|
publishedAt: new Date().toISOString(),
|
|
202
|
+
...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
|
|
203
203
|
...(uploadedBy ? { uploadedBy } : {}),
|
|
204
204
|
};
|
|
205
205
|
await this._putBuffer(
|