@cosmicdrift/kumiko-framework 0.211.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.
@@ -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
+ }