@askrjs/cli 0.0.7 → 0.0.9

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/dist/add.js CHANGED
@@ -226,9 +226,18 @@ function renderServerActionRegistry(actions) {
226
226
  ...actions.map((action) => `import { ${action.handlerName} } from './actions/${action.slug}.js';`),
227
227
  "import type { AppDependencies } from './dependencies.js';",
228
228
  "",
229
+ "function csrfSecret(): string {",
230
+ " const secret = process.env.CSRF_SECRET;",
231
+ " if (process.env.NODE_ENV !== 'production') return secret ?? 'development-only-secret';",
232
+ " if (!secret || secret.length < 32 || new Set(secret).size < 12) {",
233
+ " throw new Error('Production requires a strong CSRF_SECRET (at least 32 varied characters).');",
234
+ " }",
235
+ " return secret;",
236
+ "}",
237
+ "",
229
238
  "export function createActions(deps: AppDependencies) {",
230
239
  " return defineServerActions({ dependencies: deps,",
231
- " csrf: { secret: process.env.CSRF_SECRET ?? 'development-only-secret' },",
240
+ " csrf: { secret: csrfSecret() },",
232
241
  " },",
233
242
  ...actions.map((action) => ` handleAction(${action.descriptorName}, ${action.handlerName}),`),
234
243
  " );",
package/dist/create.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as isDirectExecution } from "./is-direct-execution-Cdlr-ZUl.js";
3
- import { t as installBundledSkills } from "./skills-B7CbWur9.js";
3
+ import { t as installBundledSkills } from "./skills-C2KzfTl9.js";
4
4
  import { n as publishStagedDirectory, t as createSiblingStage } from "./directory-swap-DWoHtx7C.js";
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
@@ -1,5 +1,4 @@
1
- import { t as analyzeRange } from "./range-YUs9eimn.js";
2
- import { t as parseDependencySpecification } from "./specification-DXnDOC-0.js";
1
+ import { n as analyzeRange, t as parseDependencySpecification } from "./specification-BSlq_n9A.js";
3
2
  import fs from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import { load } from "js-yaml";
package/dist/generate.js CHANGED
@@ -1,6 +1,9 @@
1
- import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
1
+ import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { lookup } from "node:dns/promises";
5
+ import { request } from "node:https";
6
+ import { BlockList, isIP } from "node:net";
4
7
  import { load } from "js-yaml";
5
8
  //#region src/generate/generator.ts
