rolldown-pnpm-config 0.2.2 → 0.4.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
 
@@ -3,8 +3,8 @@ import { exportCommand } from "../cli/commands/export.js";
3
3
  import { previewCommand } from "../cli/commands/preview.js";
4
4
  import { upgradeCommand } from "../cli/commands/upgrade.js";
5
5
  import { Effect } from "effect";
6
- import { Command } from "@effect/cli";
7
- import { NodeContext, NodeRuntime } from "@effect/platform-node";
6
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
7
+ import { Command } from "effect/unstable/cli";
8
8
 
9
9
  //#region src/cli/bin.ts
10
10
  const root = Command.make("rolldown-pnpm-config").pipe(Command.withSubcommands([
@@ -12,10 +12,7 @@ const root = Command.make("rolldown-pnpm-config").pipe(Command.withSubcommands([
12
12
  exportCommand,
13
13
  previewCommand
14
14
  ]));
15
- Command.run(root, {
16
- name: "rolldown-pnpm-config",
17
- version: "0.2.2"
18
- })(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain);
15
+ Command.run(root, { version: "0.4.0" }).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain);
19
16
 
20
17
  //#endregion
21
18
  export { };
@@ -16,7 +16,7 @@ import { overlayWorkspace } from "../workspace-overlay.js";
16
16
  import { Data, Effect, Option } from "effect";
17
17
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
18
  import { dirname, join, relative } from "node:path";
19
- import { Args, Command, Options } from "@effect/cli";
19
+ import { Argument, Command, Flag } from "effect/unstable/cli";
20
20
 
21
21
  //#region src/cli/commands/export.ts
22
22
  /**
@@ -110,9 +110,9 @@ function runExport(opts) {
110
110
  };
111
111
  });
112
112
  }
113
- const pathArg = Args.file({ name: "path" }).pipe(Args.optional);
114
- const dryRunFlag = Options.boolean("dry-run").pipe(Options.withDefault(false));
115
- const fullFlag = Options.boolean("full").pipe(Options.withDefault(false));
113
+ const pathArg = Argument.file("path").pipe(Argument.optional);
114
+ const dryRunFlag = Flag.boolean("dry-run").pipe(Flag.withDefault(false));
115
+ const fullFlag = Flag.boolean("full").pipe(Flag.withDefault(false));
116
116
  /**
117
117
  * The "export" command. Materializes the plugin config into pnpm-workspace.yaml.
118
118
  * An optional path argument overrides the auto-detected workspace file. --dry-run
@@ -11,7 +11,7 @@ import { runPreview } from "../ui/run-preview.js";
11
11
  import { Data, Effect, Option } from "effect";
12
12
  import { existsSync, readFileSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
- import { Args, Command } from "@effect/cli";
14
+ import { Argument, Command } from "effect/unstable/cli";
15
15
 
16
16
  //#region src/cli/commands/preview.ts
17
17
  /** Typed failure for the preview run. @internal */
@@ -48,7 +48,7 @@ function runPreviewViews(opts) {
48
48
  });
49
49
  });
50
50
  }
51
- const pathArg = Args.file({ name: "path" }).pipe(Args.optional);
51
+ const pathArg = Argument.file("path").pipe(Argument.optional);
52
52
  /**
53
53
  * The "preview" command: interactive ink-tab explorer of the export diff
54
54
  * (Changes / Full / Simulated). Falls back to printing the Changes view when
@@ -12,11 +12,12 @@ 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
- import { Data, Effect, Option } from "effect";
17
+ import { Data, Effect, Option, Result } from "effect";
17
18
  import { readFileSync, writeFileSync } from "node:fs";
18
- import { Args, Command, Options } from "@effect/cli";
19
- import { NodeContext } from "@effect/platform-node";
19
+ import { NodeServices } from "@effect/platform-node";
20
+ import { Argument, Command, Flag } from "effect/unstable/cli";
20
21
 
21
22
  //#region src/cli/commands/upgrade.ts
22
23
  /**
@@ -28,9 +29,9 @@ var UpgradeError = class extends Data.TaggedError("UpgradeError") {};
28
29
  /** Combine the config-declared and pnpm-resolved release-age gates (strictest of both). @internal */
