@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.
- package/package.json +7 -3
- package/src/__tests__/schema-cli.integration.test.ts +45 -0
- package/src/__tests__/upgrade-cli.test.ts +370 -0
- package/src/api/__tests__/request-locale.integration.test.ts +96 -0
- package/src/api/api-constants.ts +7 -0
- package/src/api/request-context.ts +5 -0
- package/src/api/request-id-middleware.ts +10 -0
- package/src/arg-parser.ts +66 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +6 -3
- 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/index.ts +6 -0
- package/src/i18n/request-locale.ts +63 -0
- package/src/i18n/required-surface-keys.ts +22 -7
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +28 -29
- package/src/pipeline/dispatch-shared.ts +8 -0
- package/src/pipeline/distributed-lock.ts +26 -0
- package/src/schema-cli.ts +11 -0
- package/src/stack/test-stack.ts +6 -1
- package/src/testing/handler-context.ts +17 -12
- 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");
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
|
4
|
-
// (ctx.query/write/loadAggregate/...).
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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
|
-
//
|
|
126
|
-
//
|
|
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
|
+
}
|