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 +2 -2
- package/bin/rolldown-pnpm-config.js +1 -1
- package/cli/commands/upgrade.js +148 -75
- package/cli/drift.js +8 -2
- package/cli/edits.js +28 -18
- package/cli/peer-range.js +29 -3
- package/cli/plan.js +7 -5
- package/cli/summary.js +94 -67
- package/cli/ui/Walk.js +53 -27
- package/cli/ui/run-walk.js +6 -4
- package/cli/validate.js +88 -0
- package/cli/walk-plan.js +11 -3
- package/cli/walk-reducer.js +90 -35
- package/package.json +2 -2
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
|
-
#
|
|
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
|
|
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.
|
|
17
|
+
version: "0.3.0"
|
|
18
18
|
})(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain);
|
|
19
19
|
|
|
20
20
|
//#endregion
|
package/cli/commands/upgrade.js
CHANGED
|
@@ -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 [
|
|
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 [
|
|
62
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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 (
|
|
195
|
-
|
|
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
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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,
|
|
247
|
+
function applyInteropAndDecisions(file, source, nonInteropEdits, interopEdits) {
|
|
218
248
|
return Effect.gen(function* () {
|
|
219
|
-
const edits = [...
|
|
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
|
-
|
|
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
|
|
301
|
-
* --yes applies latest-in-range non-interactively; --dry-run
|
|
302
|
-
*
|
|
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
|
-
|
|
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(
|
|
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,
|
|
432
|
-
const
|
|
433
|
-
yield* Effect.sync(() => process.stdout.write(`
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 (
|
|
21
|
-
|
|
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:
|
|
28
|
-
style: "
|
|
40
|
+
text: `── catalog: ${entry.catalog} ──`,
|
|
41
|
+
style: "unchanged"
|
|
29
42
|
}]
|
|
30
43
|
});
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
}
|
|
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 {
|
|
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
|
|
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(() =>
|
|
20
|
+
const [state, setState] = useState(() => initTable(items));
|
|
17
21
|
useEffect(() => {
|
|
18
|
-
if (
|
|
19
|
-
onDone(
|
|
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 ? "
|
|
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 =
|
|
31
|
+
const next = tableStep(state, items, which);
|
|
28
32
|
setState(next);
|
|
29
33
|
if (next.done) {
|
|
30
|
-
onDone(next
|
|
34
|
+
onDone(tableDecisions(next, items));
|
|
31
35
|
app.exit();
|
|
32
36
|
}
|
|
33
37
|
});
|
|
34
|
-
if (state.done ||
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
package/cli/ui/run-walk.js
CHANGED
|
@@ -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
|
|
9
|
-
* decisions once the user
|
|
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
|
}
|
package/cli/validate.js
ADDED
|
@@ -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),
|
|
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
|
-
|
|
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;
|
package/cli/walk-reducer.js
CHANGED
|
@@ -1,61 +1,116 @@
|
|
|
1
1
|
//#region src/cli/walk-reducer.ts
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
9
|
-
*
|
|
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
|
|
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
|
-
|
|
19
|
-
done:
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
39
|
+
function tableStep(state, items, key) {
|
|
30
40
|
if (state.done) return state;
|
|
31
|
-
|
|
32
|
-
const count = item.candidates.length;
|
|
33
|
-
if (key === "up") return {
|
|
41
|
+
if (key === "submit") return {
|
|
34
42
|
...state,
|
|
35
|
-
|
|
43
|
+
done: true
|
|
36
44
|
};
|
|
37
|
-
if (key === "
|
|
45
|
+
if (key === "cancel") return {
|
|
38
46
|
...state,
|
|
39
|
-
|
|
47
|
+
done: true,
|
|
48
|
+
cancelled: true
|
|
40
49
|
};
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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",
|