wyvrnpm 2.8.1 → 2.10.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 +75 -4
- package/bin/wyvrn.js +93 -2
- package/claude/skills/wyvrnpm.skill +0 -0
- package/package.json +1 -1
- package/src/bootstrap/cookbook.js +196 -0
- package/src/bootstrap/detect.js +150 -0
- package/src/bootstrap/index.js +220 -0
- package/src/bootstrap/version.js +72 -0
- package/src/build/index.js +3 -2
- package/src/commands/add.js +2 -1
- package/src/commands/bootstrap.js +96 -0
- package/src/commands/build.js +109 -44
- package/src/commands/install.js +674 -665
- package/src/commands/link.js +320 -319
- package/src/commands/profile.js +237 -237
- package/src/commands/publish.js +435 -420
- package/src/commands/show.js +18 -13
- package/src/context.js +230 -0
- package/src/http-fetch.js +53 -0
- package/src/providers/file.js +5 -19
- package/src/providers/http.js +26 -26
- package/src/providers/s3.js +29 -25
package/src/commands/show.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
const {
|
|
5
|
-
const
|
|
6
|
-
const log = require('../logger');
|
|
3
|
+
const { getProvider } = require('../providers');
|
|
4
|
+
const { buildContext } = require('../context');
|
|
5
|
+
const log = require('../logger');
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* `wyvrnpm show <pkg>` — surface registry metadata for a package.
|
|
@@ -37,10 +36,19 @@ async function show(argv) {
|
|
|
37
36
|
process.exit(1);
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// Shared per-invocation state (config, auth, JSON-mode toggle). `show`
|
|
40
|
+
// never reads the manifest, which is why ctx.manifest is lazy. See
|
|
41
|
+
// src/context.js + claude/PLAN-COMMAND-CONTEXT.md.
|
|
42
|
+
let ctx;
|
|
43
|
+
try {
|
|
44
|
+
ctx = buildContext(argv);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
log.error(err.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const jsonOut = ctx.flags.format === 'json';
|
|
42
50
|
|
|
43
|
-
const { sources, awsProfile, token } = resolveSources(argv);
|
|
51
|
+
const { sources, awsProfile, token } = resolveSources(argv, ctx);
|
|
44
52
|
if (sources.length === 0) {
|
|
45
53
|
log.error(
|
|
46
54
|
'no sources configured. ' +
|
|
@@ -199,8 +207,8 @@ function parseSpec(spec) {
|
|
|
199
207
|
};
|
|
200
208
|
}
|
|
201
209
|
|
|
202
|
-
function resolveSources(argv) {
|
|
203
|
-
const config =
|
|
210
|
+
function resolveSources(argv, ctx) {
|
|
211
|
+
const { config } = ctx;
|
|
204
212
|
const sources = (argv.source && argv.source.length > 0)
|
|
205
213
|
? argv.source
|
|
206
214
|
: config.installSources.map((s) => s.url);
|
|
@@ -209,10 +217,7 @@ function resolveSources(argv) {
|
|
|
209
217
|
return {
|
|
210
218
|
sources,
|
|
211
219
|
awsProfile: argv.awsProfile ?? firstConfig?.profile,
|
|
212
|
-
token:
|
|
213
|
-
{ token: argv.token, tokenEnv: argv.tokenEnv },
|
|
214
|
-
firstConfig,
|
|
215
|
-
).token,
|
|
220
|
+
token: ctx.auth.for(firstConfig).token,
|
|
216
221
|
};
|
|
217
222
|
}
|
|
218
223
|
|
package/src/context.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CommandContext — the shared per-invocation state every command consumes.
|
|
5
|
+
*
|
|
6
|
+
* Commands used to each re-read the manifest, re-resolve the profile name,
|
|
7
|
+
* re-read config, re-resolve auth, and each toggle JSON mode on the logger.
|
|
8
|
+
* The divergence was cheap at 11 commands; it starts costing real bugs
|
|
9
|
+
* once F3 (`tool_requires`) and F4 (build/host profile split) double the
|
|
10
|
+
* flag surface. See [claude/PLAN-COMMAND-CONTEXT.md](../claude/PLAN-COMMAND-CONTEXT.md).
|
|
11
|
+
*
|
|
12
|
+
* `buildContext(argv)` runs once at the top of each command. It:
|
|
13
|
+
* - resolves filesystem anchors (rootDir, manifestPath)
|
|
14
|
+
* - reads config once
|
|
15
|
+
* - resolves the profile-name fallback chain + loads the profile
|
|
16
|
+
* - parses CLI options + conf (format-level only; namespace allow-
|
|
17
|
+
* listing happens later in the sinks that need it)
|
|
18
|
+
* - sets JSON mode on the logger if --format=json
|
|
19
|
+
* - exposes an auth helper pre-curried with CLI --token / --token-env
|
|
20
|
+
* - exposes a lazy manifest getter (show never reads the manifest)
|
|
21
|
+
*
|
|
22
|
+
* `buildContext` does NOT do source filtering — each command has subtly
|
|
23
|
+
* different CLI shape there (publish takes name-or-URL, install/show
|
|
24
|
+
* take URL arrays, pin-resolution only matters for install). That
|
|
25
|
+
* stays per-command until a second feature shows the same duplication.
|
|
26
|
+
*
|
|
27
|
+
* Errors: `buildContext` THROWS on all input-validation failures. Each
|
|
28
|
+
* command wraps the call in one try/catch that does `log.error + exit`.
|
|
29
|
+
* This keeps buildContext unit-testable; behaviour from the user's
|
|
30
|
+
* point of view is unchanged.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
const { readConfig } = require('./config');
|
|
37
|
+
const { loadProfile } = require('./profile');
|
|
38
|
+
const { readManifest } = require('./manifest');
|
|
39
|
+
const { resolveSourceAuth } = require('./auth');
|
|
40
|
+
const { parseCliOptions } = require('./options');
|
|
41
|
+
const { parseCliConf } = require('./conf');
|
|
42
|
+
const log = require('./logger');
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Internal helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the active profile name per the long-standing precedence:
|
|
50
|
+
* --profile > config.defaultProfile > 'default'
|
|
51
|
+
*/
|
|
52
|
+
function resolveProfileName(argv, config) {
|
|
53
|
+
if (typeof argv.profile === 'string' && argv.profile.length > 0) {
|
|
54
|
+
return argv.profile;
|
|
55
|
+
}
|
|
56
|
+
if (typeof config.defaultProfile === 'string' && config.defaultProfile.length > 0) {
|
|
57
|
+
return config.defaultProfile;
|
|
58
|
+
}
|
|
59
|
+
return 'default';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalise CLI flags into the stable `ctx.flags` shape. Centralising
|
|
64
|
+
* defaults here means individual commands don't each re-apply
|
|
65
|
+
* `?? 'never'` / `?? 'Release'` / etc.
|
|
66
|
+
*
|
|
67
|
+
* Flags that are command-specific (argv.preset, argv.pkg, argv.clean,
|
|
68
|
+
* argv.verbose, argv.generator, argv.install, argv.installPrefix,
|
|
69
|
+
* argv.uploadSource, argv.autoInstall, argv.noGpgSign, ...) stay on
|
|
70
|
+
* argv and commands read them there. `ctx.argv` is the escape hatch.
|
|
71
|
+
*/
|
|
72
|
+
function normaliseFlags(argv) {
|
|
73
|
+
return {
|
|
74
|
+
format: argv.format === 'json' ? 'json' : 'text',
|
|
75
|
+
dryRun: argv.dryRun === true,
|
|
76
|
+
build: argv.build ?? 'never',
|
|
77
|
+
uploadBuilt: argv.uploadBuilt === true,
|
|
78
|
+
force: argv.force === true,
|
|
79
|
+
platform: argv.platform ?? defaultPlatform(),
|
|
80
|
+
timeout: typeof argv.timeout === 'number' ? argv.timeout : 300,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Default platform string when --platform is absent. Mirrors what
|
|
86
|
+
* bin/wyvrn.js passes when yargs applies its own default — keeping
|
|
87
|
+
* it here makes context.js testable without exercising the CLI layer.
|
|
88
|
+
*/
|
|
89
|
+
function defaultPlatform() {
|
|
90
|
+
if (process.platform === 'win32') {
|
|
91
|
+
return process.arch === 'ia32' ? 'win_x86' : 'win_x64';
|
|
92
|
+
}
|
|
93
|
+
if (process.platform === 'darwin') return 'macos';
|
|
94
|
+
return 'linux';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Produce a frozen `{ host, build }` profile pair. Today both point at
|
|
99
|
+
* the same resolved object — when F4 (build/host profile split) lands,
|
|
100
|
+
* only this function changes; every command reading `ctx.profiles.host`
|
|
101
|
+
* or `ctx.profiles.build` continues to work.
|
|
102
|
+
*
|
|
103
|
+
* The two sub-objects share a reference today. A regression test in
|
|
104
|
+
* tests/context.test.js asserts `ctx.profiles.host === ctx.profiles.build`
|
|
105
|
+
* — that test is expected to be removed (not fixed) when F4 makes them
|
|
106
|
+
* legitimately distinct.
|
|
107
|
+
*/
|
|
108
|
+
function resolveProfiles(argv, config) {
|
|
109
|
+
const name = resolveProfileName(argv, config);
|
|
110
|
+
const { profile, fromFile } = loadProfile(name);
|
|
111
|
+
const entry = Object.freeze({ name, profile, fromFile });
|
|
112
|
+
return Object.freeze({ host: entry, build: entry });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// buildContext
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the per-invocation CommandContext.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} argv - yargs-parsed argument map. Fields read:
|
|
123
|
+
* root, manifest, profile, format, dryRun, build, uploadBuilt,
|
|
124
|
+
* force, platform, timeout, token, tokenEnv, option, conf.
|
|
125
|
+
* Command-specific fields pass through on ctx.argv unchanged.
|
|
126
|
+
*
|
|
127
|
+
* @returns {CommandContext}
|
|
128
|
+
*
|
|
129
|
+
* @throws on:
|
|
130
|
+
* - parseCliOptions: malformed `-o pkg:name=value`
|
|
131
|
+
* - parseCliConf: malformed `--conf KEY=VALUE`
|
|
132
|
+
* - loadProfile: malformed profile JSON, cycle in `extends`, etc.
|
|
133
|
+
*
|
|
134
|
+
* Does NOT throw on:
|
|
135
|
+
* - missing manifest (lazy — throws at first ctx.manifest access)
|
|
136
|
+
* - missing config (readConfig returns empty defaults)
|
|
137
|
+
* - missing profile (loadProfile falls back to detectProfile())
|
|
138
|
+
* - missing --token-env value (resolveSourceAuth throws at ctx.auth.for time)
|
|
139
|
+
*/
|
|
140
|
+
function buildContext(argv = {}) {
|
|
141
|
+
// 1. Filesystem anchors — pure path math, no I/O.
|
|
142
|
+
const rootDir = path.resolve(argv.root ?? '.');
|
|
143
|
+
const manifestPath = path.resolve(
|
|
144
|
+
argv.manifest ?? path.join(rootDir, 'wyvrn.json'),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// 2. Config — sync, small file, every command needs at least .defaultProfile.
|
|
148
|
+
const config = readConfig();
|
|
149
|
+
|
|
150
|
+
// 3. Profile (F4-ready shape).
|
|
151
|
+
const profiles = resolveProfiles(argv, config);
|
|
152
|
+
|
|
153
|
+
// 4. CLI auth, kept as the raw pair. Actual token lookup happens
|
|
154
|
+
// through ctx.auth.for(sourceEntry), which runs resolveSourceAuth
|
|
155
|
+
// each time so --token-env misses surface at use (same as today).
|
|
156
|
+
const authCli = Object.freeze({
|
|
157
|
+
token: typeof argv.token === 'string' ? argv.token : undefined,
|
|
158
|
+
tokenEnv: typeof argv.tokenEnv === 'string' ? argv.tokenEnv : undefined,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// 5. CLI options + conf — format-level parsing only. Namespace
|
|
162
|
+
// allow-listing for conf happens later in resolveEffectiveConf;
|
|
163
|
+
// options validation against the recipe's declared `allowed` set
|
|
164
|
+
// happens at resolveEffectiveOptions time.
|
|
165
|
+
// Both can throw — that's the contract: buildContext surfaces
|
|
166
|
+
// input-level errors up front.
|
|
167
|
+
const cliOptions = parseCliOptions(argv.option);
|
|
168
|
+
const cliConf = parseCliConf(argv.conf);
|
|
169
|
+
|
|
170
|
+
// 6. Flags.
|
|
171
|
+
const flags = normaliseFlags(argv);
|
|
172
|
+
|
|
173
|
+
// 7. JSON mode — side effect that must happen before the command
|
|
174
|
+
// starts logging. Flipping the logger here means every command
|
|
175
|
+
// that constructs a ctx gets JSON mode right automatically.
|
|
176
|
+
if (flags.format === 'json') log.setJsonMode(true);
|
|
177
|
+
|
|
178
|
+
// 8. Assemble.
|
|
179
|
+
const ctx = {
|
|
180
|
+
rootDir,
|
|
181
|
+
manifestPath,
|
|
182
|
+
cwd: process.cwd(),
|
|
183
|
+
profiles,
|
|
184
|
+
config,
|
|
185
|
+
auth: Object.freeze({
|
|
186
|
+
cli: authCli,
|
|
187
|
+
for: (sourceEntry) => resolveSourceAuth(authCli, sourceEntry ?? null),
|
|
188
|
+
}),
|
|
189
|
+
cliOptions,
|
|
190
|
+
cliConf,
|
|
191
|
+
flags,
|
|
192
|
+
argv,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// 9. Lazy manifest getter. Most commands need it; `show` does not.
|
|
196
|
+
// Defining as a getter means:
|
|
197
|
+
// - show pays nothing.
|
|
198
|
+
// - other commands pay once, cached.
|
|
199
|
+
// - an invalid manifest surfaces at first use, not at ctx
|
|
200
|
+
// construction — so `show` never trips over it.
|
|
201
|
+
let manifestCache = null;
|
|
202
|
+
let manifestLoaded = false;
|
|
203
|
+
Object.defineProperty(ctx, 'manifest', {
|
|
204
|
+
configurable: false,
|
|
205
|
+
enumerable: true,
|
|
206
|
+
get() {
|
|
207
|
+
if (!manifestLoaded) {
|
|
208
|
+
if (!fs.existsSync(manifestPath)) {
|
|
209
|
+
throw new Error(`Manifest not found: ${manifestPath}`);
|
|
210
|
+
}
|
|
211
|
+
manifestCache = readManifest(manifestPath);
|
|
212
|
+
manifestLoaded = true;
|
|
213
|
+
}
|
|
214
|
+
return manifestCache;
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return ctx;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
buildContext,
|
|
223
|
+
// Exported for unit tests — kept out of the default import surface.
|
|
224
|
+
_helpers: {
|
|
225
|
+
resolveProfileName,
|
|
226
|
+
normaliseFlags,
|
|
227
|
+
defaultPlatform,
|
|
228
|
+
resolveProfiles,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
@@ -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/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(
|
package/src/providers/s3.js
CHANGED
|
@@ -9,21 +9,13 @@ const RE_S3_URI = /^s3:\/\//;
|
|
|
9
9
|
const RE_S3_VIRTUAL_HOSTED = /^https?:\/\/([^.]+)\.s3[.-][^/]*\.amazonaws\.com/;
|
|
10
10
|
const RE_S3_PATH_STYLE = /^https?:\/\/s3[.-][^/]*\.amazonaws\.com\/([^/]+)/;
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
? { ...value, version: locked }
|
|
20
|
-
: locked;
|
|
21
|
-
} else {
|
|
22
|
-
result[pkgName] = value;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return result;
|
|
26
|
-
}
|
|
12
|
+
// CloudFront TTL cap for mutable manifest JSON (versions.json, latest.json)
|
|
13
|
+
// and the per-profile wyvrn.json / source.json. 5 min is short enough that
|
|
14
|
+
// a publish is visible to `install` / `add` well within a coffee break, but
|
|
15
|
+
// long enough that `install` at scale doesn't hammer the origin. Applied
|
|
16
|
+
// on every JSON PUT — consumers of the ZIPs get default (long) TTLs since
|
|
17
|
+
// a (name, version, profileHash) artefact is immutable by construction.
|
|
18
|
+
const JSON_CACHE_CONTROL = 'public, max-age=300';
|
|
27
19
|
|
|
28
20
|
class S3Provider extends BaseProvider {
|
|
29
21
|
static canHandle(source) {
|
|
@@ -76,25 +68,36 @@ class S3Provider extends BaseProvider {
|
|
|
76
68
|
|
|
77
69
|
_makeClient(awsProfile) {
|
|
78
70
|
const { S3Client, fromSSO } = this._loadSdk();
|
|
79
|
-
|
|
71
|
+
if (!awsProfile) return new S3Client({});
|
|
72
|
+
|
|
73
|
+
// fromSSO supplies credentials only. The SDK's region resolver is
|
|
74
|
+
// independent and consults AWS_REGION / AWS_DEFAULT_REGION / the profile
|
|
75
|
+
// named by AWS_PROFILE — NOT the profile handed to fromSSO. Propagate
|
|
76
|
+
// --aws-profile so its `region = …` line in ~/.aws/config is the fallback.
|
|
77
|
+
process.env.AWS_PROFILE = awsProfile;
|
|
78
|
+
return new S3Client({ credentials: fromSSO({ profile: awsProfile }) });
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
async _putFile(client, bucket, key, filePath, contentType, PutObjectCommand) {
|
|
83
82
|
log.info(` → s3://${bucket}/${key}`);
|
|
84
|
-
|
|
83
|
+
const params = {
|
|
85
84
|
Bucket: bucket, Key: key,
|
|
86
85
|
Body: fs.readFileSync(filePath),
|
|
87
86
|
ContentType: contentType,
|
|
88
|
-
}
|
|
87
|
+
};
|
|
88
|
+
if (contentType === 'application/json') params.CacheControl = JSON_CACHE_CONTROL;
|
|
89
|
+
await client.send(new PutObjectCommand(params));
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
async _putBuffer(client, bucket, key, content, contentType, PutObjectCommand) {
|
|
92
93
|
log.info(` → s3://${bucket}/${key}`);
|
|
93
|
-
|
|
94
|
+
const params = {
|
|
94
95
|
Bucket: bucket, Key: key,
|
|
95
96
|
Body: content,
|
|
96
97
|
ContentType: contentType,
|
|
97
|
-
}
|
|
98
|
+
};
|
|
99
|
+
if (contentType === 'application/json') params.CacheControl = JSON_CACHE_CONTROL;
|
|
100
|
+
await client.send(new PutObjectCommand(params));
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
async _getJson(client, bucket, key, GetObjectCommand) {
|
|
@@ -177,7 +180,7 @@ class S3Provider extends BaseProvider {
|
|
|
177
180
|
async v2Publish(files, options) {
|
|
178
181
|
const {
|
|
179
182
|
source, name, version, profileHash, buildSettings,
|
|
180
|
-
contentSha256, gitSha, gitRepo,
|
|
183
|
+
contentSha256, gitSha, gitRepo, publishedLock, awsProfile,
|
|
181
184
|
updateLatest = true, uploadedBy,
|
|
182
185
|
} = options;
|
|
183
186
|
const { PutObjectCommand, GetObjectCommand } = this._loadSdk();
|
|
@@ -190,14 +193,14 @@ class S3Provider extends BaseProvider {
|
|
|
190
193
|
// 1) Upload zip
|
|
191
194
|
await this._putFile(client, bucket, `${buildRoot}/wyvrn.zip`, files.zip, 'application/zip', PutObjectCommand);
|
|
192
195
|
|
|
193
|
-
// 2) Upload enriched wyvrn.json
|
|
196
|
+
// 2) Upload enriched wyvrn.json.
|
|
194
197
|
// buildSettings is stored for debugging purposes only.
|
|
195
|
-
// Dependencies are
|
|
198
|
+
// Dependencies are published verbatim from wyvrn.json — ranges stay as
|
|
199
|
+
// ranges so consumers can intersect them with their own. The author's
|
|
200
|
+
// lockfile snapshot is preserved in `publishedLock` for audit.
|
|
196
201
|
const rawManifest = JSON.parse(fs.readFileSync(files.manifest, 'utf8'));
|
|
197
|
-
const pinnedDeps = pinDependencies(rawManifest.dependencies, lockedDependencies);
|
|
198
202
|
const v2Meta = {
|
|
199
203
|
...rawManifest,
|
|
200
|
-
...(pinnedDeps !== undefined ? { dependencies: pinnedDeps } : {}),
|
|
201
204
|
schemaVersion: 2,
|
|
202
205
|
profileHash,
|
|
203
206
|
buildSettings,
|
|
@@ -205,6 +208,7 @@ class S3Provider extends BaseProvider {
|
|
|
205
208
|
gitSha: gitSha ?? null,
|
|
206
209
|
gitRepo: gitRepo ?? null,
|
|
207
210
|
publishedAt: new Date().toISOString(),
|
|
211
|
+
...(publishedLock && Object.keys(publishedLock).length > 0 ? { publishedLock } : {}),
|
|
208
212
|
...(uploadedBy ? { uploadedBy } : {}),
|
|
209
213
|
};
|
|
210
214
|
await this._putBuffer(
|