rolldown-pnpm-config 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,11 +77,11 @@ The bundled `rolldown-pnpm-config upgrade` command rewrites the version ranges i
77
77
 
78
78
  ```bash
79
79
  npx rolldown-pnpm-config upgrade
80
- # walks each catalog package, then:
80
+ # opens a radio-group table, one row per package, then on <Enter>:
81
81
  # Applied <n> change(s).
82
82
  ```
83
83
 
84
- The walk is interactive by default. `--yes` takes the latest in-range version without prompting, `--dry-run` prints the planned changes without writing and `--catalog <name>` restricts the walk to one catalog. Pass `--preview` for a non-interactive projection of what the walk would do, with `--full` to show the full tree without context collapsing. The output is colorized in a supporting terminal. For packages that declare a `strategy`, the command also resyncs their materialized peer range. See [upgrading catalogs](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/05-upgrading-catalogs.md) for the full surface.
84
+ The command shows an interactive table by default — every catalog package at once, one row per package, `●`/`○` bubbles, modeled on `pnpm up -i`. `--yes` takes the latest in-range version for every package without prompting and fails hard on any warning or unresolvable package name; `--dry-run` runs the identical table and skips only the write; `--catalog <name>` restricts the table to one catalog. Pass `--preview` for a non-interactive projection of what an upgrade would do, with `--full` to show up-to-date entries too. The output is colorized in a supporting terminal. For packages that declare a `strategy`, the command also resyncs their materialized peer range, preserving any prerelease identifier rather than rebuilding it from parsed version parts. See [upgrading catalogs](https://github.com/spencerbeggs/rolldown-pnpm-config/blob/main/docs/05-upgrading-catalogs.md) for the full surface.
85
85
 
86
86
  ## Exporting to pnpm-workspace.yaml
87
87
 
@@ -14,7 +14,7 @@ const root = Command.make("rolldown-pnpm-config").pipe(Command.withSubcommands([
14
14
  ]));
15
15
  Command.run(root, {
16
16
  name: "rolldown-pnpm-config",
17
- version: "0.2.2"
17
+ version: "0.3.0"
18
18
  })(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain);
19
19
 
20
20
  //#endregion
@@ -12,6 +12,7 @@ import { RegistryResolver, RegistryResolverLive } from "../resolve.js";
12
12
  import { applyEdits } from "../rewrite.js";
13
13
  import { renderSummary } from "../summary.js";
14
14
  import { runWalk } from "../ui/run-walk.js";
15
+ import { validateEdits } from "../validate.js";
15
16
  import { buildWalkItems } from "../walk-plan.js";
16
17
  import { Data, Effect, Option } from "effect";
17
18
  import { readFileSync, writeFileSync } from "node:fs";
@@ -54,12 +55,24 @@ function resolveGatedVersions(entries, resolver, gate, now, onProgress) {
54
55
  const vr = yield* resolver.versions(pkg).pipe(Effect.either);
55
56
  if (vr._tag === "Left") {
56
57
  onProgress?.(++resolved, total);
57
- return [pkg, []];
58
+ return [
59
+ pkg,
60
+ [],
61
+ []
62
+ ];
58
63
  }
59
64
  const times = gate.ageMinutes > 0 ? yield* resolver.times(pkg).pipe(Effect.catchAll(() => Effect.succeed({}))) : {};
60
65
  onProgress?.(++resolved, total);
61
- return [pkg, filterByReleaseAge(vr.right, times, gate, pkg, now)];
62
- }), { concurrency: 12 }).pipe(Effect.map((pairs) => new Map(pairs)));
66
+ return [
67
+ pkg,
68
+ filterByReleaseAge(vr.right, times, gate, pkg, now),
69
+ vr.right
70
+ ];
71
+ }), { concurrency: 12 }).pipe(Effect.map((triples) => ({
72
+ gated: new Map(triples.map(([pkg, gated]) => [pkg, gated])),
73
+ raw: new Map(triples.map(([pkg, , raw]) => [pkg, raw])),
74
+ unresolved: triples.filter(([, , raw]) => raw.length === 0).map(([pkg]) => pkg)
75
+ })));
63
76
  }
64
77
  /**
65
78
  * Write a resolve-progress line to stderr. Overwrites the previous line with
@@ -85,6 +98,12 @@ function writeResolveProgress(resolved, total) {
85
98
  * is treated as a skip, except that a strategy entry can still resync or
86
99
  * materialize its managed peer offline from the current range.
87
100
  *
101
+ * This path runs UNATTENDED (`--yes`, i.e. CI), so it fails hard rather than
102
+ * degrading: any peer-strategy warning, or any planned edit no published
103
+ * version satisfies, aborts the run and writes NOTHING. A warning that scrolls
104
+ * past unread in a CI log is a bad range in a published artifact. The
105
+ * interactive path is deliberately more forgiving (see `upgradeCommand`).
106
+ *
88
107
  * @internal
89
108
  */
90
109
  function runUpgrade(opts) {
@@ -99,33 +118,51 @@ function runUpgrade(opts) {
99
118
  });
100
119
  const gate = yield* computeGate(source, opts.file, opts.resolver);
101
120
  const versionsByPkg = yield* resolveGatedVersions(entries, opts.resolver, gate, Date.now(), opts.onProgress);
121
+ if (versionsByPkg.unresolved.length > 0) return yield* Effect.fail(new UpgradeError({ message: unresolvedMessage(versionsByPkg.unresolved) }));
102
122
  const edits = [];
123
+ const interopEdits = [];
124
+ const warnings = [];
103
125
  const changedSpans = /* @__PURE__ */ new Set();
