@cosmicdrift/kumiko-framework 0.210.0 → 0.212.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/package.json +7 -3
- package/src/__tests__/upgrade-cli.test.ts +370 -0
- package/src/arg-parser.ts +66 -0
- package/src/changes.json +56 -0
- package/src/db/queries/backfill-pii.ts +19 -2
- package/src/engine/__tests__/boot-validator.test.ts +222 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +47 -0
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +64 -0
- package/src/engine/boot-validator/detail-screens.ts +16 -2
- package/src/engine/boot-validator/i18n-keys.ts +9 -2
- package/src/engine/boot-validator/index.ts +7 -2
- package/src/engine/boot-validator/screens.ts +114 -25
- package/src/engine/feature-changelog.ts +2 -0
- package/src/errors/__tests__/classes.test.ts +8 -0
- package/src/errors/__tests__/write-failures.test.ts +5 -0
- package/src/errors/classes.ts +1 -1
- package/src/errors/write-error-info.ts +3 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +75 -0
- package/src/i18n/required-surface-keys.ts +22 -7
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +28 -29
- package/src/stack/test-stack.ts +6 -1
- package/src/upgrade-cli.ts +445 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
10
|
+
import { getFlag, getStringFlag, parseArgs } from "./arg-parser";
|
|
11
|
+
import {
|
|
12
|
+
type ChangelogEntry,
|
|
13
|
+
compareVersions,
|
|
14
|
+
filterEntriesAfter,
|
|
15
|
+
parseFeatureChangelog,
|
|
16
|
+
sortEntries,
|
|
17
|
+
} from "./engine";
|
|
18
|
+
import { ensureTemporalPolyfill } from "./time";
|
|
19
|
+
|
|
20
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
21
|
+
const CODEMOD_SUBDIR = "scripts/codemod";
|
|
22
|
+
|
|
23
|
+
export type UpgradeCliOut = {
|
|
24
|
+
readonly log: (line: string) => void;
|
|
25
|
+
readonly err: (line: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function readPackageVersion(cwd: string, pkgName: string, repoLocalPath: string): string | null {
|
|
29
|
+
// Walk up from cwd to find node_modules/@cosmicdrift/<pkgName>/package.json
|
|
30
|
+
// (handles bun workspace hoisting where packages live in parent node_modules)
|
|
31
|
+
let dir = cwd;
|
|
32
|
+
for (let i = 0; i < 10; i++) {
|
|
33
|
+
const nmPath = join(dir, `node_modules/@cosmicdrift/${pkgName}/package.json`);
|
|
34
|
+
if (existsSync(nmPath)) {
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(readFileSync(nmPath, "utf-8"));
|
|
37
|
+
return pkg.version ?? null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const parent = join(dir, "..");
|
|
43
|
+
if (parent === dir) break;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
// Fallback: repo-local package root
|
|
47
|
+
const repoPath = join(cwd, repoLocalPath);
|
|
48
|
+
if (existsSync(repoPath)) {
|
|
49
|
+
try {
|
|
50
|
+
const pkg = JSON.parse(readFileSync(repoPath, "utf-8"));
|
|
51
|
+
return pkg.version ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Changelog entries come from @cosmicdrift/kumiko-bundled-features (see
|
|
60
|
+
// findFeaturesDirs); comparing against the framework version instead
|
|
61
|
+
// compares unrelated packages once the two stop being versioned in lockstep.
|
|
62
|
+
function readCurrentVersion(cwd: string): string | null {
|
|
63
|
+
return (
|
|
64
|
+
readPackageVersion(cwd, "kumiko-bundled-features", "packages/bundled-features/package.json") ??
|
|
65
|
+
readPackageVersion(cwd, "kumiko-framework", "packages/framework/package.json")
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readChangelogFile(filePath: string): ChangelogEntry[] {
|
|
70
|
+
if (!existsSync(filePath)) return [];
|
|
71
|
+
try {
|
|
72
|
+
return [...(parseFeatureChangelog(readFileSync(filePath, "utf-8"), filePath)?.entries ?? [])];
|
|
73
|
+
} catch {
|
|
74
|
+
// Skip malformed files
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collectChangelogs(featuresDir: string): ChangelogEntry[] {
|
|
80
|
+
if (!existsSync(featuresDir)) return [];
|
|
81
|
+
|
|
82
|
+
const entries: ChangelogEntry[] = [];
|
|
83
|
+
const features = readdirSync(featuresDir, { withFileTypes: true })
|
|
84
|
+
.filter((d) => d.isDirectory())
|
|
85
|
+
.map((d) => d.name);
|
|
86
|
+
|
|
87
|
+
for (const name of features) {
|
|
88
|
+
// Layout is detected per-package, not guessed from a naming convention:
|
|
89
|
+
// enterprise packages keep changes.json under src/, framework's
|
|
90
|
+
// bundled-features keep it flat. A name-prefix heuristic (e.g. "ai-*")
|
|
91
|
+
// silently drops packages that don't match it (fw#1605).
|
|
92
|
+
const srcLayout = join(featuresDir, name, "src", "changes.json");
|
|
93
|
+
const flatLayout = join(featuresDir, name, "changes.json");
|
|
94
|
+
const changelogPath = existsSync(srcLayout) ? srcLayout : flatLayout;
|
|
95
|
+
|
|
96
|
+
entries.push(...readChangelogFile(changelogPath));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Framework core changes belong to no feature — they live in a single
|
|
103
|
+
// changes.json next to the framework sources.
|
|
104
|
+
export function findCoreChangelogFile(cwd: string): string | null {
|
|
105
|
+
const repoPath = join(cwd, "packages/framework/src/changes.json");
|
|
106
|
+
if (existsSync(repoPath)) return repoPath;
|
|
107
|
+
|
|
108
|
+
let dir = cwd;
|
|
109
|
+
for (let i = 0; i < 10; i++) {
|
|
110
|
+
const nmPath = join(dir, "node_modules/@cosmicdrift/kumiko-framework/src/changes.json");
|
|
111
|
+
if (existsSync(nmPath)) return nmPath;
|
|
112
|
+
const parent = join(dir, "..");
|
|
113
|
+
if (parent === dir) break;
|
|
114
|
+
dir = parent;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function findFeaturesDirs(cwd: string): string[] {
|
|
121
|
+
const dirs: string[] = [];
|
|
122
|
+
|
|
123
|
+
// Framework repo: packages/bundled-features/src
|
|
124
|
+
const fwDir = join(cwd, "packages/bundled-features/src");
|
|
125
|
+
if (existsSync(fwDir)) dirs.push(fwDir);
|
|
126
|
+
|
|
127
|
+
// Enterprise repo: packages/<name>/src/changes.json or packages/<name>/changes.json.
|
|
128
|
+
// Detected by presence of changes.json, not a package-name prefix — a
|
|
129
|
+
// prefix heuristic silently stops matching once packages are renamed or a
|
|
130
|
+
// differently-named package is added (fw#1605). Skipped inside the
|
|
131
|
+
// framework repo itself (packages/framework present): its own
|
|
132
|
+
// packages/framework/src/changes.json is the core changelog (already
|
|
133
|
+
// collected via findCoreChangelogFile), not a feature package, and would
|
|
134
|
+
// otherwise get double-counted as one here.
|
|
135
|
+
const isFrameworkRepo = existsSync(join(cwd, "packages/framework"));
|
|
136
|
+
const entDir = join(cwd, "packages");
|
|
137
|
+
if (!isFrameworkRepo && existsSync(entDir)) {
|
|
138
|
+
const hasEntPkgs = readdirSync(entDir, { withFileTypes: true }).some(
|
|
139
|
+
(d) =>
|
|
140
|
+
d.isDirectory() &&
|
|
141
|
+
(existsSync(join(entDir, d.name, "changes.json")) ||
|
|
142
|
+
existsSync(join(entDir, d.name, "src", "changes.json"))),
|
|
143
|
+
);
|
|
144
|
+
if (hasEntPkgs) dirs.push(entDir);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// App repos: walk up to find bundled-features in hoisted node_modules.
|
|
148
|
+
// Skipped inside the framework repo — the workspace symlink points back at
|
|
149
|
+
// the dir already collected above and would duplicate every entry.
|
|
150
|
+
if (dirs.includes(fwDir)) return dirs;
|
|
151
|
+
|
|
152
|
+
let dir = cwd;
|
|
153
|
+
for (let i = 0; i < 10; i++) {
|
|
154
|
+
const nmDir = join(dir, "node_modules/@cosmicdrift/kumiko-bundled-features/src");
|
|
155
|
+
if (existsSync(nmDir)) {
|
|
156
|
+
dirs.push(nmDir);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
const parent = join(dir, "..");
|
|
160
|
+
if (parent === dir) break;
|
|
161
|
+
dir = parent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return dirs;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Resolves a changes.json `codemod` field to an absolute script path,
|
|
168
|
+
// refusing anything that would escape scripts/codemod/ (path traversal,
|
|
169
|
+
// absolute paths, symlinks pointing outward) or that isn't a real .ts file.
|
|
170
|
+
export function resolveCodemodScript(
|
|
171
|
+
repoRoot: string,
|
|
172
|
+
codemodField: string | undefined,
|
|
173
|
+
): string | null {
|
|
174
|
+
if (!codemodField) return null;
|
|
175
|
+
if (codemodField.includes("\0") || codemodField.startsWith("/") || !codemodField.endsWith(".ts"))
|
|
176
|
+
return null;
|
|
177
|
+
|
|
178
|
+
const scriptsRoot = join(repoRoot, CODEMOD_SUBDIR);
|
|
179
|
+
const resolved = join(repoRoot, codemodField);
|
|
180
|
+
const rel = relative(scriptsRoot, resolved);
|
|
181
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
182
|
+
if (!existsSync(resolved)) return null;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const realResolved = realpathSync(resolved);
|
|
186
|
+
const realScriptsRoot = realpathSync(scriptsRoot);
|
|
187
|
+
const realRel = relative(realScriptsRoot, realResolved);
|
|
188
|
+
if (realRel.startsWith("..") || isAbsolute(realRel)) return null;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return resolved;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type CodemodRunResult = { readonly ok: boolean; readonly output: string };
|
|
197
|
+
|
|
198
|
+
// Array-form argv only — never a shell string. The script itself decides
|
|
199
|
+
// what to touch inside targetDir; this just invokes it as a subprocess.
|
|
200
|
+
async function runCodemodScript(
|
|
201
|
+
scriptPath: string,
|
|
202
|
+
targetDir: string,
|
|
203
|
+
repoRoot: string,
|
|
204
|
+
dryRun: boolean,
|
|
205
|
+
): Promise<CodemodRunResult> {
|
|
206
|
+
const cmd = ["bun", scriptPath, targetDir, ...(dryRun ? ["--dry-run"] : [])];
|
|
207
|
+
const proc = Bun.spawn({ cmd, cwd: repoRoot, stdout: "pipe", stderr: "pipe" });
|
|
208
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
209
|
+
new Response(proc.stdout).text(),
|
|
210
|
+
new Response(proc.stderr).text(),
|
|
211
|
+
proc.exited,
|
|
212
|
+
]);
|
|
213
|
+
return { ok: exitCode === 0, output: `${stdout}${stderr}`.trim() };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function hasCodemod(e: ChangelogEntry): e is ChangelogEntry & { codemod: string } {
|
|
217
|
+
return typeof e.codemod === "string" && e.codemod.length > 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
type UpgradeMarkerCodemod = {
|
|
221
|
+
readonly version: string;
|
|
222
|
+
readonly codemod: string;
|
|
223
|
+
readonly title: string;
|
|
224
|
+
};
|
|
225
|
+
type UpgradeMarker = {
|
|
226
|
+
readonly version: string;
|
|
227
|
+
readonly appliedAt: string;
|
|
228
|
+
readonly codemods: readonly UpgradeMarkerCodemod[];
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
function writeUpgradeMarker(targetDir: string, marker: UpgradeMarker): void {
|
|
232
|
+
const dir = join(targetDir, ".kumiko");
|
|
233
|
+
mkdirSync(dir, { recursive: true });
|
|
234
|
+
writeFileSync(join(dir, "upgrade-state.json"), `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Runs every pending breaking entry's codemod, oldest version first (so a
|
|
238
|
+
// later codemod can assume an earlier one already ran). Stops on the first
|
|
239
|
+
// failure — no partial marker. Writes the marker whenever dryRun is false —
|
|
240
|
+
// even with zero pending entries, so an already-current app still gets a
|
|
241
|
+
// bootstrap marker recording its installed version (fw#2299).
|
|
242
|
+
async function applyCodemods(
|
|
243
|
+
out: UpgradeCliOut,
|
|
244
|
+
pending: readonly ChangelogEntry[],
|
|
245
|
+
repoRoot: string,
|
|
246
|
+
targetDir: string,
|
|
247
|
+
dryRun: boolean,
|
|
248
|
+
currentVersion: string,
|
|
249
|
+
): Promise<number> {
|
|
250
|
+
if (pending.length === 0) {
|
|
251
|
+
out.log(" ✓ Nothing new since your version.");
|
|
252
|
+
if (!dryRun) {
|
|
253
|
+
writeUpgradeMarker(targetDir, {
|
|
254
|
+
version: currentVersion,
|
|
255
|
+
appliedAt: Temporal.Now.instant().toString(),
|
|
256
|
+
codemods: [],
|
|
257
|
+
});
|
|
258
|
+
out.log(` ✓ Applied 0 codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`);
|
|
259
|
+
}
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const breaking = pending.filter((e) => e.type === "breaking");
|
|
264
|
+
const codemodEntries = breaking
|
|
265
|
+
.filter(hasCodemod)
|
|
266
|
+
.sort((a, b) => compareVersions(a.version, b.version));
|
|
267
|
+
const manualEntries = breaking.filter((e) => !e.codemod);
|
|
268
|
+
|
|
269
|
+
for (const e of manualEntries) {
|
|
270
|
+
out.log(` ⚠ ${e.version} · ${e.title} — no codemod, manual migration required`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (codemodEntries.length === 0) {
|
|
274
|
+
out.log(
|
|
275
|
+
breaking.length > 0
|
|
276
|
+
? " No automatable codemods among the pending breaking changes."
|
|
277
|
+
: " ✓ No breaking changes pending.",
|
|
278
|
+
);
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const ran: UpgradeMarkerCodemod[] = [];
|
|
283
|
+
for (const e of codemodEntries) {
|
|
284
|
+
const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
|
|
285
|
+
if (!scriptPath) {
|
|
286
|
+
out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
out.log(` → ${e.version} · running ${e.codemod}${dryRun ? " (dry-run)" : ""}`);
|
|
291
|
+
const result = await runCodemodScript(scriptPath, targetDir, repoRoot, dryRun);
|
|
292
|
+
if (result.output) out.log(result.output);
|
|
293
|
+
if (!result.ok) {
|
|
294
|
+
out.err(` ✗ ${e.version} · ${e.codemod} failed`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
ran.push({ version: e.version, codemod: e.codemod, title: e.title });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (dryRun) {
|
|
301
|
+
out.log(` ✓ Dry-run: ${ran.length} codemod(s) would run. Nothing written.`);
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const latestVersion = pending.reduce(
|
|
306
|
+
(max, e) => (compareVersions(e.version, max) > 0 ? e.version : max),
|
|
307
|
+
pending[0]!.version,
|
|
308
|
+
);
|
|
309
|
+
writeUpgradeMarker(targetDir, {
|
|
310
|
+
version: latestVersion,
|
|
311
|
+
appliedAt: Temporal.Now.instant().toString(),
|
|
312
|
+
codemods: ran,
|
|
313
|
+
});
|
|
314
|
+
out.log(
|
|
315
|
+
` ✓ Applied ${ran.length} codemod(s). Wrote ${join(targetDir, ".kumiko/upgrade-state.json")}`,
|
|
316
|
+
);
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// kumiko-lint-ignore complexity-budget CLI orchestration moved from bin/commands/upgrade.ts — same branching surface, shared by kumiko upgrade + published kumiko-upgrade bin
|
|
321
|
+
export async function runUpgradeCli(
|
|
322
|
+
argv: readonly string[],
|
|
323
|
+
cwd: string,
|
|
324
|
+
out: UpgradeCliOut,
|
|
325
|
+
options?: { readonly repoRoot?: string },
|
|
326
|
+
): Promise<number> {
|
|
327
|
+
// Standalone CLI entry, not booted via runProdApp/runDevApp — Temporal needs an explicit polyfill here.
|
|
328
|
+
await ensureTemporalPolyfill();
|
|
329
|
+
const repoRoot = options?.repoRoot ?? cwd;
|
|
330
|
+
const args = parseArgs(argv);
|
|
331
|
+
const jsonMode = getFlag(args, "json");
|
|
332
|
+
const verbose = getFlag(args, "verbose");
|
|
333
|
+
const fromFlag = getStringFlag(args, "from");
|
|
334
|
+
|
|
335
|
+
const currentVersion = fromFlag ?? readCurrentVersion(cwd);
|
|
336
|
+
if (!currentVersion) {
|
|
337
|
+
out.err("");
|
|
338
|
+
out.err(" Could not detect Kumiko version.");
|
|
339
|
+
out.err(" Run from an app directory with node_modules, or use --from <version>.");
|
|
340
|
+
out.err("");
|
|
341
|
+
return 1;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!SEMVER_RE.test(currentVersion)) {
|
|
345
|
+
out.err("");
|
|
346
|
+
out.err(` Invalid version format: "${currentVersion}" — expected x.y.z`);
|
|
347
|
+
out.err("");
|
|
348
|
+
return 1;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const featuresDirs = findFeaturesDirs(cwd);
|
|
352
|
+
const coreChangelogFile = findCoreChangelogFile(cwd);
|
|
353
|
+
if (featuresDirs.length === 0 && !coreChangelogFile) {
|
|
354
|
+
out.err("");
|
|
355
|
+
out.err(" Could not find bundled-features directory.");
|
|
356
|
+
out.err(" Run from framework/enterprise repo or an app with node_modules.");
|
|
357
|
+
out.err("");
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const allEntries: ChangelogEntry[] = [];
|
|
362
|
+
for (const dir of featuresDirs) {
|
|
363
|
+
allEntries.push(...collectChangelogs(dir));
|
|
364
|
+
}
|
|
365
|
+
if (coreChangelogFile) {
|
|
366
|
+
allEntries.push(...readChangelogFile(coreChangelogFile));
|
|
367
|
+
}
|
|
368
|
+
const pending = sortEntries(filterEntriesAfter(allEntries, currentVersion));
|
|
369
|
+
|
|
370
|
+
if (getFlag(args, "apply")) {
|
|
371
|
+
const dirFlag = getStringFlag(args, "dir");
|
|
372
|
+
const targetDir = dirFlag ? resolve(dirFlag) : cwd;
|
|
373
|
+
const dryRun = getFlag(args, "dry-run");
|
|
374
|
+
out.log("");
|
|
375
|
+
const code = await applyCodemods(out, pending, repoRoot, targetDir, dryRun, currentVersion);
|
|
376
|
+
out.log("");
|
|
377
|
+
return code;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (jsonMode) {
|
|
381
|
+
out.log(JSON.stringify({ currentVersion, pending }, null, 2));
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const breaking = pending.filter((e) => e.type === "breaking");
|
|
386
|
+
const improvements = pending.filter((e) => e.type === "improvement");
|
|
387
|
+
const fixes = pending.filter((e) => e.type === "fix");
|
|
388
|
+
|
|
389
|
+
out.log("");
|
|
390
|
+
out.log(` Upgrade: ${currentVersion} → latest`);
|
|
391
|
+
out.log("");
|
|
392
|
+
|
|
393
|
+
if (pending.length === 0) {
|
|
394
|
+
out.log(" ✓ Nothing new since your version.");
|
|
395
|
+
out.log("");
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (breaking.length > 0) {
|
|
400
|
+
out.log(` ⚠ BREAKING (${breaking.length})`);
|
|
401
|
+
out.log("");
|
|
402
|
+
for (const e of breaking) {
|
|
403
|
+
out.log(` ${e.version} · ${e.title}`);
|
|
404
|
+
if (verbose && e.detail) {
|
|
405
|
+
out.log(` ${e.detail}`);
|
|
406
|
+
}
|
|
407
|
+
if (e.migration) {
|
|
408
|
+
out.log(` → Migration: ${e.migration}`);
|
|
409
|
+
}
|
|
410
|
+
out.log("");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (improvements.length > 0) {
|
|
415
|
+
out.log(` ✓ IMPROVEMENTS (${improvements.length})`);
|
|
416
|
+
out.log("");
|
|
417
|
+
for (const e of improvements) {
|
|
418
|
+
out.log(` ${e.version} · ${e.title}`);
|
|
419
|
+
if (verbose && e.detail) {
|
|
420
|
+
out.log(` ${e.detail}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
out.log("");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (fixes.length > 0) {
|
|
427
|
+
out.log(` ✓ FIXES (${fixes.length})`);
|
|
428
|
+
out.log("");
|
|
429
|
+
for (const e of fixes) {
|
|
430
|
+
out.log(` ${e.version} · ${e.title}`);
|
|
431
|
+
if (verbose && e.detail) {
|
|
432
|
+
out.log(` ${e.detail}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
out.log("");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (breaking.length > 0) {
|
|
439
|
+
out.log(" ⚠ Review breaking changes above before upgrading.");
|
|
440
|
+
out.log(" Run with --verbose for full migration details.");
|
|
441
|
+
out.log("");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return 0;
|
|
445
|
+
}
|