6
9
  const OWNED = [
@@ -119,30 +122,202 @@ function parseOpenApiDocument(contents, source) {
119
122
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new GenerationError(`OpenAPI document must be an object: ${source}`);
120
123
  return parsed;
121
124
  }
122
- async function readSource(uri) {
123
- if (uri.startsWith("http://") || uri.startsWith("https://")) {
124
- const response = await fetch(uri);
125
- if (!response.ok) throw new GenerationError(`Unable to fetch OpenAPI reference ${uri}: ${response.status} ${response.statusText}`);
125
+ const blockedAddresses = new BlockList();
126
+ for (const [network, prefix] of [
127
+ ["0.0.0.0", 8],
128
+ ["10.0.0.0", 8],
129
+ ["100.64.0.0", 10],
130
+ ["127.0.0.0", 8],
131
+ ["169.254.0.0", 16],
132
+ ["172.16.0.0", 12],
133
+ ["192.0.0.0", 24],
134
+ ["192.0.2.0", 24],
135
+ ["192.168.0.0", 16],
136
+ ["198.18.0.0", 15],
137
+ ["198.51.100.0", 24],
138
+ ["203.0.113.0", 24],
139
+ ["224.0.0.0", 4],
140
+ ["240.0.0.0", 4]
141
+ ]) blockedAddresses.addSubnet(network, prefix, "ipv4");
142
+ for (const [network, prefix] of [
143
+ ["::", 128],
144
+ ["::1", 128],
145
+ ["fc00::", 7],
146
+ ["fe80::", 10],
147
+ ["ff00::", 8],
148
+ ["2001:db8::", 32],
149
+ ["2001:2::", 48]
150
+ ]) blockedAddresses.addSubnet(network, prefix, "ipv6");
151
+ function privateAddress(address) {
152
+ const family = isIP(address);
153
+ if (family === 4) return blockedAddresses.check(address, "ipv4");
154
+ if (family !== 6) return true;
155
+ const mappedSuffix = /^::ffff:(.+)$/i.exec(address)?.[1];
156
+ const mapped = mappedSuffix?.includes(".") ? mappedSuffix : mappedSuffix && /^[0-9a-f]{1,4}:[0-9a-f]{1,4}$/i.test(mappedSuffix) ? mappedSuffix.split(":").flatMap((part) => {
157
+ const value = Number.parseInt(part, 16);
158
+ return [value >> 8, value & 255];
159
+ }).join(".") : void 0;
160
+ return mapped ? privateAddress(mapped) : blockedAddresses.check(address, "ipv6");
161
+ }
162
+ function remaining(deadline, uri) {
163
+ const value = deadline - Date.now();
164
+ if (value <= 0) throw new GenerationError(`Timed out fetching OpenAPI reference ${uri}`);
165
+ return value;
166
+ }
167
+ async function withDeadline(promise, deadline, uri) {
168
+ let timer;
169
+ try {
170
+ return await Promise.race([promise, new Promise((_, reject) => {
171
+ timer = setTimeout(() => reject(new GenerationError(`Timed out fetching OpenAPI reference ${uri}`)), remaining(deadline, uri));
172
+ })]);
173
+ } finally {
174
+ if (timer) clearTimeout(timer);
175
+ }
176
+ }
177
+ async function vettedAddresses(url, options, deadline) {
178
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
179
+ const answers = isIP(hostname) ? [{
180
+ address: hostname,
181
+ family: isIP(hostname)
182
+ }] : await withDeadline(lookup(hostname, {
183
+ all: true,
184
+ verbatim: true
185
+ }), deadline, url.href);
186
+ if (answers.length === 0 || !options.allowPrivateHosts && answers.some(({ address }) => privateAddress(address))) throw new GenerationError(`OpenAPI reference resolves to a private, reserved, or link-local address: ${url.origin}`);
187
+ return answers.map(({ address, family }) => ({
188
+ address,
189
+ family
190
+ }));
191
+ }
192
+ function retryableConnectionError(error) {
193
+ if (!(error instanceof Error)) return false;
194
+ const code = error.code ?? "";
195
+ return !/^ERR_TLS_|^CERT_|SELF_SIGNED|CERTIFICATE/.test(code) && [
196
+ "ECONNREFUSED",
197
+ "ECONNRESET",
198
+ "EHOSTUNREACH",
199
+ "ENETUNREACH",
200
+ "ETIMEDOUT",
201
+ "EPIPE"
202
+ ].includes(code);
203
+ }
204
+ function requestAddress(url, vetted, deadline) {
205
+ return new Promise((resolve, reject) => {
206
+ let receivedHeaders = false;
207
+ const request$1 = request(url, {
208
+ method: "GET",
209
+ headers: {
210
+ accept: "application/json, application/yaml, text/yaml, */*",
211
+ "accept-encoding": "identity"
212
+ },
213
+ servername: url.hostname,
214
+ lookup: (_hostname, _options, callback) => callback(null, vetted.address, vetted.family)
215
+ }, (message) => {
216
+ receivedHeaders = true;
217
+ resolve({
218
+ status: message.statusCode ?? 0,
219
+ statusText: message.statusMessage ?? "",
220
+ headers: message.headers,
221
+ message
222
+ });
223
+ });
224
+ request$1.setTimeout(remaining(deadline, url.href), () => {
225
+ request$1.destroy(Object.assign(/* @__PURE__ */ new Error("connection timed out"), { code: "ETIMEDOUT" }));
226
+ });
227
+ request$1.on("error", (error) => reject(Object.assign(error, { receivedHeaders })));
228
+ request$1.end();
229
+ });
230
+ }
231
+ async function requestVetted(url, addresses, deadline) {
232
+ let lastError;
233
+ for (const address of addresses) try {
234
+ return await withDeadline(requestAddress(url, address, deadline), deadline, url.href);
235
+ } catch (error) {
236
+ lastError = error;
237
+ if (!retryableConnectionError(error)) throw error;
238
+ }
239
+ throw lastError;
240
+ }
241
+ async function responseText(response, uri, maxBytes, deadline) {
242
+ const declared = Number(response.headers["content-length"]);
243
+ if (Number.isFinite(declared) && declared > maxBytes) throw new GenerationError(`OpenAPI reference exceeds ${maxBytes} bytes: ${uri}`);
244
+ const encoding = response.headers["content-encoding"];
245
+ if (encoding && encoding !== "identity") throw new GenerationError(`OpenAPI reference returned unsupported content encoding: ${encoding}`);
246
+ const chunks = [];
247
+ let size = 0;
248
+ try {
249
+ await withDeadline(new Promise((resolve, reject) => {
250
+ response.message.on("data", (value) => {
251
+ size += value.byteLength;
252
+ if (size > maxBytes) {
253
+ response.message.destroy();
254
+ reject(new GenerationError(`OpenAPI reference exceeds ${maxBytes} bytes: ${uri}`));
255
+ return;
256
+ }
257
+ chunks.push(value);
258
+ });
259
+ response.message.on("end", resolve);
260
+ response.message.on("error", reject);
261
+ response.message.on("aborted", () => reject(new GenerationError(`OpenAPI response body was aborted: ${uri}`)));
262
+ }), deadline, uri);
263
+ } catch (error) {
264
+ response.message.destroy();
265
+ throw error;
266
+ }
267
+ if (size > maxBytes) throw new GenerationError(`OpenAPI reference exceeds ${maxBytes} bytes: ${uri}`);
268
+ return Buffer.concat(chunks).toString("utf8");
269
+ }
270
+ async function fetchSource(uri, options) {
271
+ let current = new URL(uri);
272
+ const deadline = Date.now() + options.timeoutMs;
273
+ for (let redirects = 0;; redirects += 1) {
274
+ if (current.protocol !== "https:") throw new GenerationError(`Remote OpenAPI references must use HTTPS: ${current.href}`);
275
+ if (current.username || current.password) throw new GenerationError(`Remote OpenAPI references must not include credentials: ${current.origin}`);
276
+ if (current.origin !== options.remoteRootOrigin && !options.allowedReferenceOrigins.has(current.origin)) throw new GenerationError(`Cross-origin OpenAPI reference is not allowed: ${current.origin}`);
277
+ const addresses = await vettedAddresses(current, options, deadline);
278
+ const response = await requestVetted(current, addresses, deadline);
279
+ if (response.status >= 300 && response.status < 400) {
280
+ if (redirects >= options.maxRedirects) throw new GenerationError(`Too many redirects while fetching OpenAPI reference ${uri}`);
281
+ response.message.resume();
282
+ const location = response.headers.location;
283
+ if (!location) throw new GenerationError(`OpenAPI redirect is missing Location: ${current.href}`);
284
+ current = new URL(location, current);
285
+ continue;
286
+ }
287
+ if (response.status < 200 || response.status >= 300) {
288
+ response.message.resume();
289
+ throw new GenerationError(`Unable to fetch OpenAPI reference ${current.href}: ${response.status} ${response.statusText}`);
290
+ }
126
291
  return {
127
- uri,
128
- document: parseOpenApiDocument(await response.text(), uri)
292
+ uri: current.href,
293
+ document: parseOpenApiDocument(await responseText(response, current.href, options.maxBytes, deadline), current.href)
129
294
  };
130
295
  }
296
+ }
297
+ async function readSource(uri, options) {
298
+ if (uri.startsWith("http://") || uri.startsWith("https://")) return fetchSource(uri, options);
131
299
  const path = fileURLToPath(uri);
300
+ const canonical = await realpath(path);
301
+ const relativePath = options.localRootDirectory ? relative(options.localRootDirectory, canonical) : "..";
302
+ if (!options.localRootDirectory || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) throw new GenerationError(`Local OpenAPI reference escapes the specification directory: ${path}`);
303
+ const contents = await readFile(canonical);
304
+ if (contents.byteLength > options.maxBytes) throw new GenerationError(`OpenAPI reference exceeds ${options.maxBytes} bytes: ${path}`);
132
305
  return {
133
- uri,
134
- document: parseOpenApiDocument(await readFile(path, "utf8"), path)
306
+ uri: pathToFileURL(canonical).href,
307
+ document: parseOpenApiDocument(contents.toString("utf8"), canonical)
135
308
  };
136
309
  }
137
310
  var OpenApiBundler = class {
138
311
  root;
139
312
  rootUri;
313
+ options;
140
314
  sources = /* @__PURE__ */ new Map();
141
315
  aliases = /* @__PURE__ */ new Map();
142
316
  names = /* @__PURE__ */ new Map();
143
- constructor(root, rootUri) {
317
+ constructor(root, rootUri, options) {
144
318
  this.root = root;
145
319
  this.rootUri = rootUri;
320
+ this.options = options;
146
321
  }
147
322
  async bundle() {
148
323
  if (!this.root.components) this.root.components = {};
@@ -153,7 +328,7 @@ var OpenApiBundler = class {
153
328
  async source(uri) {
154
329
  let pending = this.sources.get(uri);
155
330
  if (!pending) {
156
- pending = readSource(uri);
331
+ pending = readSource(uri, this.options);
157
332
  this.sources.set(uri, pending);
158
333
  }
159
334
  return pending;
@@ -175,6 +350,7 @@ var OpenApiBundler = class {
175
350
  for (const [key, child] of Object.entries(value)) value[key] = await this.expand(child, uri, document, stack);
176
351
  }
177
352
  async expand(value, uri, document, stack) {
353
+ if (stack.size > this.options.maxDepth) throw new GenerationError(`OpenAPI reference depth exceeds ${this.options.maxDepth}`);
178
354
  if (Array.isArray(value)) return Promise.all(value.map((item) => this.expand(item, uri, document, stack)));
179
355
  if (!value || typeof value !== "object") return value;
180
356
  const object = value;
@@ -215,9 +391,36 @@ var OpenApiBundler = class {
215
391
  return this.expand(target, targetSource.uri, targetSource.document, new Set(stack).add(canonical));
216
392
  }
217
393
  };
218
- async function loadOpenApi(input) {
219
- const source = await readSource(/^https?:\/\//.test(input) ? input : pathToFileURL(resolve(input)).href);
220
- return new OpenApiBundler(source.document, source.uri).bundle();
394
+ async function loadOpenApi(input, options = {}) {
395
+ const remote = /^https?:\/\//.test(input);
396
+ const localPath = remote ? void 0 : await realpath(resolve(input));
397
+ const uri = remote ? new URL(input).href : pathToFileURL(localPath).href;
398
+ const rootUrl = new URL(uri);
399
+ if (remote && rootUrl.protocol !== "https:") throw new GenerationError("Remote OpenAPI roots must use HTTPS.");
400
+ const positiveOption = (name, value) => {
401
+ if (!Number.isSafeInteger(value) || value <= 0) throw new GenerationError(`${name} must be a positive safe integer.`);
402
+ return value;
403
+ };
404
+ const resolved = {
405
+ allowedReferenceOrigins: new Set((options.allowedReferenceOrigins ?? []).map((origin) => {
406
+ let parsed;
407
+ try {
408
+ parsed = new URL(origin);
409
+ } catch {
410
+ throw new GenerationError(`Allowed reference origin must be an HTTPS origin: ${origin}`);
411
+ }
412
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || parsed.href !== parsed.origin + "/") throw new GenerationError(`Allowed reference origin must be an HTTPS origin: ${origin}`);
413
+ return parsed.origin;
414
+ })),
415
+ allowPrivateHosts: options.allowPrivateHosts ?? false,
416
+ maxBytes: positiveOption("maxBytes", options.maxBytes ?? 5 * 1024 * 1024),
417
+ maxDepth: positiveOption("maxDepth", options.maxDepth ?? 32),
418
+ maxRedirects: positiveOption("maxRedirects", options.maxRedirects ?? 5),
419
+ timeoutMs: positiveOption("timeoutMs", options.timeoutMs ?? 1e4),
420
+ ...remote ? { remoteRootOrigin: rootUrl.origin } : { localRootDirectory: await realpath(dirname(localPath)) }
421
+ };
422
+ const source = await readSource(uri, resolved);
423
+ return new OpenApiBundler(source.document, source.uri, resolved).bundle();
221
424
  }
222
425
  function generateFiles(document) {
223
426
  const version = String(document.openapi ?? "");
@@ -392,13 +595,13 @@ async function writeGenerated(directory, files, check) {
392
595
  });
393
596
  }
394
597
  }
395
- async function generate(input, output, check = false) {
598
+ async function generate(input, output, check = false, loadOptions = {}) {
396
599
  if (!/^https?:\/\//.test(input)) {
397
600
  const resolvedInput = resolve(input);
398
601
  const inputWithinOutput = relative(resolve(output), resolvedInput);
399
602
  if (!inputWithinOutput.startsWith("..") && !isAbsolute(inputWithinOutput)) throw new GenerationError("Generated output must not contain its OpenAPI input document");
400
603
  }
401
- const files = generateFiles(await loadOpenApi(input));
604
+ const files = generateFiles(await loadOpenApi(input, loadOptions));
402
605
  await mkdir(dirname(resolve(output)), { recursive: true });
403
606
  await writeGenerated(output, files, check);
404
607
  }
@@ -410,13 +613,65 @@ async function runGenerateCli(args, io = console) {
410
613
  let check = false;
411
614
  let json = false;
412
615
  let help = false;
616
+ const allowedReferenceOrigins = [];
617
+ const referenceLimits = {};
413
618
  const inputs = [];
414
619
  for (let i = 0; i < args.length; i++) {
415
620
  const arg = args[i];
416
621
  if (arg === "--check") check = true;
417
622
  else if (arg === "--json") json = true;
418
623
  else if (arg === "--help" || arg === "-h") help = true;
419
- else if (arg === "-o" || arg === "--output") {
624
+ else if (arg === "--allow-ref-origin") {
625
+ const value = args[i + 1];
626
+ if (!value || value.startsWith("-")) {
627
+ const message = `${arg} requires an HTTPS origin`;
628
+ io.error(wantsJson ? JSON.stringify({
629
+ status: "error",
630
+ error: message
631
+ }) : message);
632
+ return 1;
633
+ }
634
+ allowedReferenceOrigins.push(value);
635
+ i += 1;
636
+ } else if (arg.startsWith("--allow-ref-origin=")) allowedReferenceOrigins.push(arg.slice(19));
637
+ else if (arg === "--ref-timeout-ms" || arg === "--ref-max-bytes" || arg === "--ref-max-depth" || arg === "--ref-max-redirects") {
638
+ const value = args[i + 1];
639
+ const parsed = Number(value);
640
+ if (!value || value.startsWith("-") || !Number.isSafeInteger(parsed) || parsed <= 0) {
641
+ const message = `${arg} requires a positive safe integer`;
642
+ io.error(wantsJson ? JSON.stringify({
643
+ status: "error",
644
+ error: message
645
+ }) : message);
646
+ return 1;
647
+ }
648
+ const key = {
649
+ "--ref-timeout-ms": "timeoutMs",
650
+ "--ref-max-bytes": "maxBytes",
651
+ "--ref-max-depth": "maxDepth",
652
+ "--ref-max-redirects": "maxRedirects"
653
+ }[arg];
654
+ referenceLimits[key] = parsed;
655
+ i += 1;
656
+ } else if (/^--ref-(?:timeout-ms|max-bytes|max-depth|max-redirects)=/.test(arg)) {
657
+ const [flag, value = ""] = arg.split("=", 2);
658
+ const parsed = Number(value);
659
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
660
+ const message = `${flag} requires a positive safe integer`;
661
+ io.error(wantsJson ? JSON.stringify({
662
+ status: "error",
663
+ error: message
664
+ }) : message);
665
+ return 1;
666
+ }
667
+ const key = {
668
+ "--ref-timeout-ms": "timeoutMs",
669
+ "--ref-max-bytes": "maxBytes",
670
+ "--ref-max-depth": "maxDepth",
671
+ "--ref-max-redirects": "maxRedirects"
672
+ }[flag];
673
+ referenceLimits[key] = parsed;
674
+ } else if (arg === "-o" || arg === "--output") {
420
675
  const value = args[i + 1];
421
676
  if (!value || value.startsWith("-")) {
422
677
  const message = `${arg} requires a path`;
@@ -438,7 +693,7 @@ async function runGenerateCli(args, io = console) {
438
693
  return 1;
439
694
  } else inputs.push(arg);
440
695
  }
441
- const usage = "Usage: askr generate <input> -o <output-directory> [--check] [--json]";
696
+ const usage = "Usage: askr generate <input> -o <output-directory> [--check] [--json] [--allow-ref-origin <https-origin>] [--ref-timeout-ms <n>] [--ref-max-bytes <n>] [--ref-max-depth <n>] [--ref-max-redirects <n>]";
442
697
  if (help) {
443
698
  io.log(usage);
444
699
  return 0;
@@ -451,7 +706,10 @@ async function runGenerateCli(args, io = console) {
451
706
  return 1;
452
707
  }
453
708
  try {
454
- await generate(inputs[0], output, check);
709
+ await generate(inputs[0], output, check, {
710
+ allowedReferenceOrigins,
711
+ ...referenceLimits
712
+ });
455
713
  io.log(json ? JSON.stringify({
456
714
  status: "ok",
457
715
  action: check ? "checked" : "generated",
@@ -1,5 +1,4 @@
1
- import { n as isBreakingChange, r as rewriteRange, t as analyzeRange } from "./range-YUs9eimn.js";
2
- import { t as parseDependencySpecification } from "./specification-DXnDOC-0.js";
1
+ import { i as rewriteRange, n as analyzeRange, r as isBreakingChange, t as parseDependencySpecification } from "./specification-BSlq_n9A.js";
3
2
  import semver from "semver";
4
3
  //#region src/update/planner.ts
5
4
  const STATUS_PRIORITY = {
@@ -123,12 +122,13 @@ function planOne(occurrence, packument, failure, tag, mode, chosen, blocker) {
123
122
  reason: blocker
124
123
  }
125
124
  };
126
- if (semver.satisfies(selected, occurrence.currentSpecification) || !semver.gt(selected, allowed)) return {
125
+ const declared = semver.minVersion(occurrence.currentSpecification)?.version ?? allowed;
126
+ if (!semver.gt(selected, declared)) return {
127
127
  targetVersion: target,
128
128
  occurrence: {
129
129
  ...withVersions,
130
130
  status: "current",
131
- reason: blocker ?? "selected version is already covered by the current specification"
131
+ reason: blocker ?? "the declared version is current"
132
132
  }
133
133
  };
134
134
  const analysis = analyzeRange(occurrence.currentSpecification);
@@ -149,11 +149,20 @@ function planOne(occurrence, packument, failure, tag, mode, chosen, blocker) {
149
149
  reason: "breaking update is available via askr upgrade"
150
150
  }
151
151
  };
152
+ const proposedSpecification = rewriteRange(analysis.shape, selected, breaking);
153
+ if (proposedSpecification === occurrence.currentSpecification) return {
154
+ targetVersion: target,
155
+ occurrence: {
156
+ ...withVersions,
157
+ status: "current",
158
+ reason: "the selected version cannot further rebase this range style"
159
+ }
160
+ };
152
161
  return {
153
162
  targetVersion: target,
154
163
  occurrence: {
155
164
  ...withVersions,
156
- proposedSpecification: rewriteRange(analysis.shape, selected, breaking),
165
+ proposedSpecification,
157
166
  status: breaking ? "breaking" : "safe",
158
167
  reason: selected === target ? "selected tag target is eligible" : `compatible version ${selected} selected below ${tag}@${target}`
159
168
  }
@@ -185,10 +194,8 @@ function solveWorkspace(workspace, selectedOccurrences, context, packuments, tag
185
194
  }
186
195
  const variables = [...states.entries()].filter(([, state]) => state.selected && state.candidates.length > 0).sort(([a], [b]) => a.localeCompare(b));
187
196
  const fixed = new Map([...states].flatMap(([name, state]) => state.selected ? [] : state.current ? [[name, state.current]] : []));
188
- let best = null;
189
- let bestChanged = -1;
190
- let firstFailure = "no jointly peer-compatible published version set exists";
191
- const validate = (choices) => {
197
+ const currentChoices = new Map(variables.flatMap(([name, state]) => state.current ? [[name, state.current]] : []));
198
+ const validate = (choices, domains) => {
192
199
  const installed = new Map([...fixed, ...choices]);
193
200
  for (const [name, version] of installed) {
194
201
  const packument = packuments.get(name);
@@ -198,10 +205,12 @@ function solveWorkspace(workspace, selectedOccurrences, context, packuments, tag
198
205
  for (const [peer, requirement] of Object.entries(meta.peerDependencies ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
199
206
  if (typeof requirement !== "string") continue;
200
207
  const providerChanged = states.get(name)?.current !== version;
201
- const peerChanged = states.get(peer)?.selected && states.get(peer)?.current !== installed.get(peer);
208
+ const assignedPeer = choices.get(peer);
209
+ const peerChanged = assignedPeer !== void 0 && states.get(peer)?.current !== assignedPeer;
202
210
  if (!providerChanged && !peerChanged) continue;
203
- const peerVersion = installed.get(peer) ?? localVersions.get(peer);
211
+ const peerVersion = assignedPeer ?? (states.get(peer)?.selected ? void 0 : installed.get(peer));
204
212
  if (!peerVersion) {
213
+ if ((domains?.get(peer))?.some((candidate) => semver.satisfies(candidate, requirement, { includePrerelease: true }))) continue;
205
214
  if (optionalPeer(meta, peer)) continue;
206
215
  return `${name}@${version} requires missing peer ${peer}@${requirement}`;
207
216
  }
@@ -210,31 +219,103 @@ function solveWorkspace(workspace, selectedOccurrences, context, packuments, tag
210
219
  }
211
220
  return null;
212
221
  };
213
- const visit = (index, choices) => {
214
- if (index === variables.length) {
215
- const failure = validate(choices);
222
+ const edges = new Map(variables.map(([name]) => [name, /* @__PURE__ */ new Set()]));
223
+ for (const [name, state] of variables) for (const version of state.candidates) {
224
+ const peers = metadata(packuments.get(name), version)?.peerDependencies ?? {};
225
+ for (const peer of Object.keys(peers)) {
226
+ if (!edges.has(peer)) continue;
227
+ edges.get(name).add(peer);
228
+ edges.get(peer).add(name);
229
+ }
230
+ }
231
+ const components = [];
232
+ const unseen = new Set(edges.keys());
233
+ while (unseen.size > 0) {
234
+ const start = [...unseen].sort()[0];
235
+ const component = [];
236
+ const pending = [start];
237
+ unseen.delete(start);
238
+ while (pending.length > 0) {
239
+ const name = pending.pop();
240
+ component.push(name);
241
+ for (const neighbor of [...edges.get(name)].sort().reverse()) {
242
+ if (!unseen.delete(neighbor)) continue;
243
+ pending.push(neighbor);
244
+ }
245
+ }
246
+ components.push(component.sort());
247
+ }
248
+ const choices = new Map(currentChoices);
249
+ const blockers = /* @__PURE__ */ new Map();
250
+ for (const component of components) {
251
+ const componentSet = new Set(component);
252
+ const baseChoices = new Map([...choices].filter(([name]) => !componentSet.has(name)));
253
+ const domains = new Map(component.map((name) => [name, states.get(name).candidates]));
254
+ let best = null;
255
+ let bestChanged = -1;
256
+ let bestVector = [];
257
+ let statesVisited = 0;
258
+ let exhausted = false;
259
+ let firstFailure = "no jointly peer-compatible published version set exists";
260
+ const memo = /* @__PURE__ */ new Set();
261
+ const visit = (assigned) => {
262
+ if (exhausted) return;
263
+ statesVisited += 1;
264
+ if (statesVisited > 5e4) {
265
+ exhausted = true;
266
+ return;
267
+ }
268
+ const merged = new Map([...baseChoices, ...assigned]);
269
+ const remaining = component.filter((name) => !assigned.has(name));
270
+ const changed = component.filter((name) => assigned.has(name) && assigned.get(name) !== states.get(name).current).length;
271
+ if (changed + remaining.length < bestChanged) return;
272
+ const pruned = /* @__PURE__ */ new Map();
273
+ for (const name of remaining) {
274
+ const viable = domains.get(name).filter((version) => validate(new Map([...merged, [name, version]]), domains) === null);
275
+ if (viable.length === 0) {
276
+ firstFailure = validate(merged, domains) ?? firstFailure;
277
+ return;
278
+ }
279
+ pruned.set(name, viable);
280
+ }
281
+ const failure = validate(merged, new Map([...domains, ...pruned]));
216
282
  if (failure) {
217
283
  firstFailure = failure;
218
284
  return;
219
285
  }
220
- const changed = variables.filter(([name, state]) => choices.get(name) !== state.current).length;
221
- if (changed > bestChanged) {
222
- best = new Map(choices);
223
- bestChanged = changed;
286
+ if (remaining.length === 0) {
287
+ const vector = component.map((name) => states.get(name).candidates.indexOf(assigned.get(name)));
288
+ const newer = vector.some((value, index) => value < (bestVector[index] ?? Number.POSITIVE_INFINITY) && vector.slice(0, index).every((prior, priorIndex) => prior === bestVector[priorIndex]));
289
+ if (changed > bestChanged || changed === bestChanged && newer) {
290
+ best = new Map(assigned);
291
+ bestChanged = changed;
292
+ bestVector = vector;
293
+ }
294
+ return;
224
295
  }
225
- return;
226
- }
227
- const [name, state] = variables[index];
228
- for (const version of state.candidates) {
229
- choices.set(name, version);
230
- visit(index + 1, choices);
231
- }
232
- };
233
- visit(0, /* @__PURE__ */ new Map());
234
- const blockers = /* @__PURE__ */ new Map();
235
- if (!best) for (const [name] of variables) blockers.set(name, firstFailure);
236
- const choices = best ?? new Map(variables.flatMap(([name, state]) => state.current ? [[name, state.current]] : []));
296
+ const next = remaining.map((name) => [name, pruned.get(name)]).sort(([leftName, left], [rightName, right]) => left.length - right.length || leftName.localeCompare(rightName))[0];
297
+ const signature = component.map((name) => {
298
+ const assignedVersion = assigned.get(name);
299
+ if (assignedVersion !== void 0) return `${name}=assigned:${assignedVersion}`;
300
+ return `${name}=domain:${(pruned.get(name) ?? []).join(",")}`;
301
+ }).join("|");
302
+ if (memo.has(signature)) return;
303
+ memo.add(signature);
304
+ for (const version of next[1]) {
305
+ assigned.set(next[0], version);
306
+ visit(assigned);
307
+ assigned.delete(next[0]);
308
+ }
309
+ };
310
+ visit(/* @__PURE__ */ new Map());
311
+ if (exhausted) {
312
+ const reason = "peer compatibility search exceeded the 50,000-state budget; resolve this component manually";
313
+ for (const name of component) blockers.set(name, reason);
314
+ } else if (!best) for (const name of component) blockers.set(name, firstFailure);
315
+ else for (const [name, version] of best) choices.set(name, version);
316
+ }
237
317
  for (const [name, state] of variables) {
318
+ if (blockers.has(name)) continue;
238
319
  if (choices.get(name) !== state.current || state.candidates[0] === state.current) continue;
239
320
  const attempted = new Map(choices).set(name, state.candidates[0]);
240
321
  blockers.set(name, validate(attempted) ?? "no jointly peer-compatible update advances this dependency");
@@ -271,7 +352,7 @@ function summarize(decisions) {
271
352
  function planUpdates(options) {
272
353
  const failures = options.failures ?? /* @__PURE__ */ new Map();
273
354
  const tags = options.tags ?? {};
274
- const mode = options.mode ?? (options.force ? "upgrade" : "update");
355
+ const mode = options.mode ?? "update";
275
356
  const context = options.contextOccurrences ?? options.occurrences;
276
357
  const workspaceSolutions = /* @__PURE__ */ new Map();
277
358
  if (mode !== "force") for (const workspace of new Set(options.occurrences.map((entry) => entry.workspace))) workspaceSolutions.set(workspace, solveWorkspace(workspace, options.occurrences.filter((entry) => entry.workspace === workspace), context, options.packuments, tags, options.cliTag, options.localVersions ?? /* @__PURE__ */ new Map(), mode));