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.
@@ -1,9 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const { readConfig } = require('../config');
4
- const { getProvider } = require('../providers');
5
- const { resolveSourceAuth } = require('../auth');
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
- const jsonOut = argv.format === 'json';
41
- if (jsonOut) log.setJsonMode(true);
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 = readConfig();
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: resolveSourceAuth(
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 };
@@ -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, lockedDependencies,
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 replaced with locked versions so the server never sees "latest".
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);
@@ -8,21 +8,11 @@ const { Readable } = require('stream');
8
8
  const BaseProvider = require('./base');
9
9
  const log = require('../logger');
10
10
 
11
- function pinDependencies(rawDeps, lockedDeps) {
12
- if (!rawDeps || !lockedDeps || Object.keys(lockedDeps).length === 0) return rawDeps;
13
- const result = {};
14
- for (const [pkgName, value] of Object.entries(rawDeps)) {
15
- const locked = lockedDeps[pkgName];
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) { res.resume(); resolve(null); return; }
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, lockedDependencies, token,
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 replaced with locked versions so the server never sees "latest".
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(
@@ -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
- function pinDependencies(rawDeps, lockedDeps) {
13
- if (!rawDeps || !lockedDeps || Object.keys(lockedDeps).length === 0) return rawDeps;
14
- const result = {};
15
- for (const [pkgName, value] of Object.entries(rawDeps)) {
16
- const locked = lockedDeps[pkgName];
17
- if (locked) {
18
- result[pkgName] = typeof value === 'object' && value !== null
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
- return new S3Client(awsProfile ? { credentials: fromSSO({ profile: awsProfile }) } : {});
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
- await client.send(new PutObjectCommand({
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
- await client.send(new PutObjectCommand({
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, lockedDependencies, awsProfile,
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 replaced with locked versions so the server never sees "latest".
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(