@cosmicdrift/kumiko-framework 0.211.0 → 0.213.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 (35) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/schema-cli.integration.test.ts +45 -0
  3. package/src/__tests__/upgrade-cli.test.ts +370 -0
  4. package/src/api/__tests__/request-locale.integration.test.ts +96 -0
  5. package/src/api/api-constants.ts +7 -0
  6. package/src/api/request-context.ts +5 -0
  7. package/src/api/request-id-middleware.ts +10 -0
  8. package/src/arg-parser.ts +66 -0
  9. package/src/bun-db/index.ts +1 -0
  10. package/src/bun-db/query.ts +6 -3
  11. package/src/db/queries/backfill-pii.ts +19 -2
  12. package/src/engine/__tests__/boot-validator.test.ts +222 -0
  13. package/src/engine/__tests__/required-surface-keys.test.ts +47 -0
  14. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +64 -0
  15. package/src/engine/boot-validator/detail-screens.ts +16 -2
  16. package/src/engine/boot-validator/i18n-keys.ts +9 -2
  17. package/src/engine/boot-validator/index.ts +7 -2
  18. package/src/engine/boot-validator/screens.ts +114 -25
  19. package/src/engine/feature-changelog.ts +2 -0
  20. package/src/errors/__tests__/classes.test.ts +8 -0
  21. package/src/errors/__tests__/write-failures.test.ts +5 -0
  22. package/src/errors/classes.ts +1 -1
  23. package/src/errors/write-error-info.ts +3 -1
  24. package/src/event-store/__tests__/backfill-pii.integration.test.ts +75 -0
  25. package/src/i18n/index.ts +6 -0
  26. package/src/i18n/request-locale.ts +63 -0
  27. package/src/i18n/required-surface-keys.ts +22 -7
  28. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +31 -0
  29. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +28 -29
  30. package/src/pipeline/dispatch-shared.ts +8 -0
  31. package/src/pipeline/distributed-lock.ts +26 -0
  32. package/src/schema-cli.ts +11 -0
  33. package/src/stack/test-stack.ts +6 -1
  34. package/src/testing/handler-context.ts +17 -12
  35. package/src/upgrade-cli.ts +445 -0
@@ -5,6 +5,11 @@ import { RedisKeys } from "./redis-keys";
5
5
  export type DistributedLock = {
6
6
  acquire(key: string, options?: { ttlSeconds?: number }): Promise<string | null>;
7
7
  release(key: string, token: string): Promise<boolean>;
8
+ /** Extends the TTL of a lock this caller still holds (token matches).
9
+ * Returns false when the token doesn't match — expired and re-claimed
10
+ * by someone else, or never held — the caller must treat that as
11
+ * "no longer the owner" and stop renewing, not retry. */
12
+ renew(key: string, token: string, ttlSeconds: number): Promise<boolean>;
8
13
  };
9
14
 
