rolldown-pnpm-config 0.1.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/bin/rolldown-pnpm-config.js +21 -0
  4. package/catalogs.js +27 -0
  5. package/cli/commands/export.js +118 -0
  6. package/cli/commands/preview.js +73 -0
  7. package/cli/commands/upgrade.js +437 -0
  8. package/cli/diff/build.js +122 -0
  9. package/cli/diff/render.js +103 -0
  10. package/cli/discover.js +128 -0
  11. package/cli/drift.js +22 -0
  12. package/cli/edits.js +41 -0
  13. package/cli/effective.js +42 -0
  14. package/cli/evaluate.js +91 -0
  15. package/cli/interop.js +285 -0
  16. package/cli/local-merge.js +75 -0
  17. package/cli/peer-range.js +34 -0
  18. package/cli/plan.js +66 -0
  19. package/cli/preview-views.js +34 -0
  20. package/cli/release-age.js +74 -0
  21. package/cli/resolve.js +109 -0
  22. package/cli/rewrite.js +22 -0
  23. package/cli/select-file.js +64 -0
  24. package/cli/summary.js +137 -0
  25. package/cli/ui/Preview.js +60 -0
  26. package/cli/ui/Walk.js +55 -0
  27. package/cli/ui/ansi.js +20 -0
  28. package/cli/ui/env.js +20 -0
  29. package/cli/ui/run-preview.js +23 -0
  30. package/cli/ui/run-walk.js +29 -0
  31. package/cli/ui/styled.js +27 -0
  32. package/cli/walk-plan.js +35 -0
  33. package/cli/walk-reducer.js +61 -0
  34. package/cli/workspace-file.js +58 -0
  35. package/cli/workspace-overlay.js +21 -0
  36. package/descriptors/build.js +248 -0
  37. package/descriptors/hoisting.js +175 -0
  38. package/descriptors/index.js +38 -0
  39. package/descriptors/lockfile.js +117 -0
  40. package/descriptors/misc.js +144 -0
  41. package/descriptors/network.js +108 -0
  42. package/descriptors/resolution.js +250 -0
  43. package/descriptors/runtime-cfg.js +90 -0
  44. package/descriptors/schemas.js +26 -0
  45. package/descriptors/workspace.js +116 -0
  46. package/index.d.ts +363 -0
  47. package/index.js +3 -0
  48. package/package.json +60 -0
  49. package/plugin/freeze.js +79 -0
  50. package/plugin/index.js +48 -0
  51. package/plugin/serialize.js +26 -0
  52. package/registry.js +8 -0
  53. package/runtime/ctx.js +39 -0
  54. package/runtime/enforcement.js +36 -0
  55. package/runtime/strategies/arrays.js +37 -0
  56. package/runtime/strategies/catalogs.js +36 -0
  57. package/runtime/strategies/maps.js +46 -0
  58. package/runtime/strategies/overrides.js +57 -0
  59. package/runtime/strategies/scalar.js +57 -0
  60. package/runtime/strategies/table.js +27 -0
  61. package/runtime/warnings.js +61 -0
  62. package/runtime.d.ts +111 -0
  63. package/runtime.js +54 -0
  64. package/tsdoc-metadata.json +11 -0
  65. package/virtual.d.ts +18 -0
