rolldown-pnpm-config 0.6.2 → 0.7.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.
@@ -12,7 +12,7 @@ const root = Command.make("rolldown-pnpm-config").pipe(Command.withSubcommands([
12
12
  exportCommand,
13
13
  previewCommand
14
14
  ]));
15
- Command.run(root, { version: "0.6.2" }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
15
+ Command.run(root, { version: "0.7.0" }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
16
16
 
17
17
  //#endregion
18
18
  export { };
package/catalogs.js CHANGED
@@ -11,6 +11,7 @@
11
11
  function normalizeCatalogs(input) {
12
12
  const out = {};
13
13
  for (const [name, decl] of Object.entries(input)) {
14
+ if (typeof decl !== "object" || decl === null || typeof decl.packages !== "object" || decl.packages === null) continue;
14
15
  const base = {};
15
16
  const peers = {};
16
17
  for (const [pkg, spec] of Object.entries(decl.packages)) {
@@ -4,6 +4,7 @@ import { filterEntriesByCatalog, findConfigFiles, pickConfigCandidate } from "..
4
4
  import { detectCapabilities } from "../ui/env.js";
5
5
  import { derivePeerRange } from "../peer-range.js";
6
6
  import { detectPeerDrift } from "../drift.js";
7
+ import { versionKeyOf } from "../version-key.js";
7
8
  import { buildEdits } from "../edits.js";
8
9
  import { buildInteropEdits, interopEntryChanged, runInterop } from "../interop.js";
9
10
  import { buildGroupModel, computeGroupPeers } from "../interop-live.js";
@@ -15,8 +16,10 @@ import { renderSummary } from "../summary.js";
15
16
  import { runWalk } from "../ui/run-walk.js";
16
17
  import { validateEdits } from "../validate.js";
17
18
  import { buildWalkItems } from "../walk-plan.js";
19
+ import { findWorkspaceRoot, makeWorkspaceResolver } from "../workspace-resolve.js";
18
20
  import { Data, Effect, Option, Result } from "effect";
19
21
  import { readFileSync, writeFileSync } from "node:fs";
22
+ import { dirname } from "node:path";
20
23
  import { NodeServices } from "@effect/platform-node";
21
24
  import { Argument, Command, Flag } from "effect/unstable/cli";
22
25
  import { ReleaseAgeGate } from "@effected/npm";
@@ -41,40 +44,63 @@ function computeGate(source, file, resolver) {
41
44
  /** Maximum number of per-package version+times fetches to issue concurrently. @internal */
42
45
  const RESOLVE_CONCURRENCY = 12;
43
46
  /**
44
- * Fetch and age-gate the version list for each unique package.
47
+ * Fetch and age-gate the version list for each unique (pkg × route) pair.
48
+ * The returned maps are keyed by `versionKeyOf(entry)`, NOT the bare package
49
+ * name: the same name can appear workspace-sourced in one catalog and
50
+ * registry-sourced in another, and the two routes must neither share a version
51
+ * list nor a gate exemption.
45
52
  *
46
53
  * @param onProgress - Optional callback invoked after each package resolves with
47
54
  * `(resolved, total)`. Useful for emitting CLI progress feedback. Called with
48
55
  * `(0, total)` before any work starts so callers can emit the initial banner.
56
+ * @param workspace - Optional workspace-backed resolver. An entry whose
57
+ * `source` is `"workspace"` resolves through it instead of the registry, and
58
+ * is EXEMPT from the release-age gate: its next version is unpublished, so
59
+ * `times` is empty and the gate would otherwise hold it forever.
49
60
  *
50
61
  * @internal
51
62
  */
52
- function resolveGatedVersions(entries, resolver, gate, now, onProgress) {
53
- const uniquePkgs = [...new Set(entries.map((e) => e.pkg))];
54
- const total = uniquePkgs.length;
63
+ function resolveGatedVersions(entries, resolver, gate, now, onProgress, workspace) {
64
+ const pairs = /* @__PURE__ */ new Map();
65
+ for (const e of entries) pairs.set(versionKeyOf(e), {
66
+ pkg: e.pkg,
67
+ fromWorkspace: workspace !== void 0 && e.source === "workspace"
68
+ });
69
+ const total = pairs.size;
55
70
  let resolved = 0;
56
71
  onProgress?.(0, total);
57
- return Effect.forEach(uniquePkgs, (pkg) => Effect.gen(function* () {
58
- const vr = yield* resolver.versions(pkg).pipe(Effect.result);
72
+ return Effect.forEach([...pairs], ([key, { pkg, fromWorkspace }]) => Effect.gen(function* () {
73
+ const vr = yield* (fromWorkspace && workspace !== void 0 ? workspace : resolver).versions(pkg).pipe(Effect.result);
59
74
  if (Result.isFailure(vr)) {
60
75
  onProgress?.(++resolved, total);
61
76
  return [
77
+ key,
62
78
  pkg,
63
79
  [],
64
80
  []
65
81
  ];
66
82
  }
83
+ if (fromWorkspace) {
84
+ onProgress?.(++resolved, total);
85
+ return [
86
+ key,
87
+ pkg,
88
+ vr.success,
89
+ vr.success
90
+ ];
91
+ }
67
92
  const times = gate.ageMinutes > 0 ? yield* resolver.times(pkg).pipe(Effect.catch(() => Effect.succeed({}))) : {};
68
93
  onProgress?.(++resolved, total);
69
94
  return [
95
+ key,
70
96
  pkg,
71
97
  [...gate.filterVersions(vr.success, times, pkg, now)],
72
98
  vr.success
73
99
  ];
74
- }), { concurrency: 12 }).pipe(Effect.map((triples) => ({
75
- gated: new Map(triples.map(([pkg, gated]) => [pkg, gated])),
76
- raw: new Map(triples.map(([pkg, , raw]) => [pkg, raw])),
77
- unresolved: triples.filter(([, , raw]) => raw.length === 0).map(([pkg]) => pkg)
100
+ }), { concurrency: 12 }).pipe(Effect.map((rows) => ({
101
+ gated: new Map(rows.map(([key, , gated]) => [key, gated])),
102
+ raw: new Map(rows.map(([key, , , raw]) => [key, raw])),
103
+ unresolved: [...new Set(rows.filter(([, , , raw]) => raw.length === 0).map(([, pkg]) => pkg))]
78
104
  })));
79
105
  }
80
106
  /**
@@ -120,20 +146,35 @@ function runUpgrade(opts) {
120
146
  catch: (e) => new UpgradeError({ message: String(e) })
121
147
  });
122
148
  const gate = yield* computeGate(source, opts.file, opts.resolver);
123
- const versionsByPkg = yield* resolveGatedVersions(entries, opts.resolver, gate, Date.now(), opts.onProgress);
149
+ const versionsByPkg = yield* resolveGatedVersions(entries, opts.resolver, gate, Date.now(), opts.onProgress, opts.workspaceResolver);
124
150
  if (versionsByPkg.unresolved.length > 0) return yield* Effect.fail(new UpgradeError({ message: unresolvedMessage(versionsByPkg.unresolved) }));
125
151
  const edits = [];
126
152
  const interopEdits = [];
127
153
  const warnings = [];
128
154
  const changedSpans = /* @__PURE__ */ new Set();
155
+ const changedPkgs = /* @__PURE__ */ new Map();
156
+ const markChanged = (entry, to) => {
157
+ changedSpans.add(entry.rangeSpan[0]);
158
+ const name = `${entry.catalog}.${entry.pkg}`;
159
+ changedPkgs.set(name, {
160
+ name,
161
+ catalog: entry.catalog,
162
+ pkg: entry.pkg,
163
+ from: entry.currentRange,
164
+ ...to !== void 0 ? { to } : {},
165
+ source: entry.source ?? "registry"
166
+ });
167
+ };
129
168
  for (const entry of entries) {
130
169
  if (entry.strategy === "interop") continue;
131
- const versions = versionsByPkg.gated.get(entry.pkg) ?? [];
170
+ const versionKey = versionKeyOf(entry);
171
+ const versions = versionsByPkg.gated.get(versionKey) ?? [];
132
172
  const pkg = entry.pkg;
133
173
  const rangeEdit = (span, value) => ({
134
174
  span,
135
175
  text: JSON.stringify(value),
136
176
  pkg,
177
+ versionKey,
137
178
  kind: "range",
138
179
  value
139
180
  });
@@ -141,6 +182,7 @@ function runUpgrade(opts) {
141
182
  span,
142
183
  text: JSON.stringify(value),
143
184
  pkg,
185
+ versionKey,
144
186
  kind: "peer",
145
187
  value
146
188
  });
@@ -148,6 +190,7 @@ function runUpgrade(opts) {
148
190
  span: [at, at],
149
191
  text: `, peer: ${JSON.stringify(value)}`,
150
192
  pkg,
193
+ versionKey,
151
194
  kind: "peer",
152
195
  value
153
196
  });
@@ -159,32 +202,33 @@ function runUpgrade(opts) {
159
202
  const expected = yield* detectPeerDrift(entry).pipe(Effect.catch(() => Effect.succeed(null)));
160
203
  if (expected !== null) {
161
204
  edits.push(peerEdit(entry.peer.span, expected));
162
- changedSpans.add(entry.rangeSpan[0]);
205
+ markChanged(entry);
163
206
  continue;
164
207
  }
165
208
  } else if (!entry.peer && entry.strategy && derived !== null) {
166
209
  edits.push(peerInsert(at, derived.range));
167
- changedSpans.add(entry.rangeSpan[0]);
210
+ markChanged(entry);
168
211
  continue;
169
212
  }
170
213
  skipped.push(`${entry.catalog}.${entry.pkg}`);
171
214
  continue;
172
215
  }
173
- const inRange = (yield* planEntry(entry, versions).pipe(Effect.catch(() => Effect.succeed([])))).find((c) => c.kind === "in-range");
216
+ const candidates = yield* planEntry(entry, versions).pipe(Effect.catch(() => Effect.succeed([])));
217
+ const inRange = entry.source === "workspace" ? candidates.find((c) => c.kind !== "keep") : candidates.find((c) => c.kind === "in-range");
174
218
  const at = entry.rangeSpan[1];
175
219
  if (inRange) {
176
220
  edits.push(rangeEdit(entry.rangeSpan, inRange.range));
177
- changedSpans.add(entry.rangeSpan[0]);
221
+ markChanged(entry, inRange.range);
178
222
  if (entry.peer && inRange.peerRange) edits.push(peerEdit(entry.peer.span, inRange.peerRange));
179
223
  else if (!entry.peer && entry.strategy && inRange.peerRange) edits.push(peerInsert(at, inRange.peerRange));
180
224
  } else if (!entry.peer && entry.strategy && derived !== null) {
181
225
  edits.push(peerInsert(at, derived.range));
182
- changedSpans.add(entry.rangeSpan[0]);
226
+ markChanged(entry);
183
227
  } else if (entry.peer && entry.strategy) {
184
228
  const expected = yield* detectPeerDrift(entry).pipe(Effect.catch(() => Effect.succeed(null)));
185
229
  if (expected !== null) {
186
230
  edits.push(peerEdit(entry.peer.span, expected));
187
- changedSpans.add(entry.rangeSpan[0]);
231
+ markChanged(entry);
188
232
  }
189
233
  }
190
234
  }
@@ -199,7 +243,7 @@ function runUpgrade(opts) {
199
243
  for (const [, group] of byCatalog) {
200
244
  const members = [];
201
245
  for (const e of group) {
202
- const versions = versionsByPkg.gated.get(e.pkg) ?? [];
246
+ const versions = versionsByPkg.gated.get(versionKeyOf(e)) ?? [];
203
247
  const inRange = (yield* planEntry(e, versions).pipe(Effect.catch(() => Effect.succeed([])))).find((c) => c.kind === "in-range");
204
248
  const ceiling = inRange ? inRange.version : e.currentRange.replace(/^[\^~]/, "");
205
249
  members.push({
@@ -210,10 +254,18 @@ function runUpgrade(opts) {
210
254
  }
211
255
  const result = yield* runInterop(members, opts.resolver);
212
256
  interopEdits.push(...buildInteropEdits(group, result));
213
- for (const e of group) if (interopEntryChanged(e, result)) changedSpans.add(e.rangeSpan[0]);
257
+ for (const e of group) {
258
+ if (!interopEntryChanged(e, result)) continue;
259
+ const next = result.resolved.get(e.pkg);
260
+ const nextRange = next === void 0 ? void 0 : `${e.operator}${next}`;
261
+ markChanged(e, nextRange !== void 0 && nextRange !== e.currentRange ? nextRange : void 0);
262
+ }
214
263
  conflicts.push(...result.conflicts);
215
264
  }
216
- if (warnings.length > 0) return yield* Effect.fail(new UpgradeError({ message: `Refusing to apply with an incompatible peer strategy:\n${warnings.map((w) => ` ${w}`).join("\n")}` }));
265
+ if (warnings.length > 0) return yield* Effect.fail(new UpgradeError({
266
+ message: `Refusing to apply with an incompatible peer strategy:\n${warnings.map((w) => ` ${w}`).join("\n")}`,
267
+ kind: "peer-strategy"
268
+ }));
217
269
  const { accepted, rejected } = yield* validateEdits(edits, versionsByPkg.raw);
218
270
  if (rejected.length > 0) return yield* Effect.fail(new UpgradeError({ message: `Refusing to write unsatisfiable range(s):\n${rejected.map((r) => ` ${r.reason}`).join("\n")}` }));
219
271
  const allEdits = [...accepted, ...interopEdits];
@@ -228,7 +280,8 @@ function runUpgrade(opts) {
228
280
  updated: changedSpans.size,
229
281
  skipped,
230
282
  conflicts,
231
- rejected
283
+ rejected,
284
+ changed: [...changedPkgs.values()]
232
285
  };
233
286
  });
234
287
  }
@@ -283,7 +336,7 @@ function unresolvedMessage(unresolved) {
283
336
  function projectDecisions(items, full) {
284
337
  const out = [];
285
338
  for (const i of items) {
286
- const inRange = i.candidates.find((c) => c.kind === "in-range");
339
+ const inRange = i.entry.source === "workspace" ? i.candidates.find((c) => c.kind !== "keep") : i.candidates.find((c) => c.kind === "in-range");
287
340
  if (inRange) {
288
341
  out.push({
289
342
  item: i,
@@ -323,13 +376,156 @@ function runUpgradePreview(opts) {
323
376
  catch: (e) => new UpgradeError({ message: String(e) })
324
377
  });
325
378
  const gate = yield* computeGate(source, opts.file, opts.resolver);
326
- const versions = yield* resolveGatedVersions(discovered.entries, opts.resolver, gate, Date.now());
379
+ const versions = yield* resolveGatedVersions(discovered.entries, opts.resolver, gate, Date.now(), void 0, opts.workspaceResolver);
327
380
  const items = yield* buildWalkItems(discovered.entries, versions.gated).pipe(Effect.catch((e) => Effect.fail(new UpgradeError({ message: e.message }))));
328
381
  const text = renderSummary(projectDecisions(items, opts.full), void 0, { color: opts.color ?? false });
329
382
  return versions.unresolved.length > 0 ? `${text}\n⚠ ${unresolvedMessage(versions.unresolved)}` : text;
330
383
  });
331
384
  }
332
385
  /**
386
+ * Map a check run's drift list to the process outcome. `--check` is a pure
387
+ * gate: exit 0 when every entry is in sync, exit 1 when an `upgrade --yes`
388
+ * would rewrite anything — the exit code IS the contract (a release
389
+ * validation phase calls this), and it never writes.
390
+ *
391
+ * @internal
392
+ */
393
+ function checkOutcome(changed) {
394
+ if (changed.length === 0) return {
395
+ exitCode: 0,
396
+ text: "Catalogs are in sync.\n"
397
+ };
398
+ const list = changed.map((c) => ` ${c.name} (${c.source})`).join("\n");
399
+ return {
400
+ exitCode: 1,
401
+ text: `Catalog drift detected in ${changed.length} package(s):\n${list}\nRun \`rolldown-pnpm-config upgrade --yes\` to apply.\n`
402
+ };
403
+ }
404
+ /**
405
+ * Map a check run's FAILURE (a resolution failure or peer warning — an
406
+ * UpgradeError, not drift) to the process outcome. Shares --check's single
407
+ * non-zero exit code with drift, so the OUTPUT must name the failure family:
408
+ * a gate consuming the exit code reports every non-zero as "drifted", and
409
+ * without this label the CI log lies about a typo'd package or auth failure.
410
+ *
411
+ * @internal
412
+ */
413
+ function checkFailureOutcome(message) {
414
+ return {
415
+ exitCode: 1,
416
+ text: `Catalog check failed before drift could be evaluated (resolution error, not drift):\n${message.split("\n").map((l) => ` ${l}`).join("\n")}\n`
417
+ };
418
+ }
419
+ /** Project a drift row to its stable `--json` object: catalog/pkg/from/to?/source, camelCase, `to` omitted (never null) when unknown. */
420
+ function driftRowJson(row) {
421
+ return {
422
+ catalog: row.catalog,
423
+ pkg: row.pkg,
424
+ from: row.from,
425
+ ...row.to !== void 0 ? { to: row.to } : {},
426
+ source: row.source
427
+ };
428
+ }
429
+ /** Serialize one single-line JSON document, newline-terminated — the ONLY bytes `--json` puts on stdout. */
430
+ function jsonLine(doc) {
431
+ return `${JSON.stringify(doc)}\n`;
432
+ }
433
+ /**
434
+ * The `--check --json` outcome. stdout carries exactly one single-line JSON
435
+ * document in EVERY case — including the resolution-failure family, which
436
+ * keeps its non-zero exit but must never leave a bash gate with exit 1 and an
437
+ * empty stdout. The human failure label stays on stderr; the in-sync/drift
438
+ * text is superseded by the document.
439
+ *
440
+ * @internal
441
+ */
442
+ function checkJsonOutcome(result) {
443
+ if (Result.isFailure(result)) {
444
+ const message = result.failure.message;
445
+ return {
446
+ exitCode: 1,
447
+ stdout: jsonLine({
448
+ command: "check",
449
+ inSync: false,
450
+ error: {
451
+ kind: result.failure.kind ?? "resolution",
452
+ message
453
+ }
454
+ }),
455
+ stderr: checkFailureOutcome(message).text
456
+ };
457
+ }
458
+ const changed = result.success.changed;
459
+ return {
460
+ exitCode: changed.length === 0 ? 0 : 1,
461
+ stdout: jsonLine({
462
+ command: "check",
463
+ inSync: changed.length === 0,
464
+ drift: changed.map(driftRowJson)
465
+ }),
466
+ stderr: ""
467
+ };
468
+ }
469
+ /**
470
+ * The `--yes --json` / `--dry-run --json` outcome. `applied` reports whether
471
+ * the run wrote at least one change — always false under dry-run AND on an
472
+ * already-in-sync run, so a consumer keying on it never reads a no-op as a
473
+ * real apply; `changed` uses the same row object as check's `drift`. A failure
474
+ * emits an error document on stdout with a non-zero exit and the human message
475
+ * on stderr; `error.kind` is `"peer-strategy"` for a peer-strategy refusal and
476
+ * `"resolution"` otherwise.
477
+ *
478
+ * @internal
479
+ */
480
+ function upgradeJsonOutcome(result, dryRun) {
481
+ if (Result.isFailure(result)) {
482
+ const message = result.failure.message;
483
+ return {
484
+ exitCode: 1,
485
+ stdout: jsonLine({
486
+ command: "upgrade",
487
+ applied: false,
488
+ error: {
489
+ kind: result.failure.kind ?? "resolution",
490
+ message
491
+ }
492
+ }),
493
+ stderr: `${message}\n`
494
+ };
495
+ }
496
+ const r = result.success;
497
+ return {
498
+ exitCode: 0,
499
+ stdout: jsonLine({
500
+ command: "upgrade",
501
+ applied: !dryRun && r.updated > 0,
502
+ updated: r.updated,
503
+ changed: r.changed.map(driftRowJson),
504
+ skipped: r.skipped,
505
+ conflicts: r.conflicts.map((c) => ({
506
+ pkg: c.pkg,
507
+ ceiling: c.ceiling,
508
+ blockedBy: c.blockedBy
509
+ }))
510
+ }),
511
+ stderr: ""
512
+ };
513
+ }
514
+ /**
515
+ * Reject `--json` outside the non-interactive modes it exists for. JSON mode
516
+ * is a machine contract (a GitHub Action parsing stdout from bash); the
517
+ * interactive table and the preview views have no meaningful document to emit.
518
+ * Returns the rejection to fail with, or null when the combination is valid.
519
+ *
520
+ * @internal
521
+ */
522
+ function validateJsonMode(flags) {
523
+ if (!flags.json) return null;
524
+ if (flags.preview) return new UpgradeError({ message: "--json cannot be combined with --preview; use --check, --yes, or --dry-run" });
525
+ if (!flags.check && !flags.yes && !flags.dryRun) return new UpgradeError({ message: "--json requires a non-interactive mode: combine it with --check, --yes, or --dry-run" });
526
+ return null;
527
+ }
528
+ /**
333
529
  * Resolve the target file: the passed path, or autodetect in cwd.
334
530
  *
335
531
  * @internal
@@ -350,6 +546,8 @@ const dryRunFlag = Flag.boolean("dry-run").pipe(Flag.withDefault(false));
350
546
  const catalogOption = Flag.string("catalog").pipe(Flag.optional);
351
547
  const previewFlag = Flag.boolean("preview").pipe(Flag.withDefault(false));
352
548
  const fullFlag = Flag.boolean("full").pipe(Flag.withDefault(false));
549
+ const checkFlag = Flag.boolean("check").pipe(Flag.withDefault(false));
550
+ const jsonFlag = Flag.boolean("json").pipe(Flag.withDefault(false));
353
551
  /**
354
552
  * The "upgrade" command. The default path runs the interactive table;
355
553
  * --yes applies latest-in-range non-interactively; --dry-run runs the identical
@@ -364,17 +562,67 @@ const upgradeCommand = Command.make("upgrade", {
364
562
  dryRun: dryRunFlag,
365
563
  catalog: catalogOption,
366
564
  preview: previewFlag,
367
- full: fullFlag
368
- }, ({ file: fileOpt, yes, dryRun, catalog, preview, full }) => Effect.gen(function* () {
565
+ full: fullFlag,
566
+ check: checkFlag,
567
+ json: jsonFlag
568
+ }, ({ file: fileOpt, yes, dryRun, catalog, preview, full, check, json }) => Effect.gen(function* () {
569
+ const jsonRejection = validateJsonMode({
570
+ json,
571
+ check,
572
+ yes,
573
+ dryRun,
574
+ preview
575
+ });
576
+ if (jsonRejection !== null) return yield* Effect.fail(jsonRejection);
369
577
  const file = yield* resolveTargetFile(fileOpt);
370
578
  const resolver = yield* RegistryResolver;
371
579
  const caps = detectCapabilities();
580
+ const workspaceResolver = makeWorkspaceResolver(findWorkspaceRoot(dirname(file)));
581
+ if (check) {
582
+ const result = yield* runUpgrade({
583
+ file,
584
+ resolver,
585
+ workspaceResolver,
586
+ dryRun: true
587
+ }).pipe(Effect.result);
588
+ if (json) {
589
+ const o = checkJsonOutcome(result);
590
+ yield* Effect.sync(() => {
591
+ if (o.stderr !== "") process.stderr.write(o.stderr);
592
+ process.stdout.write(o.stdout);
593
+ process.exitCode = o.exitCode;
594
+ });
595
+ return;
596
+ }
597
+ const failed = Result.isFailure(result);
598
+ const outcome = failed ? checkFailureOutcome(result.failure.message) : checkOutcome(result.success.changed);
599
+ yield* Effect.sync(() => {
600
+ (failed ? process.stderr : process.stdout).write(outcome.text);
601
+ process.exitCode = outcome.exitCode;
602
+ });
603
+ return;
604
+ }
605
+ if (json) {
606
+ const o = upgradeJsonOutcome(yield* runUpgrade({
607
+ file,
608
+ resolver,
609
+ dryRun,
610
+ workspaceResolver
611
+ }).pipe(Effect.result), dryRun);
612
+ yield* Effect.sync(() => {
613
+ if (o.stderr !== "") process.stderr.write(o.stderr);
614
+ process.stdout.write(o.stdout);
615
+ process.exitCode = o.exitCode;
616
+ });
617
+ return;
618
+ }
372
619
  if (preview) {
373
620
  const text = yield* runUpgradePreview({
374
621
  file,
375
622
  resolver,
376
623
  full,
377
- color: caps.color
624
+ color: caps.color,
625
+ workspaceResolver
378
626
  });
379
627
  yield* Effect.sync(() => process.stdout.write(`${text}\n`));
380
628
  return;
@@ -384,6 +632,7 @@ const upgradeCommand = Command.make("upgrade", {
384
632
  file,
385
633
  resolver,
386
634
  dryRun,
635
+ workspaceResolver,
387
636
  ...caps.interactive ? { onProgress: writeResolveProgress } : {}
388
637
  });
389
638
  yield* Effect.sync(() => process.stdout.write(dryRun ? `Dry run — no changes written. ${result.updated} package(s) would be updated; skipped ${result.skipped.length}.\n` : `Updated ${result.updated} package(s); skipped ${result.skipped.length}.\n`));
@@ -403,7 +652,7 @@ const upgradeCommand = Command.make("upgrade", {
403
652
  });
404
653
  const catalogName = Option.getOrUndefined(catalog);
405
654
  const entries = filterEntriesByCatalog(discovered.entries, catalogName);
406
- const versions = yield* resolveGatedVersions(entries, resolver, yield* computeGate(source, file, resolver), Date.now(), caps.interactive ? writeResolveProgress : void 0);
655
+ const versions = yield* resolveGatedVersions(entries, resolver, yield* computeGate(source, file, resolver), Date.now(), caps.interactive ? writeResolveProgress : void 0, workspaceResolver);
407
656
  const items = yield* buildWalkItems(entries, versions.gated).pipe(Effect.catch((e) => Effect.fail(new UpgradeError({ message: e.message }))));
408
657
  if (!caps.interactive) {
409
658
  const text = renderSummary(projectDecisions(items, full), void 0, { color: caps.color });
@@ -492,4 +741,4 @@ const upgradeCommand = Command.make("upgrade", {
492
741
  }).pipe(Effect.provide(RegistryResolverLive), Effect.provide(NodeServices.layer))).pipe(Command.withDescription("Upgrade catalog versions in a config file"));
493
742
 
494
743
  //#endregion
495
- export { UpgradeError, applyInteropAndDecisions, computeGate, countChangedDecisions, nothingToUpgradeMessage, projectDecisions, resolveGatedVersions, resolveTargetFile, runUpgrade, runUpgradePreview, unresolvedMessage, upgradeCommand, writeResolveProgress };
744
+ export { UpgradeError, applyInteropAndDecisions, checkFailureOutcome, checkJsonOutcome, checkOutcome, computeGate, countChangedDecisions, nothingToUpgradeMessage, projectDecisions, resolveGatedVersions, resolveTargetFile, runUpgrade, runUpgradePreview, unresolvedMessage, upgradeCommand, upgradeJsonOutcome, validateJsonMode, writeResolveProgress };
package/cli/discover.js CHANGED
@@ -90,6 +90,7 @@ function discoverCatalogEntries(source, filename) {
90
90
  let rangeNode;
91
91
  let peerNode;
92
92
  let strategy;
93
+ let source;
93
94
  if (value.type === "Literal" && typeof value.value === "string") rangeNode = value;
94
95
  else if (value.type === "ObjectExpression") {
95
96
  const r = prop(value, "range");
@@ -98,6 +99,8 @@ function discoverCatalogEntries(source, filename) {
98
99
  if (p?.type === "Literal" && typeof p.value === "string") peerNode = p;
99
100
  const s = prop(value, "strategy");
100
101
  if (s?.type === "Literal" && (s.value === "lock" || s.value === "lock-minor" || s.value === "interop")) strategy = s.value;
102
+ const src = prop(value, "source");
103
+ if (src?.type === "Literal" && (src.value === "registry" || src.value === "workspace")) source = src.value;
101
104
  }
102
105
  if (!rangeNode || !SIMPLE_RANGE_RE.test(rangeNode.value)) {
103
106
  skipped.push(`${catalog}.${pkg}`);
@@ -114,7 +117,8 @@ function discoverCatalogEntries(source, filename) {
114
117
  value: peerNode.value,
115
118
  span: [peerNode.start, peerNode.end]
116
119
  } } : {},
117
- ...strategy ? { strategy } : {}
120
+ ...strategy ? { strategy } : {},
121
+ ...source ? { source } : {}
118
122
  });
119
123
  }
120
124
  }
package/cli/edits.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { versionKeyOf } from "./version-key.js";
2
+
1
3
  //#region src/cli/edits.ts
2
4
  /**
3
5
  * Convert resolved decisions into span edits. A chosen upgrade rewrites the
@@ -15,11 +17,13 @@ function buildEdits(decisions) {
15
17
  for (const { item, chosen } of decisions) {
16
18
  const { entry } = item;
17
19
  const pkg = entry.pkg;
20
+ const versionKey = versionKeyOf(entry);
18
21
  const insertAt = entry.rangeSpan[1];
19
22
  const range = (span, value) => ({
20
23
  span,
21
24
  text: JSON.stringify(value),
22
25
  pkg,
26
+ versionKey,
23
27
  kind: "range",
24
28
  value
25
29
  });
@@ -27,6 +31,7 @@ function buildEdits(decisions) {
27
31
  span,
28
32
  text: JSON.stringify(value),
29
33
  pkg,
34
+ versionKey,
30
35
  kind: "peer",
31
36
  value
32
37
  });
@@ -34,6 +39,7 @@ function buildEdits(decisions) {
34
39
  span: [insertAt, insertAt],
35
40
  text: `, peer: ${JSON.stringify(value)}`,
36
41
  pkg,
42
+ versionKey,
37
43
  kind: "peer",
38
44
  value
39
45
  });
package/cli/validate.js CHANGED
@@ -41,9 +41,10 @@ function rangeIsSatisfiable(range, versions) {
41
41
  * same drift and re-reject it forever. A package is either fully updated or
42
42
  * not touched at all.
43
43
  *
44
- * `versionsByPkg` MUST be the UNGATED version list. Validating against the
45
- * release-age-gated list would spuriously reject a package whose only matching
46
- * version was published inside the gate window.
44
+ * `versionsByPkg` MUST be the UNGATED version list, keyed by each edit's
45
+ * route-aware `versionKey` (bare package name for registry-routed edits).
46
+ * Validating against the release-age-gated list would spuriously reject a
47
+ * package whose only matching version was published inside the gate window.
47
48
  *
48
49
  * @internal
49
50
  */
@@ -51,14 +52,16 @@ function validateEdits(edits, versionsByPkg) {
51
52
  return Effect.gen(function* () {
52
53
  const byPkg = /* @__PURE__ */ new Map();
53
54
  for (const e of edits) {
54
- const group = byPkg.get(e.pkg) ?? [];
55
+ const key = e.versionKey ?? e.pkg;
56
+ const group = byPkg.get(key) ?? [];
55
57
  group.push(e);
56
- byPkg.set(e.pkg, group);
58
+ byPkg.set(key, group);
57
59
  }
58
60
  const accepted = [];
59
61
  const rejected = [];
60
- for (const [pkg, group] of byPkg) {
61
- const versions = versionsByPkg.get(pkg) ?? [];
62
+ for (const [key, group] of byPkg) {
63
+ const pkg = group[0]?.pkg ?? key;
64
+ const versions = versionsByPkg.get(key) ?? [];
62
65
  const checked = [];
63
66
  for (const e of group) checked.push({
64
67
  edit: e,
@@ -0,0 +1,18 @@
1
+ //#region src/cli/version-key.ts
2
+ /**
3
+ * Route-aware key for the resolved-version maps returned by
4
+ * `resolveGatedVersions`. The same package name can appear workspace-sourced
5
+ * in one catalog and registry-sourced in another, and the two routes resolve
6
+ * through DIFFERENT resolvers to different version lists (with different
7
+ * release-age-gate treatment) — so the maps key each (pkg × route) pair
8
+ * separately. The registry route keys by the bare package name; `:` can never
9
+ * appear in an npm package name, so the prefixed workspace form cannot collide.
10
+ *
11
+ * @internal
12
+ */
13
+ function versionKeyOf(entry) {
14
+ return entry.source === "workspace" ? `workspace:${entry.pkg}` : entry.pkg;
15
+ }
16
+
17
+ //#endregion
18
+ export { versionKeyOf };
package/cli/walk-plan.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { derivePeerRange } from "./peer-range.js";
2
2
  import { detectPeerDrift } from "./drift.js";
3
+ import { versionKeyOf } from "./version-key.js";
3
4
  import { planEntry } from "./plan.js";
4
5
  import { Effect } from "effect";
5
6
 
@@ -16,7 +17,7 @@ function buildWalkItems(entries, versionsByPkg) {
16
17
  return Effect.gen(function* () {
17
18
  const items = [];
18
19
  for (const entry of entries) {
19
- const versions = versionsByPkg.get(entry.pkg) ?? [];
20
+ const versions = versionsByPkg.get(versionKeyOf(entry)) ?? [];
20
21
  const candidates = yield* planEntry(entry, [...versions]);
21
22
  const driftPeer = yield* detectPeerDrift(entry);
22
23
  let peerWarning = null;
@@ -0,0 +1,162 @@
1
+ import { ResolveError } from "./resolve.js";
2
+ import { Effect, Layer } from "effect";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { SemVer } from "@effected/semver";
6
+ import { getWorkspacePackagesSync } from "@effected/workspaces";
7
+ import { nodeSyncOps } from "@effected/workspaces/node-sync";
8
+
9
+ //#region src/cli/workspace-resolve.ts
10
+ const BUMP_ORDER = {
11
+ patch: 0,
12
+ minor: 1,
13
+ major: 2
14
+ };
15
+ /** Matches one changeset frontmatter line: `"@scope/name": minor` (quotes optional). */
16
+ const FRONTMATTER_LINE_RE = /^\s*["']?([^"':\s]+)["']?\s*:\s*(major|minor|patch)\s*$/;
17
+ /**
18
+ * Read every workspace package manifest under `rootDir` with a usable name and
19
+ * version, honoring the workspace's own `pnpm-workspace.yaml` `packages:`
20
+ * globs (including exclusion patterns) via `@effected/workspaces` — never a
21
+ * hardcoded `packages/` directory.
22
+ *
23
+ * There is deliberately NO publishability filter: the resolver's job is
24
+ * finding packages and their versions, full stop. Whether and how a package
25
+ * publishes is the author's decision, made elsewhere — and catalog membership
26
+ * is already an explicit choice in the consuming config.
27
+ *
28
+ * The sync discovery facade is total by contract — an unreadable or malformed
29
+ * manifest (including a version-less workspace root) is skipped, never raised —
30
+ * which is exactly this resolver's degrade-don't-fail contract; the Effect
31
+ * surface (`WorkspaceDiscovery`) instead fails typed on any unusable member.
32
+ *
33
+ * @internal
34
+ */
35
+ function readManifests(rootDir) {
36
+ const out = /* @__PURE__ */ new Map();
37
+ for (const pkg of getWorkspacePackagesSync(rootDir, nodeSyncOps)) out.set(pkg.name, {
38
+ name: pkg.name,
39
+ version: pkg.version,
40
+ peerDependencies: pkg.peerDependencies
41
+ });
42
+ return out;
43
+ }
44
+ /**
45
+ * Read the pending changeset bumps from `<rootDir>/.changeset/*.md` (skipping
46
+ * README.md): each file's YAML frontmatter (between the first two `---` lines)
47
+ * maps package name → bump level; the strongest bump per package wins.
48
+ * An absent or unreadable `.changeset/` yields an empty map.
49
+ */
50
+ function readPendingBumps(rootDir) {
51
+ const out = /* @__PURE__ */ new Map();
52
+ const dir = join(rootDir, ".changeset");
53
+ if (!existsSync(dir)) return out;
54
+ let names;
55
+ try {
56
+ names = readdirSync(dir).filter((n) => n.endsWith(".md") && n !== "README.md");
57
+ } catch {
58
+ return out;
59
+ }
60
+ for (const name of names) {
61
+ let text;
62
+ try {
63
+ text = readFileSync(join(dir, name), "utf8");
64
+ } catch {
65
+ continue;
66
+ }
67
+ const lines = text.split("\n");
68
+ const fences = [];
69
+ for (let i = 0; i < lines.length && fences.length < 2; i++) if (lines[i]?.trim() === "---") fences.push(i);
70
+ const [open, close] = fences;
71
+ if (open === void 0 || close === void 0) continue;
72
+ for (const line of lines.slice(open + 1, close)) {
73
+ const m = FRONTMATTER_LINE_RE.exec(line);
74
+ if (!m) continue;
75
+ const [, pkg, bump] = m;
76
+ const prev = out.get(pkg);
77
+ if (prev === void 0 || BUMP_ORDER[bump] > BUMP_ORDER[prev]) out.set(pkg, bump);
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+ /** Apply the strongest pending bump for each package already present in `versions`. */
83
+ function applyPendingBumps(rootDir, versions) {
84
+ const bumps = readPendingBumps(rootDir);
85
+ for (const [pkg, bump] of bumps) {
86
+ const current = versions.get(pkg);
87
+ if (current === void 0) continue;
88
+ let parsed;
89
+ try {
90
+ parsed = Effect.runSync(SemVer.parse(current));
91
+ } catch {
92
+ continue;
93
+ }
94
+ versions.set(pkg, parsed.bump[bump]().toString());
95
+ }
96
+ }
97
+ /**
98
+ * Read every workspace package's NEXT release version: the current
99
+ * manifest version overlaid with the bump implied by any pending changeset.
100
+ * Absent or unreadable `.changeset/` degrades to current versions.
101
+ *
102
+ * @internal
103
+ */
104
+ function readWorkspaceVersions(rootDir) {
105
+ const out = /* @__PURE__ */ new Map();
106
+ for (const [name, manifest] of readManifests(rootDir)) out.set(name, manifest.version);
107
+ applyPendingBumps(rootDir, out);
108
+ return out;
109
+ }
110
+ /**
111
+ * The plain (layer-less) workspace resolver value, for callers that route
112
+ * per-entry between the registry service and the workspace instead of
113
+ * providing one layer for the whole run. Reads lazily on first use so
114
+ * constructing it for a config with no workspace-sourced entries costs nothing.
115
+ *
116
+ * @internal
117
+ */
118
+ function makeWorkspaceResolver(rootDir) {
119
+ let manifests;
120
+ let versions;
121
+ const load = () => {
122
+ manifests ??= readManifests(rootDir);
123
+ versions ??= readWorkspaceVersions(rootDir);
124
+ return {
125
+ manifests,
126
+ versions
127
+ };
128
+ };
129
+ return {
130
+ versions: (pkg) => {
131
+ const v = load().versions.get(pkg);
132
+ return v === void 0 ? Effect.fail(new ResolveError({
133
+ pkg,
134
+ message: `${pkg} is not a workspace package`
135
+ })) : Effect.succeed([v]);
136
+ },
137
+ times: () => Effect.succeed({}),
138
+ peerDependencies: (pkg) => Effect.succeed(load().manifests.get(pkg)?.peerDependencies ?? {}),
139
+ pnpmConfig: () => Effect.succeed(null)
140
+ };
141
+ }
142
+ /**
143
+ * Walk upward from `startDir` to the nearest directory containing
144
+ * `pnpm-workspace.yaml`, falling back to `startDir` itself. The config file an
145
+ * upgrade run rewrites may live in a nested package (e.g.
146
+ * `packages/<name>/savvy.build.ts`), while the workspace versions live at the
147
+ * repo root.
148
+ *
149
+ * @internal
150
+ */
151
+ function findWorkspaceRoot(startDir) {
152
+ let dir = resolve(startDir);
153
+ for (;;) {
154
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
155
+ const parent = dirname(dir);
156
+ if (parent === dir) return resolve(startDir);
157
+ dir = parent;
158
+ }
159
+ }
160
+
161
+ //#endregion
162
+ export { findWorkspaceRoot, makeWorkspaceResolver, readManifests, readWorkspaceVersions };
package/index.d.ts CHANGED
@@ -8,9 +8,22 @@ import { Plugin } from "rolldown";
8
8
  * @public
9
9
  */
10
10
  type PeerStrategy = "lock" | "lock-minor" | "interop";
11
+ /**
12
+ * Where a catalog entry's `range` is resolved from by the `upgrade` CLI.
13
+ * `"registry"` (the default when omitted) queries published versions;
14
+ * `"workspace"` reads the local workspace's NEXT release versions (current
15
+ * manifest versions overlaid with pending changeset bumps). Orthogonal to
16
+ * `strategy`, which governs how `peer` is derived from `range` — collapsing
17
+ * the two would drop peer materialization entirely. CLI-only metadata; the
18
+ * runtime ignores it.
19
+ *
20
+ * @public
21
+ */
22
+ type VersionSource = "registry" | "workspace";
11
23
  /**
12
24
  * A package's version: a bare range, or an object carrying a materialized peer
13
- * range (`peer`) and optional CLI recompute `strategy`.
25
+ * range (`peer`), optional CLI recompute `strategy`, and optional CLI version
26
+ * `source`.
14
27
  *
15
28
  * @public
16
29
  */
@@ -18,6 +31,7 @@ type CatalogPackageSpec = string | {
18
31
  readonly range: string;
19
32
  readonly peer?: string;
20
33
  readonly strategy?: PeerStrategy;
34
+ readonly source?: VersionSource;
21
35
  };
22
36
  /**
23
37
  * One catalog's declaration: a map of package name to version spec.
@@ -406,5 +420,5 @@ interface PluginConfig {
406
420
  */
407
421
  declare function PnpmConfigPlugin(config: PluginConfig): Plugin;
408
422
  //#endregion
409
- export { type AllowedVersionsFromCatalogs, type CatalogDeclaration, type CatalogPackageSpec, type Enforcement, type FieldInput, type LocalDirective, type PeerStrategy, type PluginConfig, PnpmConfigPlugin };
423
+ export { type AllowedVersionsFromCatalogs, type CatalogDeclaration, type CatalogPackageSpec, type Enforcement, type FieldInput, type LocalDirective, type PeerStrategy, type PluginConfig, PnpmConfigPlugin, type VersionSource };
410
424
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rolldown-pnpm-config",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "A dogfooding example of our plugin",
6
6
  "repository": {
@@ -36,8 +36,9 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@effect/platform-node": "4.0.0-rc.109",
39
- "@effected/npm": "^0.11.0",
39
+ "@effected/npm": "^0.11.1",
40
40
  "@effected/semver": "^0.5.0",
41
+ "@effected/workspaces": "^0.17.0",
41
42
  "@effected/yaml": "^0.10.0",
42
43
  "effect": "4.0.0-rc.109",
43
44
  "ink": "^7.1.1",
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.58.12"
8
+ "packageVersion": "7.58.13"
9
9
  }
10
10
  ]
11
11
  }