10
15
  export function createDistributedLock(
@@ -20,6 +25,16 @@ export function createDistributedLock(
20
25
  end
21
26
  `;
22
27
 
28
+ // Lua script for atomic check-and-extend — same ownership check as release,
29
+ // but resets the TTL instead of deleting the key.
30
+ const renewScript = `
31
+ if redis.call("get", KEYS[1]) == ARGV[1] then
32
+ return redis.call("expire", KEYS[1], ARGV[2])
33
+ else
34
+ return 0
35
+ end
36
+ `;
37
+
23
38
  return {
24
39
  async acquire(key, options = {}) {
25
40
  const ttl = options.ttlSeconds ?? 30;
@@ -33,5 +48,16 @@ export function createDistributedLock(
33
48
  const result = (await redis.eval(releaseScript, 1, `${prefix}${key}`, token)) as number; // @cast-boundary db-operator
34
49
  return result === 1;
35
50
  },
51
+
52
+ async renew(key, token, ttlSeconds) {
53
+ const result = (await redis.eval(
54
+ renewScript,
55
+ 1,
56
+ `${prefix}${key}`,
57
+ token,
58
+ String(ttlSeconds),
59
+ )) as number; // @cast-boundary db-operator
60
+ return result === 1;
61
+ },
36
62
  };
37
63
  }
package/src/schema-cli.ts CHANGED
@@ -143,10 +143,21 @@ export async function runSchemaCli(
143
143
  switch (sub) {
144
144
  case "generate": {
145
145
  const name = argv[1];
146
+ if (name === "--help" || name === "-h") {
147
+ out.log(" Usage: schema generate <name>");
148
+ return 0;
149
+ }
146
150
  if (!name) {
147
151
  out.err(" Usage: schema generate <name>");
148
152
  return 1;
149
153
  }
154
+ // name lands unescaped in `${seq}_${name}.sql` (generateMigration) — this
155
+ // allowlist blocks flag-like names and path traversal (`../../x`).
156
+ if (name.startsWith("-") || !/^[A-Za-z0-9_-]+$/.test(name)) {
157
+ out.err(` Invalid migration name "${name}" — use letters, digits, "-", "_" only.`);
158
+ out.err(" Usage: schema generate <name>");
159
+ return 1;
160
+ }
150
161
  if (!existsSync(schemaFile)) {
151
162
  out.err(` ${schemaFile} fehlt.`);
152
163
  out.err(" App-Convention: kumiko/schema.ts mit");
@@ -153,6 +153,11 @@ export type TestStackOptions = {
153
153
  consumerLane?: JobRunIn;
154
154
  queueNamePrefix?: string;
155
155
  };
156
+ /** Override the event dispatcher's polling-timer interval. Default 50ms.
157
+ * Tests that assert LISTEN/NOTIFY wake-up latency need this pushed far
158
+ * out (e.g. 60_000) so the polling timer can't land inside the
159
+ * assertion window and mask a dead subscription — see E.4 (#2042). */
160
+ eventDispatcherPollIntervalMs?: number;
156
161
  };
157
162
 
158
163
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
@@ -379,7 +384,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
379
384
  // plumbs through the LISTEN wake-up for tests that want to measure
380
385
  // post-commit latency (Sprint E.4).
381
386
  eventDispatcher: {
382
- pollIntervalMs: 50,
387
+ pollIntervalMs: options.eventDispatcherPollIntervalMs ?? 50,
383
388
  pgClient: testDb.client as PgClient | undefined,
384
389
  systemConsumers: {
385
390
  sse: enabledHooks.includes("sse"),
@@ -1,10 +1,10 @@
1
1
  // @runtime runtime
2
2
  //
3
- // bridgeStub liefert eine HandlerContext-Shape mit throw-on-use Bridge-Methods
4
- // (ctx.query/write/loadAggregate/...). Wird von Test-Code UND Production-
5
- // Services genutzt (delivery-service nutzt es um cross-feature notify-Calls
6
- // ohne echten Dispatcher zu fahren). Daher runtime-Klassifizierung trotz
7
- // Wohnsitz unter `testing/` — keine vitest-Imports, keine Test-Side-Effects.
3
+ // bridgeStub hands back a HandlerContext shape with throw-on-use bridge
4
+ // methods (ctx.query/write/loadAggregate/...). Used by both test code AND
5
+ // production services (delivery-service uses it to run cross-feature notify
6
+ // calls without a real dispatcher). Hence the runtime classification despite
7
+ // living under `testing/` — no vitest imports, no test side-effects.
8
8
  import type {
9
9
  AppendEventArgs,
10
10
  FetchForWritingArgs,
@@ -12,6 +12,7 @@ import type {
12
12
  SessionUser,
13
13
  WriteResult,
14
14
  } from "../engine/types";
15
+ import { DEFAULT_LOCALE } from "../i18n/request-locale";
15
16
  import { createNoopMetricsHandle, getFallbackTracer } from "../observability";
16
17
  import { createTzContext } from "../time";
17
18
 
@@ -61,13 +62,14 @@ export function bridgeStub(opts?: {
61
62
  | "metricsFor"
62
63
  | "tracer"
63
64
  | "tz"
65
+ | "locale"
64
66
  | "user"
65
67
  > {
66
- // ctx.user ist Convenience-Alias zu event.user (siehe HandlerContext-
67
- // Doku). Caller-Code erwartet das Feld; bridgeStub liefert es als
68
- // Stub mit den Anonymous-Default-Werten wenn kein User explizit
69
- // übergeben wird. Test-Code mit Identity-Bezug übergibt seinen
70
- // SessionUser hier und bekommt ihn am ctx zurück.
68
+ // ctx.user is a convenience alias for event.user (see HandlerContext
69
+ // docs). Caller code expects the field; bridgeStub hands back a stub with
70
+ // anonymous default values when no user is passed explicitly. Test code
71
+ // that cares about identity passes its own SessionUser here and gets it
72
+ // back on ctx.
71
73
  const stubUser: SessionUser = opts?.user ?? {
72
74
  id: "00000000-0000-0000-0000-000000000000",
73
75
  tenantId: "00000000-0000-0000-0000-000000000000" as SessionUser["tenantId"], // @cast-boundary engine-bridge
@@ -122,8 +124,11 @@ export function bridgeStub(opts?: {
122
124
  metrics: createNoopMetricsHandle(),
123
125
  metricsFor: () => createNoopMetricsHandle(),
124
126
  tracer: noopTracer,
125
- // Echter TzContext, kein notAvailable — Test-Code nutzt ctx.tz häufig
126
- // ohne dass es ein "Bridge"-Konzept ist. Default UTC.
127
+ // Real TzContext, not notAvailable — test code uses ctx.tz routinely,
128
+ // it isn't a "bridge" concept. Defaults to UTC.
127
129
  tz: createTzContext(),
130
+ // Same reasoning as tz above — ctx.locale is always-present, not a
131
+ // bridge method. Defaults to DEFAULT_LOCALE.
132
+ locale: DEFAULT_LOCALE,
128
133
  };
129
134
  }
@@ -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
+ }