@@ -0,0 +1,34 @@
1
+ import { Data, Effect } from "effect";
2
+ import { SemVer } from "semver-effect";
3
+
4
+ //#region src/cli/peer-range.ts
5
+ /**
6
+ * Typed failure raised when a peer range cannot be derived from a range string.
7
+ *
8
+ * @internal
9
+ */
10
+ var PeerRangeError = class extends Data.TaggedError("PeerRangeError") {};
11
+ /** Splits a simple range into its operator prefix and version (e.g. `^6.5.1`). */
12
+ const PREFIX_RE = /^(\^|~|)(\d.*)$/;
13
+ /**
14
+ * Recompute a materialized peer range from a package range and a strategy.
15
+ * "lock" pins to the exact version; "lock-minor" floors the patch to .0.
16
+ * The operator (^/~/exact) is preserved.
17
+ *
18
+ * Note: expects a release (non-prerelease) version string; a prerelease tag
19
+ * would be dropped by the major.minor.patch reconstruction.
20
+ *
21
+ * @internal
22
+ */
23
+ function derivePeerRange(range, strategy) {
24
+ return Effect.gen(function* () {
25
+ const match = PREFIX_RE.exec(range);
26
+ if (!match) return yield* Effect.fail(new PeerRangeError({ message: `Cannot derive peer range from "${range}"` }));
27
+ const [, prefix, version] = match;
28
+ 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`;
30
+ });
31
+ }
32
+
33
+ //#endregion
34
+ export { PeerRangeError, derivePeerRange };
package/cli/plan.js ADDED
@@ -0,0 +1,66 @@
1
+ import { derivePeerRange } from "./peer-range.js";
2
+ import { Effect } from "effect";
3
+ import { Range, SemVer } from "semver-effect";
4
+
5
+ //#region src/cli/plan.ts
6
+ /** Parse a version, returning null instead of failing (filters junk tags). */
7
+ const parseOrNull = (v) => SemVer.parse(v).pipe(Effect.catchAll(() => Effect.succeed(null)));
8
+ /**
9
+ * Compute the candidate versions for one catalog entry against the list of
10
+ * published versions. Order: latest in-range (when newer than current), latest
11
+ * overall stable (when newer than the in-range pick), then keep. Prereleases
12
+ * are excluded. When the entry carries a strategy, each non-keep candidate gets
13
+ * a recomputed `peerRange`.
14
+ *
15
+ * @internal
16
+ */
17
+ function planEntry(entry, versions) {
18
+ return Effect.gen(function* () {
19
+ const range = yield* Range.parse(entry.currentRange).pipe(Effect.catchAll(() => Effect.succeed(null)));
20
+ const parsed = [];
21
+ for (const v of versions) {
22
+ const sv = yield* parseOrNull(v);
23
+ if (sv?.isStable) parsed.push(sv);
24
+ }
25
+ parsed.sort((a, b) => a.compare(b));
26
+ const maxOf = (list) => list.length ? list[list.length - 1] : null;
27
+ const current = yield* parseOrNull(entry.currentRange.replace(/^[\^~]/, ""));
28
+ const currentMajor = current?.major ?? 0;
29
+ const inRangeMax = range ? maxOf(parsed.filter((v) => range.test(v))) : null;
30
+ const overallMax = maxOf(parsed);
31
+ const withPeer = (version) => entry.strategy && entry.strategy !== "interop" ? derivePeerRange(`${entry.operator}${version}`, entry.strategy) : Effect.succeed(void 0);
32
+ const candidates = [];
33
+ if (inRangeMax && (current === null || inRangeMax.gt(current))) {
34
+ const version = inRangeMax.toString();
35
+ const peerRange = yield* withPeer(version);
36
+ candidates.push({
37
+ kind: "in-range",
38
+ range: `${entry.operator}${version}`,
39
+ version,
40
+ isMajor: inRangeMax.major > currentMajor,
41
+ ...peerRange ? { peerRange } : {}
42
+ });
43
+ }
44
+ if (overallMax && (current === null || overallMax.gt(current)) && (!inRangeMax || overallMax.gt(inRangeMax))) {
45
+ const version = overallMax.toString();
46
+ const peerRange = yield* withPeer(version);
47
+ candidates.push({
48
+ kind: "latest",
49
+ range: `${entry.operator}${version}`,
50
+ version,
51
+ isMajor: overallMax.major > currentMajor,
52
+ ...peerRange ? { peerRange } : {}
53
+ });
54
+ }
55
+ candidates.push({
56
+ kind: "keep",
57
+ range: entry.currentRange,
58
+ version: entry.currentRange.replace(/^[\^~]/, ""),
59
+ isMajor: false
60
+ });
61
+ return candidates;
62
+ });
63
+ }
64
+
65
+ //#endregion
66
+ export { planEntry };
@@ -0,0 +1,34 @@
1
+ import { buildDiff } from "./diff/build.js";
2
+ import { renderExportDiff } from "./diff/render.js";
3
+ import { effectiveManaged, vanillaManaged } from "./effective.js";
4
+ import { canonicalize } from "./workspace-file.js";
5
+ import { overlayWorkspace } from "./workspace-overlay.js";
6
+ import { WORKSPACE_FIELDS } from "./commands/export.js";
7
+
8
+ //#region src/cli/preview-views.ts
9
+ /**
10
+ * Build the three preview views as styled lines: Changes (parsed→merged, with
11
+ * local + preserve + excludeByRepo), Full (same tree, full verbosity), and
12
+ * Simulated (parsed→vanilla fresh-consumer output, no local/overlay).
13
+ *
14
+ * @internal
15
+ */
16
+ function buildPreviewViews(input) {
17
+ const meta = {
18
+ localKeys: new Set(input.local ? Object.keys(input.local) : []),
19
+ managedKeys: WORKSPACE_FIELDS
20
+ };
21
+ const merged = overlayWorkspace(effectiveManaged(input.managed, input.local, input.parsed, input.manifest, input.rootName), input.parsed);
22
+ const vanilla = vanillaManaged(input.managed, input.manifest, input.rootName);
23
+ const before = canonicalize(input.parsed);
24
+ const changesTree = buildDiff(before, canonicalize(merged), meta);
25
+ const simulatedTree = buildDiff(before, canonicalize(vanilla), meta);
26
+ return {
27
+ changes: renderExportDiff(changesTree, { full: false }),
28
+ full: renderExportDiff(changesTree, { full: true }),
29
+ simulated: renderExportDiff(simulatedTree, { full: false })
30
+ };
31
+ }
32
+
33
+ //#endregion
34
+ export { buildPreviewViews };
@@ -0,0 +1,74 @@
1
+ //#region src/cli/release-age.ts
2
+ /** Combine two sources: strictest age (max), widest exempt set (union). @internal */
3
+ function combineReleaseAge(a, b) {
4
+ const ages = [a?.ageMinutes, b?.ageMinutes].filter((n) => typeof n === "number");
5
+ return {
6
+ ageMinutes: ages.length ? Math.max(0, ...ages) : 0,
7
+ exclude: [.../* @__PURE__ */ new Set([...a?.exclude ?? [], ...b?.exclude ?? []])]
8
+ };
9
+ }
10
+ /** Match a package name against exact names or `*`-globs (e.g. `@effect/*`). @internal */
11
+ function matchesExclude(pkg, patterns) {
12
+ for (const pat of patterns) {
13
+ if (pat === pkg) return true;
14
+ if (pat.includes("*")) {
15
+ if (new RegExp(`^${pat.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`).test(pkg)) return true;
16
+ }
17
+ }
18
+ return false;
19
+ }
20
+ /**
21
+ * Drop versions younger than the gate's cutoff (and versions with no publish
22
+ * timestamp). No-op when the age is 0 or the package is exempt.
23
+ *
24
+ * @internal
25
+ */
26
+ function filterByReleaseAge(versions, times, gate, pkg, now) {
27
+ if (gate.ageMinutes <= 0 || matchesExclude(pkg, gate.exclude)) return [...versions];
28
+ const cutoff = now - gate.ageMinutes * 6e4;
29
+ return versions.filter((v) => {
30
+ const t = times[v];
31
+ if (t === void 0) return false;
32
+ const published = Date.parse(t);
33
+ return Number.isFinite(published) && published <= cutoff;
34
+ });
35
+ }
36
+ /** Unwrap a managed field that may be a bare value or a `{ value, enforcement }` FieldInput. */
37
+ function fieldValue(raw) {
38
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) return raw.value;
39
+ return raw;
40
+ }
41
+ /** Read the release-age gate declared in a statically-evaluated PnpmConfigPlugin config. @internal */
42
+ function readConfigReleaseAge(config) {
43
+ if (!config) return null;
44
+ const age = fieldValue(config.minimumReleaseAge);
45
+ const exc = fieldValue(config.minimumReleaseAgeExclude);
46
+ const out = {};
47
+ if (typeof age === "number" && Number.isFinite(age)) out.ageMinutes = age;
48
+ if (Array.isArray(exc)) out.exclude = exc.filter((x) => typeof x === "string");
49
+ return out.ageMinutes === void 0 && out.exclude === void 0 ? null : out;
50
+ }
51
+ /** Parse `pnpm config get minimumReleaseAge[Exclude]` stdout into a PartialGate. @internal */
52
+ function parsePnpmGate(age, exclude) {
53
+ const out = {};
54
+ const trimmedAge = age?.trim();
55
+ if (trimmedAge && trimmedAge !== "undefined") {
56
+ const n = Number.parseInt(trimmedAge, 10);
57
+ if (Number.isFinite(n)) out.ageMinutes = n;
58
+ }
59
+ const trimmedExc = exclude?.trim();
60
+ if (trimmedExc && trimmedExc !== "undefined") {
61
+ let list = [];
62
+ try {
63
+ const json = JSON.parse(trimmedExc);
64
+ list = Array.isArray(json) ? json.map(String) : [String(json)];
65
+ } catch {
66
+ list = trimmedExc.split(/[\s,]+/).filter(Boolean);
67
+ }
68
+ if (list.length) out.exclude = list;
69
+ }
70
+ return out.ageMinutes === void 0 && out.exclude === void 0 ? null : out;
71
+ }
72
+
73
+ //#endregion
74
+ export { combineReleaseAge, filterByReleaseAge, matchesExclude, parsePnpmGate, readConfigReleaseAge };
package/cli/resolve.js ADDED
@@ -0,0 +1,109 @@
1
+ import { Context, Data, Effect, Layer } from "effect";
2
+ import { Command, CommandExecutor } from "@effect/platform";
3
+
4
+ //#region src/cli/resolve.ts
5
+ /**
6
+ * Typed failure raised when a package's versions cannot be resolved.
7
+ *
8
+ * @internal
9
+ */
10
+ var ResolveError = class extends Data.TaggedError("ResolveError") {};
11
+ /**
12
+ * Resolves the published versions of a package from the registry. The Live
13
+ * implementation shells out to `pnpm view`, reusing the user's .npmrc, scoped
14
+ * registries, and auth tokens.
15
+ *
16
+ * @internal
17
+ */
18
+ var RegistryResolver = class extends Context.Tag("RegistryResolver")() {};
19
+ /** Parse `pnpm view ... versions --json` stdout: a JSON array, or a single JSON string. */
20
+ function parseVersions(pkg, stdout) {
21
+ return Effect.try({
22
+ try: () => {
23
+ const json = JSON.parse(stdout);
24
+ if (Array.isArray(json)) return json.map(String);
25
+ if (typeof json === "string") return [json];
26
+ throw new Error("unexpected shape");
27
+ },
28
+ catch: () => new ResolveError({
29
+ pkg,
30
+ message: `Could not parse versions for ${pkg}`
31
+ })
32
+ });
33
+ }
34
+ /** Parse `pnpm view <pkg> time --json` stdout: an object of version → ISO date. @internal */
35
+ function parseTimes(pkg, stdout) {
36
+ return Effect.try({
37
+ try: () => {
38
+ const json = JSON.parse(stdout);
39
+ if (json && typeof json === "object" && !Array.isArray(json)) {
40
+ const out = {};
41
+ for (const [k, v] of Object.entries(json)) out[k] = String(v);
42
+ return out;
43
+ }
44
+ throw new Error("unexpected shape");
45
+ },
46
+ catch: () => new ResolveError({
47
+ pkg,
48
+ message: `Could not parse times for ${pkg}`
49
+ })
50
+ });
51
+ }
52
+ /** Parse `pnpm view <pkg>@<version> peerDependencies --json`; empty stdout → {}. @internal */
53
+ function parsePeerDeps(pkg, stdout) {
54
+ const trimmed = stdout.trim();
55
+ if (trimmed === "") return Effect.succeed({});
56
+ return Effect.try({
57
+ try: () => {
58
+ const json = JSON.parse(trimmed);
59
+ if (json && typeof json === "object" && !Array.isArray(json)) {
60
+ const out = {};
61
+ for (const [k, v] of Object.entries(json)) out[k] = String(v);
62
+ return out;
63
+ }
64
+ throw new Error("unexpected shape");
65
+ },
66
+ catch: () => new ResolveError({
67
+ pkg,
68
+ message: `Could not parse peerDependencies for ${pkg}`
69
+ })
70
+ });
71
+ }
72
+ /**
73
+ * Live RegistryResolver backed by `pnpm view <pkg> versions --json`.
74
+ *
75
+ * @internal
76
+ */
77
+ const RegistryResolverLive = Layer.effect(RegistryResolver, Effect.gen(function* () {
78
+ const executor = yield* CommandExecutor.CommandExecutor;
79
+ return {
80
+ versions: (pkg) => Effect.gen(function* () {
81
+ const cmd = Command.make("pnpm", "view", pkg, "versions", "--json");
82
+ return yield* parseVersions(pkg, yield* executor.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
83
+ pkg,
84
+ message: String(e)
85
+ }))));
86
+ }),
87
+ times: (pkg) => Effect.gen(function* () {
88
+ const cmd = Command.make("pnpm", "view", pkg, "time", "--json");
89
+ return yield* parseTimes(pkg, yield* executor.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
90
+ pkg,
91
+ message: String(e)
92
+ }))));
93
+ }),
94
+ peerDependencies: (pkg, version) => Effect.gen(function* () {
95
+ const cmd = Command.make("pnpm", "view", `${pkg}@${version}`, "peerDependencies", "--json");
96
+ return yield* parsePeerDeps(pkg, yield* executor.string(cmd).pipe(Effect.mapError((e) => new ResolveError({
97
+ pkg,
98
+ message: String(e)
99
+ }))));
100
+ }),
101
+ pnpmConfig: (key) => Effect.gen(function* () {
102
+ const cmd = Command.make("pnpm", "config", "get", key);
103
+ return yield* executor.string(cmd).pipe(Effect.map((s) => s.trim()), Effect.catchAll(() => Effect.succeed(null)));
104
+ })
105
+ };
106
+ }));
107
+
108
+ //#endregion
109
+ export { RegistryResolver, RegistryResolverLive, ResolveError, parsePeerDeps, parseTimes, parseVersions };
package/cli/rewrite.js ADDED
@@ -0,0 +1,22 @@
1
+ //#region src/cli/rewrite.ts
2
+ /**
3
+ * Apply span replacements to a source string. Edits are applied in descending
4
+ * start order so each edit's offsets remain valid as later text shifts. Throws
5
+ * a RangeError if any two edits overlap.
6
+ *
7
+ * @internal
8
+ */
9
+ function applyEdits(source, edits) {
10
+ const sorted = [...edits].sort((a, b) => b.span[0] - a.span[0]);
11
+ for (let i = 1; i < sorted.length; i++) {
12
+ const prev = sorted[i - 1];
13
+ const cur = sorted[i];
14
+ if (cur.span[1] > prev.span[0]) throw new RangeError(`Overlapping edits at [${cur.span[0]}, ${cur.span[1]}) and [${prev.span[0]}, ${prev.span[1]})`);
15
+ }
16
+ let out = source;
17
+ for (const edit of sorted) out = out.slice(0, edit.span[0]) + edit.text + out.slice(edit.span[1]);
18
+ return out;
19
+ }
20
+
21
+ //#endregion
22
+ export { applyEdits };
@@ -0,0 +1,64 @@
1
+ import { discoverCatalogEntries } from "./discover.js";
2
+ import { Effect } from "effect";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ //#region src/cli/select-file.ts
7
+ /**
8
+ * Scan a directory for top-level `.ts` files whose source contains a usable
9
+ * `PnpmConfigPlugin(...)` catalog (discovery yields at least one entry). Read
10
+ * or parse failures are skipped silently. Returns absolute-or-joined paths.
11
+ *
12
+ * @internal
13
+ */
14
+ function findConfigFiles(dir) {
15
+ return Effect.sync(() => {
16
+ let names;
17
+ try {
18
+ names = readdirSync(dir);
19
+ } catch {
20
+ return [];
21
+ }
22
+ const matches = [];
23
+ for (const name of names) {
24
+ if (!name.endsWith(".ts") || name.endsWith(".d.ts")) continue;
25
+ const path = join(dir, name);
26
+ try {
27
+ const { entries } = discoverCatalogEntries(readFileSync(path, "utf8"), path);
28
+ if (entries.length > 0) matches.push(path);
29
+ } catch {}
30
+ }
31
+ return matches;
32
+ });
33
+ }
34
+ /**
35
+ * Choose the config file to operate on from a set of candidate paths. Exactly
36
+ * one is required; zero or many is an error the caller surfaces.
37
+ *
38
+ * @internal
39
+ */
40
+ function pickConfigCandidate(matches) {
41
+ if (matches.length === 1) return {
42
+ ok: true,
43
+ file: matches[0]
44
+ };
45
+ if (matches.length === 0) return {
46
+ ok: false,
47
+ message: "No config file found. Pass a file path explicitly."
48
+ };
49
+ return {
50
+ ok: false,
51
+ message: `Multiple config files found; pass one explicitly: ${matches.join(", ")}`
52
+ };
53
+ }
54
+ /**
55
+ * Restrict entries to a single catalog by name, or return all when undefined.
56
+ *
57
+ * @internal
58
+ */
59
+ function filterEntriesByCatalog(entries, catalog) {
60
+ return catalog === void 0 ? [...entries] : entries.filter((e) => e.catalog === catalog);
61
+ }
62
+
63
+ //#endregion
64
+ export { filterEntriesByCatalog, findConfigFiles, pickConfigCandidate };
package/cli/summary.js ADDED
@@ -0,0 +1,137 @@
1
+ import { toAnsi } from "./ui/ansi.js";
2
+
3
+ //#region src/cli/summary.ts
4
+ /**
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`.
8
+ *
9
+ * @internal
10
+ */
11
+ function summaryLines(decisions, interop) {
12
+ const lines = [];
13
+ let toUpdate = 0;
14
+ let major = 0;
15
+ let resync = 0;
16
+ let materialize = 0;
17
+ let upToDate = 0;
18
+ for (const { item, chosen } of decisions) {
19
+ const { entry } = item;
20
+ if (chosen.kind !== "keep") {
21
+ toUpdate++;
22
+ if (chosen.isMajor) major++;
23
+ lines.push({
24
+ indent: 0,
25
+ gutter: "~",
26
+ segments: [{
27
+ text: `${entry.catalog} › ${entry.pkg} ${entry.currentRange} → ${chosen.range}`,
28
+ style: "changed"
29
+ }]
30
+ });
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
+ }]
85
+ });
86
+ } else upToDate++;
87
+ }
88
+ lines.push({
89
+ indent: 0,
90
+ gutter: " ",
91
+ segments: [{
92
+ text: `${toUpdate} to update · ${major} major · ${resync} resync · ${materialize} new peer · ${upToDate} up to date`,
93
+ style: "unchanged"
94
+ }]
95
+ });
96
+ if (interop) {
97
+ for (const a of interop.adjustments) {
98
+ lines.push({
99
+ indent: 0,
100
+ gutter: "~",
101
+ segments: [{
102
+ text: `↓ ${a.pkg} ${a.from} → ${a.to}`,
103
+ style: "changed"
104
+ }]
105
+ });
106
+ lines.push({
107
+ indent: 1,
108
+ gutter: "~",
109
+ segments: [{
110
+ text: `↳ peer → ${a.peer}`,
111
+ style: "changed"
112
+ }]
113
+ });
114
+ }
115
+ for (const c of interop.conflicts) lines.push({
116
+ indent: 0,
117
+ gutter: "⚠",
118
+ segments: [{
119
+ text: `${c.pkg} (kept ${c.ceiling}) blocked by ${c.blockedBy}`,
120
+ style: "warn"
121
+ }]
122
+ });
123
+ }
124
+ return lines;
125
+ }
126
+ /**
127
+ * Render the summary to a string. Color defaults off so non-TTY/test callers
128
+ * get clean text; the upgrade command passes the detected color flag.
129
+ *
130
+ * @internal
131
+ */
132
+ function renderSummary(decisions, interop, opts) {
133
+ return toAnsi(summaryLines(decisions, interop), { color: opts?.color ?? false });
134
+ }
135
+
136
+ //#endregion
137
+ export { renderSummary, summaryLines };
@@ -0,0 +1,60 @@
1
+ import { Box, Text, useApp, useInput } from "ink";
2
+ import { createElement, useState } from "react";
3
+ import { Tab, Tabs } from "ink-tab";
4
+
5
+ //#region src/cli/ui/Preview.ts
6
+ const INK_COLOR = {
7
+ added: "green",
8
+ removed: "red",
9
+ changed: "yellow",
10
+ warn: "red",
11
+ local: "magenta",
12
+ unmanaged: "gray",
13
+ unchanged: "gray",
14
+ plain: void 0
15
+ };
16
+ const TabC = Tab;
17
+ const TabsC = Tabs;
18
+ function renderLines(lines) {
19
+ return createElement(Box, { flexDirection: "column" }, ...lines.map((l, i) => {
20
+ const indent = " ".repeat(l.indent);
21
+ const tag = l.tag ? ` (${l.tag})` : "";
22
+ const body = l.segments.map((s, j) => {
23
+ const color = INK_COLOR[s.style];
24
+ return createElement(Text, {
25
+ key: j,
26
+ ...color ? { color } : {}
27
+ }, s.text);
28
+ });
29
+ return createElement(Text, { key: i }, `${l.gutter} ${indent}`, ...body, tag);
30
+ }));
31
+ }
32
+ /**
33
+ * Interactive export preview: an ink-tab bar over the Changes / Full /
34
+ * Simulated views. `q`/Esc exits. Written with React.createElement (no JSX).
35
+ *
36
+ * @internal
37
+ */
38
+ function Preview({ views, onExit }) {
39
+ const app = useApp();
40
+ const [active, setActive] = useState("changes");
41
+ useInput((input, key) => {
42
+ if (input === "q" || key.escape) {
43
+ onExit();
44
+ app.exit();
45
+ }
46
+ });
47
+ return createElement(Box, { flexDirection: "column" }, createElement(TabsC, { onChange: (name) => setActive(name) }, createElement(TabC, {
48
+ name: "changes",
49
+ key: "changes"
50
+ }, "Changes"), createElement(TabC, {
51
+ name: "full",
52
+ key: "full"
53
+ }, "Full"), createElement(TabC, {
54
+ name: "simulated",
55
+ key: "simulated"
56
+ }, "Simulated")), renderLines(views[active]));
57
+ }
58
+
59
+ //#endregion
60
+ export { Preview };
package/cli/ui/Walk.js ADDED
@@ -0,0 +1,55 @@
1
+ import { initWalk, walkStep } from "../walk-reducer.js";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { createElement, useEffect, useState } from "react";
4
+
5
+ //#region src/cli/ui/Walk.ts
6
+ /**
7
+ * Interactive per-package upgrade selector rendered with Ink.
8
+ *
9
+ * Written with React.createElement (no JSX) so the file can be plain .ts
10
+ * without requiring TSX transform configuration.
11
+ *
12
+ * @internal
13
+ */
14
+ function Walk({ items, onDone }) {
15
+ const app = useApp();
16
+ const [state, setState] = useState(() => initWalk(items));
17
+ useEffect(() => {
18
+ if (state.done) {
19
+ onDone(state.decisions);
20
+ app.exit();
21
+ }
22
+ }, []);
23
+ useInput((_input, key) => {
24
+ if (state.done) return;
25
+ const which = key.upArrow ? "up" : key.downArrow ? "down" : key.return ? "enter" : null;
26
+ if (!which) return;
27
+ const next = walkStep(state, items, which);
28
+ setState(next);
29
+ if (next.done) {
30
+ onDone(next.decisions);
31
+ app.exit();
32
+ }
33
+ });
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
+ }));
52
+ }
53
+
54
+ //#endregion
55
+ export { Walk };
package/cli/ui/ansi.js ADDED
@@ -0,0 +1,20 @@
1
+ import { paint, tagSuffix } from "./styled.js";
2
+
3
+ //#region src/cli/ui/ansi.ts
4
+ /**
5
+ * Render styled lines to a string. Each line is
6
+ * `<gutter><space><2-space-indent><painted segments><tag>`.
7
+ * Pure: color is decided by the caller, never read from the environment.
8
+ *
9
+ * @internal
10
+ */
11
+ function toAnsi(lines, opts) {
12
+ return lines.map((l) => {
13
+ const indent = " ".repeat(l.indent);
14
+ const body = l.segments.map((s) => paint(s.text, s.style, opts.color)).join("");
15
+ return `${l.gutter} ${indent}${body}${tagSuffix(l.tag)}`;
16
+ }).join("\n");
17
+ }
18
+
19
+ //#endregion
20
+ export { toAnsi };