29
30
  function computeGate(source, file, resolver) {
30
31
  return Effect.gen(function* () {
31
- const { config } = yield* Effect.try(() => evaluatePluginConfig(source, file)).pipe(Effect.catchAll(() => Effect.succeed({ config: null })));
32
+ const { config } = yield* Effect.try(() => evaluatePluginConfig(source, file)).pipe(Effect.catch(() => Effect.succeed({ config: null })));
32
33
  const cfg = readConfigReleaseAge(config);
33
- const [age, exc] = yield* Effect.all([resolver.pnpmConfig("minimumReleaseAge").pipe(Effect.catchAll(() => Effect.succeed(null))), resolver.pnpmConfig("minimumReleaseAgeExclude").pipe(Effect.catchAll(() => Effect.succeed(null)))], { concurrency: "unbounded" });
34
+ const [age, exc] = yield* Effect.all([resolver.pnpmConfig("minimumReleaseAge").pipe(Effect.catch(() => Effect.succeed(null))), resolver.pnpmConfig("minimumReleaseAgeExclude").pipe(Effect.catch(() => Effect.succeed(null)))], { concurrency: "unbounded" });
34
35
  return combineReleaseAge(cfg, parsePnpmGate(age, exc));
35
36
  });
36
37
  }
@@ -51,15 +52,27 @@ function resolveGatedVersions(entries, resolver, gate, now, onProgress) {
51
52
  let resolved = 0;
52
53
  onProgress?.(0, total);
53
54
  return Effect.forEach(uniquePkgs, (pkg) => Effect.gen(function* () {
54
- const vr = yield* resolver.versions(pkg).pipe(Effect.either);
55
- if (vr._tag === "Left") {
55
+ const vr = yield* resolver.versions(pkg).pipe(Effect.result);
56
+ if (Result.isFailure(vr)) {
56
57
  onProgress?.(++resolved, total);
57
- return [pkg, []];
58
+ return [
59
+ pkg,
60
+ [],
61
+ []
62
+ ];
58
63
  }
59
- const times = gate.ageMinutes > 0 ? yield* resolver.times(pkg).pipe(Effect.catchAll(() => Effect.succeed({}))) : {};
64
+ const times = gate.ageMinutes > 0 ? yield* resolver.times(pkg).pipe(Effect.catch(() => 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.success, times, gate, pkg, now),
69
+ vr.success
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,69 +118,69 @@ 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.catch(() => 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
- const expected = yield* detectPeerDrift(entry).pipe(Effect.catchAll(() => Effect.succeed(null)));
156
+ const expected = yield* detectPeerDrift(entry).pipe(Effect.catch(() => 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;
132
169
  }
133
- const inRange = (yield* planEntry(entry, versions).pipe(Effect.catchAll(() => Effect.succeed([])))).find((c) => c.kind === "in-range");
170
+ const inRange = (yield* planEntry(entry, versions).pipe(Effect.catch(() => 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
- const expected = yield* detectPeerDrift(entry).pipe(Effect.catchAll(() => Effect.succeed(null)));
181
+ const expected = yield* detectPeerDrift(entry).pipe(Effect.catch(() => 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,8 +196,8 @@ 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) ?? [];
181
- const inRange = (yield* planEntry(e, versions).pipe(Effect.catchAll(() => Effect.succeed([])))).find((c) => c.kind === "in-range");
199
+ const versions = versionsByPkg.gated.get(e.pkg) ?? [];
200
+ const inRange = (yield* planEntry(e, versions).pipe(Effect.catch(() => Effect.succeed([])))).find((c) => c.kind === "in-range");
182
201
  const ceiling = inRange ? inRange.version : e.currentRange.replace(/^[\^~]/, "");
183
202
  members.push({
184
203
  pkg: e.pkg,
@@ -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.catch((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
  /**
@@ -287,19 +352,17 @@ function resolveTargetFile(fileOpt) {
287
352
  return picked.file;
288
353
  });
289
354
  }
290
- const fileArg = Args.file({
291
- name: "file",
292
- exists: "yes"
293
- }).pipe(Args.optional);
294
- const yesFlag = Options.boolean("yes").pipe(Options.withAlias("y"), Options.withDefault(false));
295
- const dryRunFlag = Options.boolean("dry-run").pipe(Options.withDefault(false));
296
- const catalogOption = Options.text("catalog").pipe(Options.optional);
297
- const previewFlag = Options.boolean("preview").pipe(Options.withDefault(false));
298
- const fullFlag = Options.boolean("full").pipe(Options.withDefault(false));
355
+ const fileArg = Argument.file("file", { mustExist: true }).pipe(Argument.optional);
356
+ const yesFlag = Flag.boolean("yes").pipe(Flag.withAlias("y"), Flag.withDefault(false));
357
+ const dryRunFlag = Flag.boolean("dry-run").pipe(Flag.withDefault(false));
358
+ const catalogOption = Flag.string("catalog").pipe(Flag.optional);
359
+ const previewFlag = Flag.boolean("preview").pipe(Flag.withDefault(false));
360
+ const fullFlag = Flag.boolean("full").pipe(Flag.withDefault(false));
299
361
  /**
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.
362
+ * The "upgrade" command. The default path runs the interactive table;
363
+ * --yes applies latest-in-range non-interactively; --dry-run runs the identical
364
+ * interactive flow and reports what it would have written, but writes nothing;
365
+ * --catalog restricts to a single catalog by name.
303
366
  *
304
367
  * @internal
305
368
  */
@@ -328,9 +391,10 @@ const upgradeCommand = Command.make("upgrade", {
328
391
  const result = yield* runUpgrade({
329
392
  file,
330
393
  resolver,
394
+ dryRun,
331
395
  ...caps.interactive ? { onProgress: writeResolveProgress } : {}
332
396
  });
333
- yield* Effect.sync(() => process.stdout.write(`Updated ${result.updated} package(s); skipped ${result.skipped.length}.\n`));
397
+ 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
398
  if (result.conflicts.length > 0) {
335
399
  const lines = result.conflicts.map((c) => ` ${c.pkg} (kept ${c.ceiling}) blocked by ${c.blockedBy}`).join("\n");
336
400
  yield* Effect.sync(() => process.stdout.write(`Interop conflicts (left at your pick):\n${lines}\n`));
@@ -348,18 +412,21 @@ const upgradeCommand = Command.make("upgrade", {
348
412
  const catalogName = Option.getOrUndefined(catalog);
349
413
  const entries = filterEntriesByCatalog(discovered.entries, catalogName);
350
414
  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
- }
415
+ const items = yield* buildWalkItems(entries, versions.gated).pipe(Effect.catch((e) => Effect.fail(new UpgradeError({ message: e.message }))));
357
416
  if (!caps.interactive) {
358
417
  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`));
418
+ const note = dryRun ? "(dry run — nothing written)" : "(non-interactive terminal — run with --yes to apply, or in a TTY to choose)";
419
+ const warn = versions.unresolved.length > 0 ? `\n⚠ ${unresolvedMessage(versions.unresolved)}\n` : "";
420
+ yield* Effect.sync(() => process.stdout.write(`${text}${warn}\n\n${note}\n`));
421
+ return;
422
+ }
423
+ const actionable = actionableWalkItems(items, full);
424
+ if (actionable.length === 0) {
425
+ const warn = versions.unresolved.length > 0 ? `⚠ ${unresolvedMessage(versions.unresolved)}\n\n` : "";
426
+ yield* Effect.sync(() => process.stdout.write(`${warn}${nothingToUpgradeMessage(items.length)}`));
360
427
  return;
361
428
  }
362
- const decisions = yield* runWalk(items);
429
+ const decisions = yield* runWalk(actionable, dryRun, versions.unresolved);
363
430
  const interopByCatalog = /* @__PURE__ */ new Map();
364
431
  for (const e of entries) {
365
432
  if (e.strategy !== "interop") continue;
@@ -382,7 +449,7 @@ const upgradeCommand = Command.make("upgrade", {
382
449
  let members = group.map((e) => ({
383
450
  pkg: e.pkg,
384
451
  ceiling: pickOf(e.pkg),
385
- candidates: versions.get(e.pkg) ?? []
452
+ candidates: versions.gated.get(e.pkg) ?? []
386
453
  }));
387
454
  const originalPick = new Map(members.map((m) => [m.pkg, m.ceiling]));
388
455
  const peerCache = /* @__PURE__ */ new Map();
@@ -393,10 +460,10 @@ const upgradeCommand = Command.make("upgrade", {
393
460
  const capEntries = group.filter((e) => reentry.some((rc) => rc.pkg === e.pkg));
394
461
  const cappedVersions = /* @__PURE__ */ new Map();
395
462
  for (const rc of reentry) {
396
- const all = versions.get(rc.pkg) ?? [];
463
+ const all = versions.gated.get(rc.pkg) ?? [];
397
464
  cappedVersions.set(rc.pkg, rc.cap === null ? all : yield* capVersions(all, rc.cap));
398
465
  }
399
- const reDecisions = yield* runWalk(yield* buildWalkItems(capEntries, cappedVersions).pipe(Effect.catchAll((err) => Effect.fail(new UpgradeError({ message: err.message })))));
466
+ const reDecisions = yield* runWalk(yield* buildWalkItems(capEntries, cappedVersions).pipe(Effect.catch((err) => Effect.fail(new UpgradeError({ message: err.message })))));
400
467
  const before = new Map(members.map((m) => [m.pkg, m.ceiling]));
401
468
  members = members.map((m) => {
402
469
  const rd = reDecisions.find((d) => d.item.entry.pkg === m.pkg);
@@ -424,14 +491,17 @@ const upgradeCommand = Command.make("upgrade", {
424
491
  });
425
492
  }
426
493
  }
494
+ const { accepted, rejected } = yield* validateEdits(buildEdits(nonInteropDecisions), versions.raw);
495
+ const acceptedPkgs = new Set(accepted.map((e) => e.pkg));
427
496
  yield* Effect.sync(() => process.stdout.write(`${renderSummary(decisions, {
428
497
  adjustments,
429
498
  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`));
434
- }).pipe(Effect.provide(RegistryResolverLive), Effect.provide(NodeContext.layer))).pipe(Command.withDescription("Upgrade catalog versions in a config file"));
499
+ }, { color: caps.color }, rejected)}\n`));
500
+ if (!dryRun) yield* applyInteropAndDecisions(file, source, accepted, interopEdits);
501
+ const changed = countChangedDecisions(nonInteropDecisions.filter((d) => acceptedPkgs.has(d.item.entry.pkg))) + interopChanged;
502
+ yield* Effect.sync(() => process.stdout.write(dryRun ? `Dry run — no changes written. ${changed} change(s) would be applied.\n` : `Applied ${changed} change(s).\n`));
503
+ if (versions.unresolved.length > 0) yield* Effect.sync(() => process.stdout.write(`\n⚠ ${unresolvedMessage(versions.unresolved)}\n`));
504
+ }).pipe(Effect.provide(RegistryResolverLive), Effect.provide(NodeServices.layer))).pipe(Command.withDescription("Upgrade catalog versions in a config file"));
435
505
 
436
506
  //#endregion
437
- export { UpgradeError, applyInteropAndDecisions, computeGate, projectDecisions, resolveGatedVersions, resolveTargetFile, runUpgrade, runUpgradePreview, upgradeCommand, writeResolveProgress };
507
+ 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
  }