104
126
  for (const entry of entries) {
105
127
  if (entry.strategy === "interop") continue;
106
- const versions = versionsByPkg.get(entry.pkg) ?? [];
128
+ const versions = versionsByPkg.gated.get(entry.pkg) ?? [];
129
+ const pkg = entry.pkg;
130
+ const rangeEdit = (span, value) => ({
131
+ span,
132
+ text: JSON.stringify(value),
133
+ pkg,
134
+ kind: "range",
135
+ value
136
+ });
137
+ const peerEdit = (span, value) => ({
138
+ span,
139
+ text: JSON.stringify(value),
140
+ pkg,
141
+ kind: "peer",
142
+ value
143
+ });
144
+ const peerInsert = (at, value) => ({
145
+ span: [at, at],
146
+ text: `, peer: ${JSON.stringify(value)}`,
147
+ pkg,
148
+ kind: "peer",
149
+ value
150
+ });
151
+ const derived = entry.strategy ? yield* derivePeerRange(entry.currentRange, entry.strategy).pipe(Effect.catchAll(() => Effect.succeed(null))) : null;
152
+ if (derived?.warning) warnings.push(`${entry.pkg}: ${derived.warning.message}`);
107
153
  if (versions.length === 0) {
108
154
  const at = entry.rangeSpan[1];
109
155
  if (entry.peer && entry.strategy) {
110
156
  const expected = yield* detectPeerDrift(entry).pipe(Effect.catchAll(() => Effect.succeed(null)));
111
157
  if (expected !== null) {
112
- edits.push({
113
- span: entry.peer.span,
114
- text: JSON.stringify(expected)
115
- });
116
- changedSpans.add(entry.rangeSpan[0]);
117
- continue;
118
- }
119
- } else if (!entry.peer && entry.strategy) {
120
- const peerRange = yield* derivePeerRange(entry.currentRange, entry.strategy).pipe(Effect.catchAll(() => Effect.succeed(null)));
121
- if (peerRange !== null) {
122
- edits.push({
123
- span: [at, at],
124
- text: `, peer: ${JSON.stringify(peerRange)}`
125
- });
158
+ edits.push(peerEdit(entry.peer.span, expected));
126
159
  changedSpans.add(entry.rangeSpan[0]);
127
160
  continue;
128
161
  }
162
+ } else if (!entry.peer && entry.strategy && derived !== null) {
163
+ edits.push(peerInsert(at, derived.range));
164
+ changedSpans.add(entry.rangeSpan[0]);
165
+ continue;
129
166
  }
130
167
  skipped.push(`${entry.catalog}.${entry.pkg}`);
131
168
  continue;
@@ -133,35 +170,17 @@ function runUpgrade(opts) {
133
170
  const inRange = (yield* planEntry(entry, versions).pipe(Effect.catchAll(() => Effect.succeed([])))).find((c) => c.kind === "in-range");
134
171
  const at = entry.rangeSpan[1];
135
172
  if (inRange) {
136
- edits.push({
137
- span: entry.rangeSpan,
138
- text: JSON.stringify(inRange.range)
139
- });
173
+ edits.push(rangeEdit(entry.rangeSpan, inRange.range));
174
+ changedSpans.add(entry.rangeSpan[0]);
175
+ if (entry.peer && inRange.peerRange) edits.push(peerEdit(entry.peer.span, inRange.peerRange));
176
+ else if (!entry.peer && entry.strategy && inRange.peerRange) edits.push(peerInsert(at, inRange.peerRange));
177
+ } else if (!entry.peer && entry.strategy && derived !== null) {
178
+ edits.push(peerInsert(at, derived.range));
140
179
  changedSpans.add(entry.rangeSpan[0]);
141
- if (entry.peer && inRange.peerRange) edits.push({
142
- span: entry.peer.span,
143
- text: JSON.stringify(inRange.peerRange)
144
- });
145
- else if (!entry.peer && entry.strategy && inRange.peerRange) edits.push({
146
- span: [at, at],
147
- text: `, peer: ${JSON.stringify(inRange.peerRange)}`
148
- });
149
- } else if (!entry.peer && entry.strategy) {
150
- const peerRange = yield* derivePeerRange(entry.currentRange, entry.strategy).pipe(Effect.catchAll(() => Effect.succeed(null)));
151
- if (peerRange !== null) {
152
- edits.push({
153
- span: [at, at],
154
- text: `, peer: ${JSON.stringify(peerRange)}`
155
- });
156
- changedSpans.add(entry.rangeSpan[0]);
157
- }
158
180
  } else if (entry.peer && entry.strategy) {
159
181
  const expected = yield* detectPeerDrift(entry).pipe(Effect.catchAll(() => Effect.succeed(null)));
160
182
  if (expected !== null) {
161
- edits.push({
162
- span: entry.peer.span,
163
- text: JSON.stringify(expected)
164
- });
183
+ edits.push(peerEdit(entry.peer.span, expected));
165
184
  changedSpans.add(entry.rangeSpan[0]);
166
185
  }
167
186
  }
@@ -177,7 +196,7 @@ function runUpgrade(opts) {
177
196
  for (const [, group] of byCatalog) {
178
197
  const members = [];
179
198
  for (const e of group) {
180
- const versions = versionsByPkg.get(e.pkg) ?? [];
199
+ const versions = versionsByPkg.gated.get(e.pkg) ?? [];
181
200
  const inRange = (yield* planEntry(e, versions).pipe(Effect.catchAll(() => Effect.succeed([])))).find((c) => c.kind === "in-range");
182
201
  const ceiling = inRange ? inRange.version : e.currentRange.replace(/^[\^~]/, "");
183
202
  members.push({
@@ -187,12 +206,16 @@ function runUpgrade(opts) {
187
206
  });
188
207
  }
189
208
  const result = yield* runInterop(members, opts.resolver);
190
- edits.push(...buildInteropEdits(group, result));
209
+ interopEdits.push(...buildInteropEdits(group, result));
191
210
  for (const e of group) if (interopEntryChanged(e, result)) changedSpans.add(e.rangeSpan[0]);
192
211
  conflicts.push(...result.conflicts);
193
212
  }
194
- if (edits.length > 0) {
195
- const next = applyEdits(source, edits);
213
+ 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")}` }));
214
+ const { accepted, rejected } = yield* validateEdits(edits, versionsByPkg.raw);
215
+ 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")}` }));
216
+ const allEdits = [...accepted, ...interopEdits];
217
+ if (allEdits.length > 0 && !opts.dryRun) {
218
+ const next = applyEdits(source, allEdits);
196
219
  yield* Effect.try({
197
220
  try: () => writeFileSync(opts.file, next, "utf8"),
198
221
  catch: () => new UpgradeError({ message: `Cannot write ${opts.file}` })
@@ -201,22 +224,29 @@ function runUpgrade(opts) {
201
224
  return {
202
225
  updated: changedSpans.size,
203
226
  skipped,
204
- conflicts
227
+ conflicts,
228
+ rejected
205
229
  };
206
230
  });
207
231
  }
232
+ /** Count the decisions that actually change the file (a bump, a peer resync, or a materialize). @internal */
233
+ function countChangedDecisions(decisions) {
234
+ return decisions.filter((d) => d.chosen.kind !== "keep" || d.item.entry.peer !== void 0 && d.item.driftPeer !== null || d.item.entry.peer === void 0 && d.item.materializePeer !== null).length;
235
+ }
208
236
  /**
209
- * Apply the interactive result when interop members are present: the
210
- * non-interop decisions go through `buildEdits`, the interop members through
211
- * their separately-computed span edits. Interop members are EXCLUDED from
212
- * `buildEdits` so the two never emit a range edit over the same span (which
213
- * `applyEdits` would reject as overlapping).
237
+ * Apply the interactive result: the (already validated) non-interop edits plus
238
+ * the interop members' separately-computed span edits. Interop members are
239
+ * EXCLUDED from `buildEdits` upstream so the two never emit a range edit over
240
+ * the same span (which `applyEdits` would reject as overlapping).
241
+ *
242
+ * Edits arrive pre-validated so the caller can report what was dropped rather
243
+ * than failing the whole run.
214
244
  *
215
245
  * @internal
216
246
  */
217
- function applyInteropAndDecisions(file, source, nonInteropDecisions, interopEdits) {
247
+ function applyInteropAndDecisions(file, source, nonInteropEdits, interopEdits) {
218
248
  return Effect.gen(function* () {
219
- const edits = [...buildEdits(nonInteropDecisions), ...interopEdits];
249
+ const edits = [...nonInteropEdits, ...interopEdits];
220
250
  if (edits.length === 0) return;
221
251
  const next = applyEdits(source, edits);
222
252
  yield* Effect.try({
@@ -225,6 +255,40 @@ function applyInteropAndDecisions(file, source, nonInteropDecisions, interopEdit
225
255
  });
226
256
  });
227
257
  }
258
+ /**
259
+ * Filter walk items down to the ones the interactive table should show: a row
260
+ * is actionable when it is anything but up-to-date (a range bump, a peer
261
+ * drift resync, or a peer materialization), unless `--full` asks for every
262
+ * row including inert up-to-date ones. Parity with the old walk's
263
+ * `nextActionable` auto-skip, now applied as an upfront filter instead of a
264
+ * per-step cursor advance.
265
+ *
266
+ * @internal
267
+ */
268
+ function actionableWalkItems(items, full) {
269
+ return full ? [...items] : items.filter((i) => !i.upToDate);
270
+ }
271
+ /**
272
+ * The message printed instead of entering the interactive table when nothing
273
+ * is actionable: either no catalog packages were discovered at all, or every
274
+ * discovered package is already up to date.
275
+ *
276
+ * @internal
277
+ */
278
+ function nothingToUpgradeMessage(totalItems) {
279
+ return totalItems === 0 ? "Nothing to upgrade — no catalog packages found.\n" : `Nothing to upgrade — ${totalItems} package(s) already up to date.\n`;
280
+ }
281
+ /**
282
+ * The message for packages the registry could not resolve. Almost always a
283
+ * misspelt name in the config; occasionally a private package the current
284
+ * .npmrc cannot authenticate against.
285
+ *
286
+ * @internal
287
+ */
288
+ function unresolvedMessage(unresolved) {
289
+ const list = unresolved.map((p) => ` ${p}`).join("\n");
290
+ return `Could not resolve ${unresolved.length} package(s) from the registry — check the name(s) for typos, or your registry auth:\n${list}`;
291
+ }
228
292
  /** Project walk items to the non-interactive default decisions (latest-in-range, plus peer-only keeps). @internal */
229
293
  function projectDecisions(items, full) {
230
294
  const out = [];
@@ -270,7 +334,8 @@ function runUpgradePreview(opts) {
270
334
  });
271
335
  const gate = yield* computeGate(source, opts.file, opts.resolver);
272
336
  const versions = yield* resolveGatedVersions(discovered.entries, opts.resolver, gate, Date.now());
273
- return renderSummary(projectDecisions(yield* buildWalkItems(discovered.entries, versions).pipe(Effect.catchAll((e) => Effect.fail(new UpgradeError({ message: e.message })))), opts.full), void 0, { color: opts.color ?? false });
337
+ const text = renderSummary(projectDecisions(yield* buildWalkItems(discovered.entries, versions.gated).pipe(Effect.catchAll((e) => Effect.fail(new UpgradeError({ message: e.message })))), opts.full), void 0, { color: opts.color ?? false });
338
+ return versions.unresolved.length > 0 ? `${text}\n⚠ ${unresolvedMessage(versions.unresolved)}` : text;
274
339
  });
275
340
  }
276
341
  /**
@@ -297,9 +362,10 @@ const catalogOption = Options.text("catalog").pipe(Options.optional);
297
362
  const previewFlag = Options.boolean("preview").pipe(Options.withDefault(false));
298
363
  const fullFlag = Options.boolean("full").pipe(Options.withDefault(false));
299
364
  /**
300
- * The "upgrade" command. The default path runs the interactive walk;
301
- * --yes applies latest-in-range non-interactively; --dry-run prints the
302
- * summary without writing; --catalog restricts to a single catalog by name.
365
+ * The "upgrade" command. The default path runs the interactive table;
366
+ * --yes applies latest-in-range non-interactively; --dry-run runs the identical
367
+ * interactive flow and reports what it would have written, but writes nothing;
368
+ * --catalog restricts to a single catalog by name.
303
369
  *
304
370
  * @internal
305
371
  */
@@ -328,9 +394,10 @@ const upgradeCommand = Command.make("upgrade", {
328
394
  const result = yield* runUpgrade({
329
395
  file,
330
396
  resolver,
397
+ dryRun,
331
398
  ...caps.interactive ? { onProgress: writeResolveProgress } : {}
332
399
  });
333
- yield* Effect.sync(() => process.stdout.write(`Updated ${result.updated} package(s); skipped ${result.skipped.length}.\n`));
400
+ 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`));
334
401
  if (result.conflicts.length > 0) {
335
402
  const lines = result.conflicts.map((c) => ` ${c.pkg} (kept ${c.ceiling}) blocked by ${c.blockedBy}`).join("\n");
336
403
  yield* Effect.sync(() => process.stdout.write(`Interop conflicts (left at your pick):\n${lines}\n`));
@@ -348,18 +415,21 @@ const upgradeCommand = Command.make("upgrade", {
348
415
  const catalogName = Option.getOrUndefined(catalog);
349
416
  const entries = filterEntriesByCatalog(discovered.entries, catalogName);
350
417
  const versions = yield* resolveGatedVersions(entries, resolver, yield* computeGate(source, file, resolver), Date.now(), caps.interactive ? writeResolveProgress : void 0);
351
- const items = yield* buildWalkItems(entries, versions).pipe(Effect.catchAll((e) => Effect.fail(new UpgradeError({ message: e.message }))));
352
- if (dryRun) {
353
- const decisions = projectDecisions(items, false);
354
- yield* Effect.sync(() => process.stdout.write(`${renderSummary(decisions, void 0, { color: caps.color })}\n`));
355
- return;
356
- }
418
+ const items = yield* buildWalkItems(entries, versions.gated).pipe(Effect.catchAll((e) => Effect.fail(new UpgradeError({ message: e.message }))));
357
419
  if (!caps.interactive) {
358
420
  const text = renderSummary(projectDecisions(items, full), void 0, { color: caps.color });
359
- yield* Effect.sync(() => process.stdout.write(`${text}\n\n(non-interactive terminal — run with --yes to apply, or in a TTY to choose)\n`));
421
+ const note = dryRun ? "(dry run — nothing written)" : "(non-interactive terminal — run with --yes to apply, or in a TTY to choose)";
422
+ const warn = versions.unresolved.length > 0 ? `\n⚠ ${unresolvedMessage(versions.unresolved)}\n` : "";
423
+ yield* Effect.sync(() => process.stdout.write(`${text}${warn}\n\n${note}\n`));
424
+ return;
425
+ }
426
+ const actionable = actionableWalkItems(items, full);
427
+ if (actionable.length === 0) {
428
+ const warn = versions.unresolved.length > 0 ? `⚠ ${unresolvedMessage(versions.unresolved)}\n\n` : "";
429
+ yield* Effect.sync(() => process.stdout.write(`${warn}${nothingToUpgradeMessage(items.length)}`));
360
430
  return;
361
431
  }
362
- const decisions = yield* runWalk(items);
432
+ const decisions = yield* runWalk(actionable, dryRun, versions.unresolved);
363
433
  const interopByCatalog = /* @__PURE__ */ new Map();
364
434
  for (const e of entries) {
365
435
  if (e.strategy !== "interop") continue;
@@ -382,7 +452,7 @@ const upgradeCommand = Command.make("upgrade", {
382
452
  let members = group.map((e) => ({
383
453
  pkg: e.pkg,
384
454
  ceiling: pickOf(e.pkg),
385
- candidates: versions.get(e.pkg) ?? []
455
+ candidates: versions.gated.get(e.pkg) ?? []
386
456
  }));
387
457
  const originalPick = new Map(members.map((m) => [m.pkg, m.ceiling]));
388
458
  const peerCache = /* @__PURE__ */ new Map();
@@ -393,7 +463,7 @@ const upgradeCommand = Command.make("upgrade", {
393
463
  const capEntries = group.filter((e) => reentry.some((rc) => rc.pkg === e.pkg));
394
464
  const cappedVersions = /* @__PURE__ */ new Map();
395
465
  for (const rc of reentry) {
396
- const all = versions.get(rc.pkg) ?? [];
466
+ const all = versions.gated.get(rc.pkg) ?? [];
397
467
  cappedVersions.set(rc.pkg, rc.cap === null ? all : yield* capVersions(all, rc.cap));
398
468
  }
399
469
  const reDecisions = yield* runWalk(yield* buildWalkItems(capEntries, cappedVersions).pipe(Effect.catchAll((err) => Effect.fail(new UpgradeError({ message: err.message })))));
@@ -424,14 +494,17 @@ const upgradeCommand = Command.make("upgrade", {
424
494
  });
425
495
  }
426
496
  }
497
+ const { accepted, rejected } = yield* validateEdits(buildEdits(nonInteropDecisions), versions.raw);
498
+ const acceptedPkgs = new Set(accepted.map((e) => e.pkg));
427
499
  yield* Effect.sync(() => process.stdout.write(`${renderSummary(decisions, {
428
500
  adjustments,
429
501
  conflicts: allConflicts
430
- }, { color: caps.color })}\n`));
431
- yield* applyInteropAndDecisions(file, source, nonInteropDecisions, interopEdits);
432
- const nonInteropChanged = nonInteropDecisions.filter((d) => d.chosen.kind !== "keep" || d.item.entry.peer !== void 0 && d.item.driftPeer !== null || d.item.entry.peer === void 0 && d.item.materializePeer !== null).length;
433
- yield* Effect.sync(() => process.stdout.write(`Applied ${nonInteropChanged + interopChanged} change(s).\n`));
502
+ }, { color: caps.color }, rejected)}\n`));
503
+ if (!dryRun) yield* applyInteropAndDecisions(file, source, accepted, interopEdits);
504
+ const changed = countChangedDecisions(nonInteropDecisions.filter((d) => acceptedPkgs.has(d.item.entry.pkg))) + interopChanged;
505
+ yield* Effect.sync(() => process.stdout.write(dryRun ? `Dry run — no changes written. ${changed} change(s) would be applied.\n` : `Applied ${changed} change(s).\n`));
506
+ if (versions.unresolved.length > 0) yield* Effect.sync(() => process.stdout.write(`\n⚠ ${unresolvedMessage(versions.unresolved)}\n`));
434
507
  }).pipe(Effect.provide(RegistryResolverLive), Effect.provide(NodeContext.layer))).pipe(Command.withDescription("Upgrade catalog versions in a config file"));
435
508
 
436
509
  //#endregion
437
- export { UpgradeError, applyInteropAndDecisions, computeGate, projectDecisions, resolveGatedVersions, resolveTargetFile, runUpgrade, runUpgradePreview, upgradeCommand, writeResolveProgress };
510
+ export { UpgradeError, actionableWalkItems, applyInteropAndDecisions, computeGate, countChangedDecisions, nothingToUpgradeMessage, projectDecisions, resolveGatedVersions, resolveTargetFile, runUpgrade, runUpgradePreview, unresolvedMessage, upgradeCommand, writeResolveProgress };
package/cli/drift.js CHANGED
@@ -8,12 +8,18 @@ import { Effect } from "effect";
8
8
  * up-to-date peer range) on drift, or null when in sync or not applicable
9
9
  * (missing peer or strategy).
10
10
  *
11
+ * "interop" is NOT a per-package derivation: an interop peer is computed
12
+ * GROUP-WISE by `interop.ts` from the resolved peerDependencies of the whole
13
+ * catalog group. Deriving one per package here would fall through to the
14
+ * lock-minor branch and report a bogus resync target, so interop entries are
15
+ * excluded — parity with `materializePeer` in walk-plan.ts.
16
+ *
11
17
  * @internal
12
18
  */
13
19
  function detectPeerDrift(entry) {
14
20
  return Effect.gen(function* () {
15
- if (!entry.peer || !entry.strategy) return null;
16
- const expected = yield* derivePeerRange(entry.currentRange, entry.strategy);
21
+ if (!entry.peer || !entry.strategy || entry.strategy === "interop") return null;
22
+ const { range: expected } = yield* derivePeerRange(entry.currentRange, entry.strategy);
17
23
  return expected === entry.peer.value ? null : expected;
18
24
  });
19
25
  }
package/cli/edits.js CHANGED
@@ -5,34 +5,44 @@
5
5
  * recomputed peerRange). A keep with peer drift rewrites only the peer literal
6
6
  * to the resync target.
7
7
  *
8
+ * Each edit is tagged with its package and unquoted range so `validateEdits`
9
+ * can check it against the registry before it is written.
10
+ *
8
11
  * @internal
9
12
  */
10
13
  function buildEdits(decisions) {
11
14
  const edits = [];
12
15
  for (const { item, chosen } of decisions) {
13
16
  const { entry } = item;
17
+ const pkg = entry.pkg;
14
18
  const insertAt = entry.rangeSpan[1];
15
- if (chosen.kind !== "keep") {
16
- edits.push({
17
- span: entry.rangeSpan,
18
- text: JSON.stringify(chosen.range)
19
- });
20
- if (entry.peer && chosen.peerRange) edits.push({
21
- span: entry.peer.span,
22
- text: JSON.stringify(chosen.peerRange)
23
- });
24
- else if (!entry.peer && entry.strategy && chosen.peerRange) edits.push({
25
- span: [insertAt, insertAt],
26
- text: `, peer: ${JSON.stringify(chosen.peerRange)}`
27
- });
28
- } else if (entry.peer && item.driftPeer) edits.push({
29
- span: entry.peer.span,
30
- text: JSON.stringify(item.driftPeer)
19
+ const range = (span, value) => ({
20
+ span,
21
+ text: JSON.stringify(value),
22
+ pkg,
23
+ kind: "range",
24
+ value
31
25
  });
32
- else if (!entry.peer && item.materializePeer) edits.push({
26
+ const peer = (span, value) => ({
27
+ span,
28
+ text: JSON.stringify(value),
29
+ pkg,
30
+ kind: "peer",
31
+ value
32
+ });
33
+ const peerInsert = (value) => ({
33
34
  span: [insertAt, insertAt],
34
- text: `, peer: ${JSON.stringify(item.materializePeer)}`
35
+ text: `, peer: ${JSON.stringify(value)}`,
36
+ pkg,
37
+ kind: "peer",
38
+ value
35
39
  });
40
+ if (chosen.kind !== "keep") {
41
+ edits.push(range(entry.rangeSpan, chosen.range));
42
+ if (entry.peer && chosen.peerRange) edits.push(peer(entry.peer.span, chosen.peerRange));
43
+ else if (!entry.peer && entry.strategy && chosen.peerRange) edits.push(peerInsert(chosen.peerRange));
44
+ } else if (entry.peer && item.driftPeer) edits.push(peer(entry.peer.span, item.driftPeer));
45
+ else if (!entry.peer && item.materializePeer) edits.push(peerInsert(item.materializePeer));
36
46
  }
37
47
  return edits;
38
48
  }
package/cli/peer-range.js CHANGED
@@ -15,8 +15,20 @@ const PREFIX_RE = /^(\^|~|)(\d.*)$/;
15
15
  * "lock" pins to the exact version; "lock-minor" floors the patch to .0.
16
16
  * The operator (^/~/exact) is preserved.
17
17
  *
18
- * Note: expects a release (non-prerelease) version string; a prerelease tag
19
- * would be dropped by the major.minor.patch reconstruction.
18
+ * "lock" reuses the version text verbatim rather than rebuilding it from
19
+ * major.minor.patch, so prerelease and build identifiers survive intact
20
+ * (rebuilding would silently drop them and derive an unpublished range).
21
+ *
22
+ * "lock-minor" floors a stable version's patch to .0, which intentionally
23
+ * drops any build metadata: `^6.5.1+build.7` derives to `^6.5.0`, not
24
+ * `^6.5.0+build.7`. Build metadata identifies a specific build of 6.5.1, not
25
+ * of the floored 6.5.0, and semver ignores build metadata when matching
26
+ * ranges anyway, so carrying it forward would be misleading.
27
+ *
28
+ * "lock-minor" is not meaningful on a prerelease — flooring `3.0.0-next.8` to
29
+ * `^3.0.0` yields a range that does not match `3.0.0-next.8` at all, excluding
30
+ * the very version being catalogued. It therefore degrades to "lock" behavior
31
+ * and reports a warning rather than emitting an unsatisfiable range.
20
32
  *
21
33
  * @internal
22
34
  */
@@ -26,7 +38,21 @@ function derivePeerRange(range, strategy) {
26
38
  if (!match) return yield* Effect.fail(new PeerRangeError({ message: `Cannot derive peer range from "${range}"` }));
27
39
  const [, prefix, version] = match;
28
40
  const parsed = yield* SemVer.parse(version).pipe(Effect.mapError(() => new PeerRangeError({ message: `Invalid version in range "${range}"` })));
29
- return strategy === "lock" ? `${prefix}${parsed.major}.${parsed.minor}.${parsed.patch}` : `${prefix}${parsed.major}.${parsed.minor}.0`;
41
+ if (strategy === "lock") return {
42
+ range: `${prefix}${version}`,
43
+ warning: null
44
+ };
45
+ if (parsed.prerelease.length > 0) return {
46
+ range: `${prefix}${version}`,
47
+ warning: {
48
+ kind: "lock-minor-prerelease",
49
+ message: `lock-minor cannot floor the prerelease "${version}" — pinned to the exact version instead`
50
+ }
51
+ };
52
+ return {
53
+ range: `${prefix}${parsed.major}.${parsed.minor}.0`,
54
+ warning: null
55
+ };
30
56
  });
31
57
  }
32
58
 
package/cli/plan.js CHANGED
@@ -17,19 +17,21 @@ const parseOrNull = (v) => SemVer.parse(v).pipe(Effect.catchAll(() => Effect.suc
17
17
  function planEntry(entry, versions) {
18
18
  return Effect.gen(function* () {
19
19
  const range = yield* Range.parse(entry.currentRange).pipe(Effect.catchAll(() => Effect.succeed(null)));
20
+ const currentStripped = entry.currentRange.replace(/^[\^~]/, "");
21
+ const current = yield* parseOrNull(currentStripped);
22
+ const currentMajor = current?.major ?? 0;
23
+ const track = current && current.prerelease.length > 0 ? String(current.prerelease[0]) : null;
24
+ const onTrack = (v) => track !== null && v.prerelease.length > 0 && String(v.prerelease[0]) === track;
20
25
  const parsed = [];
21
26
  for (const v of versions) {
22
27
  const sv = yield* parseOrNull(v);
23
- if (sv?.isStable) parsed.push(sv);
28
+ if (sv && (sv.isStable || onTrack(sv))) parsed.push(sv);
24
29
  }
25
30
  parsed.sort((a, b) => a.compare(b));
26
31
  const maxOf = (list) => list.length ? list[list.length - 1] : null;
27
- const currentStripped = entry.currentRange.replace(/^[\^~]/, "");
28
- const current = yield* parseOrNull(currentStripped);
29
- const currentMajor = current?.major ?? 0;
30
32
  const inRangeMax = range ? maxOf(parsed.filter((v) => range.test(v))) : null;
31
33
  const overallMax = maxOf(parsed);
32
- const withPeer = (version) => entry.strategy && entry.strategy !== "interop" ? derivePeerRange(`${entry.operator}${version}`, entry.strategy) : Effect.succeed(void 0);
34
+ const withPeer = (version) => entry.strategy && entry.strategy !== "interop" ? derivePeerRange(`${entry.operator}${version}`, entry.strategy).pipe(Effect.map((d) => d.range)) : Effect.succeed(void 0);
33
35
  const candidates = [];
34
36
  if (inRangeMax && (current === null || inRangeMax.gt(current))) {
35
37
  const version = inRangeMax.toString();
package/cli/summary.js CHANGED
@@ -1,89 +1,90 @@
1
1
  import { toAnsi } from "./ui/ansi.js";
2
+ import { displayCandidates, peerFor } from "./walk-reducer.js";
2
3
 
3
4
  //#region src/cli/summary.ts
5
+ /** Trailing annotation appended to a major candidate's cell, e.g. " ⚠ major". */
6
+ const MAJOR_SUFFIX = " ⚠ major";
7
+ /** Filled / hollow radio glyphs. MUST match `ui/Walk.ts` — the summary mirrors the table. */
8
+ const SELECTED = "●";
9
+ const UNSELECTED = "○";
10
+ /** "● " / "○ " glyph-plus-space prefix width, common to every cell. */
11
+ const BUBBLE_WIDTH = 2;
4
12
  /**
5
- * Build the pending-decisions summary as styled lines: one line per real
6
- * change, peer changes indented, a dim tally, then interop adjustments and
7
- * conflicts. Pure; color is applied by `renderSummary`/`toAnsi`.
13
+ * Build the pending-decisions summary as styled lines: one table row per
14
+ * decision mirroring the interactive selection table, catalog headers,
15
+ * chosen bubble filled then a dim tally, interop adjustments, conflicts,
16
+ * and any rejected edits. Pure; color is applied by `renderSummary`/`toAnsi`.
8
17
  *
9
18
  * @internal
10
19
  */
11
- function summaryLines(decisions, interop) {
20
+ function summaryLines(decisions, interop, rejected) {
12
21
  const lines = [];
13
22
  let toUpdate = 0;
14
23
  let major = 0;
15
24
  let resync = 0;
16
25
  let materialize = 0;
17
26
  let upToDate = 0;
27
+ const pkgWidth = decisions.length ? Math.max(...decisions.map((d) => d.item.entry.pkg.length)) : 0;
28
+ const cellWidth = decisions.length ? Math.max(...decisions.flatMap((d) => displayCandidates(d.item).map((c) => c.range.length + (c.isMajor ? 8 : 0)))) : 0;
29
+ const maxCells = decisions.length ? Math.max(...decisions.map((d) => displayCandidates(d.item).length)) : 0;
30
+ const blankCell = `${" ".repeat(BUBBLE_WIDTH + cellWidth)} `;
31
+ let lastCatalog = null;
18
32
  for (const { item, chosen } of decisions) {
19
33
  const { entry } = item;
20
- if (chosen.kind !== "keep") {
21
- toUpdate++;
22
- if (chosen.isMajor) major++;
34
+ if (entry.catalog !== lastCatalog) {
35
+ lastCatalog = entry.catalog;
23
36
  lines.push({
24
37
  indent: 0,
25
- gutter: "~",
38
+ gutter: " ",
26
39
  segments: [{
27
- text: `${entry.catalog} ${entry.pkg} ${entry.currentRange} → ${chosen.range}`,
28
- style: "changed"
40
+ text: `── catalog: ${entry.catalog} ──`,
41
+ style: "unchanged"
29
42
  }]
30
43
  });
31
- if (entry.peer && chosen.peerRange && chosen.peerRange !== entry.peer.value) lines.push({
32
- indent: 1,
33
- gutter: "~",
34
- segments: [{
35
- text: `↳ peer ${entry.peer.value} → ${chosen.peerRange}`,
36
- style: "changed"
37
- }]
38
- });
39
- else if (!entry.peer && entry.strategy && chosen.peerRange) {
40
- lines.push({
41
- indent: 1,
42
- gutter: "+",
43
- segments: [{
44
- text: `↳ peer (new) → ${chosen.peerRange}`,
45
- style: "added"
46
- }]
47
- });
48
- materialize++;
49
- }
50
- } else if (entry.peer && item.driftPeer) {
51
- resync++;
52
- lines.push({
53
- indent: 0,
54
- gutter: "~",
55
- segments: [{
56
- text: `${entry.catalog} › ${entry.pkg} (resync peer)`,
57
- style: "changed"
58
- }]
59
- });
60
- lines.push({
61
- indent: 1,
62
- gutter: "~",
63
- segments: [{
64
- text: `↳ peer ${entry.peer.value} → ${item.driftPeer}`,
65
- style: "changed"
66
- }]
67
- });
68
- } else if (!entry.peer && item.materializePeer) {
69
- materialize++;
70
- lines.push({
71
- indent: 0,
72
- gutter: "+",
73
- segments: [{
74
- text: `${entry.catalog} › ${entry.pkg} (materialize peer)`,
75
- style: "added"
76
- }]
77
- });
78
- lines.push({
79
- indent: 1,
80
- gutter: "+",
81
- segments: [{
82
- text: `↳ peer (new) → ${item.materializePeer}`,
83
- style: "added"
84
- }]
44
+ }
45
+ const cells = displayCandidates(item);
46
+ const segments = [{
47
+ text: entry.pkg.padEnd(pkgWidth + 2),
48
+ style: "plain"
49
+ }];
50
+ for (const c of cells) {
51
+ const selected = c.kind === chosen.kind;
52
+ const bubble = selected ? SELECTED : UNSELECTED;
53
+ const style = !selected ? "unchanged" : c.kind === "keep" ? "unchanged" : c.isMajor ? "changed" : "added";
54
+ const content = `${c.range}${c.isMajor ? MAJOR_SUFFIX : ""}`.padEnd(cellWidth);
55
+ segments.push({
56
+ text: `${bubble} ${content} `,
57
+ style
85
58
  });
86
- } else upToDate++;
59
+ }
60
+ for (let ci = cells.length; ci < maxCells; ci++) segments.push({
61
+ text: blankCell,
62
+ style: "plain"
63
+ });
64
+ segments.push({
65
+ text: `│ ${peerFor(item, chosen)}`,
66
+ style: "unchanged"
67
+ });
68
+ lines.push({
69
+ indent: 0,
70
+ gutter: chosen.kind === "keep" ? " " : "~",
71
+ segments
72
+ });
73
+ if (item.peerWarning) lines.push({
74
+ indent: 1,
75
+ gutter: "⚠",
76
+ segments: [{
77
+ text: item.peerWarning.message,
78
+ style: "warn"
79
+ }]
80
+ });
81
+ if (chosen.kind !== "keep") {
82
+ toUpdate++;
83
+ if (chosen.isMajor) major++;
84
+ if (!entry.peer && entry.strategy && chosen.peerRange) materialize++;
85
+ } else if (entry.peer && item.driftPeer) resync++;
86
+ else if (!entry.peer && item.materializePeer) materialize++;
87
+ else upToDate++;
87
88
  }
88
89
  lines.push({
89
90
  indent: 0,
@@ -121,6 +122,32 @@ function summaryLines(decisions, interop) {
121
122
  }]
122
123
  });
123
124
  }
125
+ if (rejected && rejected.length > 0) {
126
+ lines.push({
127
+ indent: 0,
128
+ gutter: " ",
129
+ segments: [{
130
+ text: "",
131
+ style: "plain"
132
+ }]
133
+ });
134
+ lines.push({
135
+ indent: 0,
136
+ gutter: "⚠",
137
+ segments: [{
138
+ text: "Rejected (no published version satisfies these):",
139
+ style: "warn"
140
+ }]
141
+ });
142
+ for (const r of rejected) lines.push({
143
+ indent: 1,
144
+ gutter: "⚠",
145
+ segments: [{
146
+ text: `${r.pkg} ${r.kind} ${r.value} — ${r.reason}`,
147
+ style: "warn"
148
+ }]
149
+ });
150
+ }
124
151
  return lines;
125
152
  }
126
153
  /**
@@ -129,8 +156,8 @@ function summaryLines(decisions, interop) {
129
156
  *
130
157
  * @internal
131
158
  */
132
- function renderSummary(decisions, interop, opts) {
133
- return toAnsi(summaryLines(decisions, interop), { color: opts?.color ?? false });
159
+ function renderSummary(decisions, interop, opts, rejected) {
160
+ return toAnsi(summaryLines(decisions, interop, rejected), { color: opts?.color ?? false });
134
161
  }
135
162
 
136
163
  //#endregion
package/cli/ui/Walk.js CHANGED
@@ -1,54 +1,80 @@
1
- import { initWalk, walkStep } from "../walk-reducer.js";
1
+ import { cellColor, displayCandidates, initTable, peerFor, tableDecisions, tableStep } from "../walk-reducer.js";
2
2
  import { Box, Text, useApp, useInput } from "ink";
3
3
  import { createElement, useEffect, useState } from "react";
4
4
 
5
5
  //#region src/cli/ui/Walk.ts
6
+ /** Rows visible at once before the viewport scrolls. */
7
+ const VIEWPORT = 20;
6
8
  /**
7
- * Interactive per-package upgrade selector rendered with Ink.
9
+ * Interactive upgrade table rendered with Ink. Every package is one row; each
10
+ * row is a radio group over its candidates with keep (index 0) preselected, so
11
+ * the default state of the table applies nothing.
8
12
  *
9
13
  * Written with React.createElement (no JSX) so the file can be plain .ts
10
14
  * without requiring TSX transform configuration.
11
15
  *
12
16
  * @internal
13
17
  */
14
- function Walk({ items, onDone }) {
18
+ function Walk({ items, onDone, dryRun = false, unresolved = [] }) {
15
19
  const app = useApp();
16
- const [state, setState] = useState(() => initWalk(items));
20
+ const [state, setState] = useState(() => initTable(items));
17
21
  useEffect(() => {
18
- if (state.done) {
19
- onDone(state.decisions);
22
+ if (items.length === 0) {
23
+ onDone([]);
20
24
  app.exit();
21
25
  }
22
26
  }, []);
23
27
  useInput((_input, key) => {
24
28
  if (state.done) return;
25
- const which = key.upArrow ? "up" : key.downArrow ? "down" : key.return ? "enter" : null;
29
+ const which = key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : key.return ? "submit" : key.escape ? "cancel" : null;
26
30
  if (!which) return;
27
- const next = walkStep(state, items, which);
31
+ const next = tableStep(state, items, which);
28
32
  setState(next);
29
33
  if (next.done) {
30
- onDone(next.decisions);
34
+ onDone(tableDecisions(next, items));
31
35
  app.exit();
32
36
  }
33
37
  });
34
- if (state.done || state.index >= items.length) return createElement(Text, null, "Done.");
35
- const item = items[state.index];
36
- const e = item.entry;
37
- return createElement(Box, { flexDirection: "column" }, createElement(Text, null, [
38
- `${e.catalog} ${e.pkg} current ${e.currentRange}`,
39
- e.peer ? ` peer ${e.peer.value}` : "",
40
- e.strategy ? ` strategy: ${e.strategy}` : ""
41
- ].join("")), ...item.candidates.map((c, i) => {
42
- const selected = i === state.cursor;
43
- const cursor = selected ? "❯ " : " ";
44
- const base = c.kind === "keep" ? `keep ${c.range}` : `${c.range} ${c.kind}`;
45
- const colorProps = selected ? { color: "cyan" } : c.isMajor ? { color: "yellow" } : {};
46
- const text = c.kind === "keep" ? `${cursor}${base}` : `${cursor}${base}${c.isMajor ? " ⚠ major" : ""}`;
47
- return createElement(Text, {
48
- key: c.kind,
49
- ...colorProps
50
- }, text);
51
- }));
38
+ if (state.done || items.length === 0) return createElement(Text, null, "Done.");
39
+ const pkgWidth = Math.max(...items.map((i) => i.entry.pkg.length));
40
+ const MAJOR_SUFFIX = " ⚠ major";
41
+ const SELECTED = "";
42
+ const UNSELECTED = "○";
43
+ const cellWidth = Math.max(...items.flatMap((i) => displayCandidates(i).map((c) => c.range.length + (c.isMajor ? 8 : 0))));
44
+ const maxCells = Math.max(...items.map((i) => displayCandidates(i).length));
45
+ const blankCell = `${" ".repeat(2 + cellWidth)} `;
46
+ const start = Math.max(0, Math.min(state.cursor - Math.floor(VIEWPORT / 2), items.length - VIEWPORT));
47
+ const visible = items.slice(Math.max(0, start), Math.max(0, start) + VIEWPORT);
48
+ const rows = [];
49
+ let lastCatalog = null;
50
+ visible.forEach((item, offset) => {
51
+ const i = Math.max(0, start) + offset;
52
+ if (item.entry.catalog !== lastCatalog) {
53
+ lastCatalog = item.entry.catalog;
54
+ rows.push(createElement(Text, {
55
+ key: `cat-${item.entry.catalog}`,
56
+ dimColor: true
57
+ }, ` ── catalog: ${lastCatalog} ──`));
58
+ }
59
+ const onCursor = i === state.cursor;
60
+ const pick = state.picks[i] ?? 0;
61
+ const candidates = displayCandidates(item);
62
+ const cells = candidates.map((c, ci) => {
63
+ const selected = ci === pick;
64
+ const bubble = selected ? SELECTED : UNSELECTED;
65
+ const major = c.isMajor ? MAJOR_SUFFIX : "";
66
+ const content = `${c.range}${major}`.padEnd(cellWidth);
67
+ const color = cellColor(c, selected);
68
+ return createElement(Text, {
69
+ key: c.kind,
70
+ ...color ? { color } : {}
71
+ }, `${bubble} ${content} `);
72
+ });
73
+ for (let ci = candidates.length; ci < maxCells; ci++) cells.push(createElement(Text, { key: `blank-${ci}` }, blankCell));
74
+ const chosen = candidates[pick];
75
+ rows.push(createElement(Box, { key: `${item.entry.catalog}/${item.entry.pkg}` }, createElement(Text, { ...onCursor ? { color: "cyan" } : {} }, onCursor ? "❯ " : " "), createElement(Text, { bold: onCursor }, item.entry.pkg.padEnd(pkgWidth + 2)), ...cells, createElement(Text, { dimColor: true }, `│ ${chosen === void 0 ? "—" : peerFor(item, chosen)}`), item.peerWarning ? createElement(Text, { color: "red" }, ` ⚠ ${item.peerWarning.message}`) : null));
76
+ });
77
+ return createElement(Box, { flexDirection: "column" }, createElement(Text, { bold: true }, dryRun ? "Enter to preview • Esc to cancel" : "Enter to update • Esc to cancel"), dryRun ? createElement(Text, { color: "yellow" }, "DRY RUN — nothing will be written to the config") : null, unresolved.length > 0 ? createElement(Text, { color: "red" }, `⚠ Could not resolve from the registry — check for a typo: ${unresolved.join(", ")}`) : null, createElement(Box, { height: 1 }), ...rows);
52
78
  }
53
79
 
54
80
  //#endregion
@@ -5,17 +5,19 @@ import { createElement } from "react";
5
5
 
6
6
  //#region src/cli/ui/run-walk.ts
7
7
  /**
8
- * Render the interactive Walk inside an Effect, resolving with the collected
9
- * decisions once the user finishes (or immediately when nothing is actionable),
10
- * after Ink has fully exited.
8
+ * Render the interactive table inside an Effect, resolving with the collected
9
+ * decisions once the user submits (or with an empty list when they cancel with
10
+ * Esc, or when no rows are actionable), after Ink has fully exited.
11
11
  *
12
12
  * @internal
13
13
  */
14
- function runWalk(items) {
14
+ function runWalk(items, dryRun = false, unresolved = []) {
15
15
  return Effect.async((resume) => {
16
16
  let collected = [];
17
17
  render(createElement(Walk, {
18
18
  items,
19
+ dryRun,
20
+ unresolved,
19
21
  onDone: (d) => {
20
22
  collected = d;
21
23
  }
@@ -0,0 +1,88 @@
1
+ import { Effect } from "effect";
2
+ import { Range, SemVer } from "semver-effect";
3
+
4
+ //#region src/cli/validate.ts
5
+ /**
6
+ * Whether at least one published version satisfies `range`.
7
+ *
8
+ * The predicate is deliberately "some published version satisfies this range",
9
+ * NOT "this exact version was published": `^3.4.0` is a valid lock-minor floor
10
+ * even when 3.4.0 itself never shipped but 3.4.1 did. Conversely `^3.0.0`
11
+ * against a package with only `3.0.0-next.*` releases matches nothing.
12
+ *
13
+ * Fails OPEN — an empty version list (fetch failure, fully age-gated package)
14
+ * or an unparseable range yields `true`. Validation is a safety net against
15
+ * ranges we DERIVED wrongly, not a gate on the author's own hand-written
16
+ * ranges, and rejecting everything the moment the registry is unreachable would
17
+ * break offline peer materialization.
18
+ *
19
+ * @internal
20
+ */
21
+ function rangeIsSatisfiable(range, versions) {
22
+ return Effect.gen(function* () {
23
+ if (versions.length === 0) return true;
24
+ const parsedRange = yield* Range.parse(range).pipe(Effect.catchAll(() => Effect.succeed(null)));
25
+ if (parsedRange === null) return true;
26
+ for (const v of versions) {
27
+ const sv = yield* SemVer.parse(v).pipe(Effect.catchAll(() => Effect.succeed(null)));
28
+ if (sv && parsedRange.test(sv)) return true;
29
+ }
30
+ return false;
31
+ });
32
+ }
33
+ /**
34
+ * Partition planned edits into those whose range some published version
35
+ * satisfies and those none does — ATOMICALLY per package. A catalog package
36
+ * with a `strategy` produces a `range` edit and a `peer` edit as a pair; if
37
+ * either is unsatisfiable, BOTH are rejected. Writing one half of the pair
38
+ * (say, an accepted range bump next to a rejected, stale peer) would leave the
39
+ * file internally inconsistent — the peer no longer matches what the strategy
40
+ * derives from the new range — and every subsequent run would re-report the
41
+ * same drift and re-reject it forever. A package is either fully updated or
42
+ * not touched at all.
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.
47
+ *
48
+ * @internal
49
+ */
50
+ function validateEdits(edits, versionsByPkg) {
51
+ return Effect.gen(function* () {
52
+ const byPkg = /* @__PURE__ */ new Map();
53
+ for (const e of edits) {
54
+ const group = byPkg.get(e.pkg) ?? [];
55
+ group.push(e);
56
+ byPkg.set(e.pkg, group);
57
+ }
58
+ const accepted = [];
59
+ const rejected = [];
60
+ for (const [pkg, group] of byPkg) {
61
+ const versions = versionsByPkg.get(pkg) ?? [];
62
+ const checked = [];
63
+ for (const e of group) checked.push({
64
+ edit: e,
65
+ ok: yield* rangeIsSatisfiable(e.value, versions)
66
+ });
67
+ const failing = checked.filter((c) => !c.ok);
68
+ if (failing.length === 0) {
69
+ accepted.push(...group);
70
+ continue;
71
+ }
72
+ const failingKinds = failing.map((f) => f.edit.kind).join(" and ");
73
+ for (const { edit, ok } of checked) rejected.push({
74
+ pkg,
75
+ kind: edit.kind,
76
+ value: edit.value,
77
+ reason: ok ? `dropped along with its ${failingKinds} edit for ${pkg}, which is unsatisfiable` : `no published version of ${pkg} satisfies ${edit.value}`
78
+ });
79
+ }
80
+ return {
81
+ accepted,
82
+ rejected
83
+ };
84
+ });
85
+ }
86
+
87
+ //#endregion
88
+ export { rangeIsSatisfiable, validateEdits };
package/cli/walk-plan.js CHANGED
@@ -7,7 +7,8 @@ import { Effect } from "effect";
7
7
  /**
8
8
  * Build the interactive walk items: for each entry, its candidate list (from
9
9
  * planEntry against the resolved versions), an up-to-date flag (only the keep
10
- * candidate remains), and any peer drift resync target.
10
+ * candidate remains), any peer drift resync target, any peer to materialize,
11
+ * and any strategy/prerelease incompatibility warning.
11
12
  *
12
13
  * @internal
13
14
  */
@@ -17,14 +18,21 @@ function buildWalkItems(entries, versionsByPkg) {
17
18
  for (const entry of entries) {
18
19
  const candidates = yield* planEntry(entry, [...versionsByPkg.get(entry.pkg) ?? []]);
19
20
  const driftPeer = yield* detectPeerDrift(entry);
20
- const materializePeer = entry.strategy && !entry.peer ? yield* derivePeerRange(entry.currentRange, entry.strategy) : null;
21
+ let peerWarning = null;
22
+ let materializePeer = null;
23
+ if (entry.strategy && entry.strategy !== "interop") {
24
+ const derived = yield* derivePeerRange(entry.currentRange, entry.strategy);
25
+ peerWarning = derived.warning;
26
+ if (!entry.peer) materializePeer = derived.range;
27
+ }
21
28
  const upToDate = candidates.length === 1 && driftPeer === null && materializePeer === null;
22
29
  items.push({
23
30
  entry,
24
31
  candidates,
25
32
  upToDate,
26
33
  driftPeer,
27
- materializePeer
34
+ materializePeer,
35
+ peerWarning
28
36
  });
29
37
  }
30
38
  return items;
@@ -1,61 +1,116 @@
1
1
  //#region src/cli/walk-reducer.ts
2
- /** Index of the next actionable (not up-to-date) item at or after `from`, or -1. */
3
- function nextActionable(items, from) {
4
- for (let i = from; i < items.length; i++) if (!items[i].upToDate) return i;
5
- return -1;
2
+ const ORDER = {
3
+ keep: 0,
4
+ "in-range": 1,
5
+ latest: 2
6
+ };
7
+ /**
8
+ * The row's options in display order: keep first (always index 0, always the
9
+ * default), then the in-range bump, then the latest-overall bump. `planEntry`
10
+ * emits them in the opposite order, with keep last.
11
+ *
12
+ * @internal
13
+ */
14
+ function displayCandidates(item) {
15
+ return [...item.candidates].sort((a, b) => ORDER[a.kind] - ORDER[b.kind]);
6
16
  }
7
17
  /**
8
- * Initialize the walk on the first actionable item (auto-skipping up-to-date
9
- * items). If none are actionable, the walk is immediately done.
18
+ * Initialize the table: cursor on the first row, keep selected on every row, so
19
+ * the default state applies nothing.
10
20
  *
11
21
  * @internal
12
22
  */
13
- function initWalk(items) {
14
- const index = nextActionable(items, 0);
23
+ function initTable(items) {
15
24
  return {
16
- index: index === -1 ? items.length : index,
17
25
  cursor: 0,
18
- decisions: [],
19
- done: index === -1
26
+ picks: items.map(() => 0),
27
+ done: false,
28
+ cancelled: false
20
29
  };
21
30
  }
31
+ const clamp = (n, max) => n < 0 ? 0 : n > max ? max : n;
22
32
  /**
23
- * Advance the walk by a key. up/down move the cursor within the current item's
24
- * candidates; enter records the highlighted candidate and moves to the next
25
- * actionable item (or completes).
33
+ * Advance the table by a key. up/down move between rows; left/right move the
34
+ * radio selection within the row under the cursor; submit applies; cancel exits
35
+ * without applying. Both axes clamp at their ends rather than wrapping.
26
36
  *
27
37
  * @internal
28
38
  */
29
- function walkStep(state, items, key) {
39
+ function tableStep(state, items, key) {
30
40
  if (state.done) return state;
31
- const item = items[state.index];
32
- const count = item.candidates.length;
33
- if (key === "up") return {
41
+ if (key === "submit") return {
34
42
  ...state,
35
- cursor: (state.cursor - 1 + count) % count
43
+ done: true
36
44
  };
37
- if (key === "down") return {
45
+ if (key === "cancel") return {
38
46
  ...state,
39
- cursor: (state.cursor + 1) % count
47
+ done: true,
48
+ cancelled: true
40
49
  };
41
- const chosen = item.candidates[state.cursor];
42
- const decisions = [...state.decisions, {
43
- item,
44
- chosen
45
- }];
46
- const next = nextActionable(items, state.index + 1);
47
- if (next === -1) return {
50
+ if (items.length === 0) return state;
51
+ if (key === "up") return {
48
52
  ...state,
49
- decisions,
50
- done: true
53
+ cursor: clamp(state.cursor - 1, items.length - 1)
54
+ };
55
+ if (key === "down") return {
56
+ ...state,
57
+ cursor: clamp(state.cursor + 1, items.length - 1)
51
58
  };
59
+ const count = displayCandidates(items[state.cursor]).length;
60
+ const delta = key === "right" ? 1 : -1;
61
+ const picks = [...state.picks];
62
+ picks[state.cursor] = clamp(picks[state.cursor] + delta, count - 1);
52
63
  return {
53
- index: next,
54
- cursor: 0,
55
- decisions,
56
- done: false
64
+ ...state,
65
+ picks
57
66
  };
58
67
  }
68
+ /**
69
+ * Project the table's selections into decisions. A cancelled table yields none,
70
+ * so nothing is written.
71
+ *
72
+ * @internal
73
+ */
74
+ function tableDecisions(state, items) {
75
+ if (state.cancelled) return [];
76
+ return items.map((item, i) => ({
77
+ item,
78
+ chosen: displayCandidates(item)[state.picks[i] ?? 0]
79
+ }));
80
+ }
81
+ /**
82
+ * The peer range that would be written for a row's currently chosen candidate.
83
+ * Non-keep candidates carry their own recomputed `peerRange`; keep reuses
84
+ * whichever peer source the item already resolved (a drift resync, a
85
+ * materialize target, or the entry's existing literal), falling back to an
86
+ * em dash placeholder when none applies.
87
+ *
88
+ * @internal
89
+ */
90
+ function peerFor(item, chosen) {
91
+ if (chosen.kind !== "keep") return chosen.peerRange ?? "—";
92
+ if (item.driftPeer) return item.driftPeer;
93
+ if (item.materializePeer) return item.materializePeer;
94
+ return item.entry.peer?.value ?? "—";
95
+ }
96
+ /**
97
+ * The Ink color for one candidate cell, or null for the terminal's default.
98
+ *
99
+ * Only a SELECTED UPGRADE is colored — green in-range, yellow for a major. A
100
+ * selected KEEP is deliberately left uncolored: it is the current value, not a
101
+ * change, and coloring it dim made the column the eye lands on first read as
102
+ * disabled. Unselected cells are always default.
103
+ *
104
+ * Extracted from the render so it can be unit-tested: `ink-testing-library`
105
+ * strips ANSI from `lastFrame()`, so a color assertion is impossible against
106
+ * the rendered output.
107
+ *
108
+ * @internal
109
+ */
110
+ function cellColor(candidate, selected) {
111
+ if (!selected || candidate.kind === "keep") return null;
112
+ return candidate.isMajor ? "yellow" : "green";
113
+ }
59
114
 
60
115
  //#endregion
61
- export { initWalk, walkStep };
116
+ export { cellColor, displayCandidates, initTable, peerFor, tableDecisions, tableStep };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rolldown-pnpm-config",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "A dogfooding example of our plugin",
6
6
  "repository": {
@@ -47,7 +47,7 @@
47
47
  "effect": "^3.21.4",
48
48
  "ink": "^7.1.0",
49
49
  "ink-tab": "^5.2.0",
50
- "oxc-parser": "^0.139.0",
50
+ "oxc-parser": "^0.140.0",
51
51
  "react": "^19.2.7",
52
52
  "semver-effect": "^0.3.1",
53
53
  "std-env": "^4.2.0",