@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.
- package/package.json +7 -3
- package/src/__tests__/upgrade-cli.test.ts +370 -0
- package/src/arg-parser.ts +66 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.212.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -159,6 +159,10 @@
|
|
|
159
159
|
"types": "./src/consumer-cli.ts",
|
|
160
160
|
"default": "./src/consumer-cli.ts"
|
|
161
161
|
},
|
|
162
|
+
"./upgrade-cli": {
|
|
163
|
+
"types": "./src/upgrade-cli.ts",
|
|
164
|
+
"default": "./src/upgrade-cli.ts"
|
|
165
|
+
},
|
|
162
166
|
"./stack": {
|
|
163
167
|
"types": "./src/stack/index.ts",
|
|
164
168
|
"default": "./src/stack/index.ts"
|
|
@@ -190,7 +194,7 @@
|
|
|
190
194
|
"./package.json": "./package.json"
|
|
191
195
|
},
|
|
192
196
|
"dependencies": {
|
|
193
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
197
|
+
"@cosmicdrift/kumiko-types": "0.212.0",
|
|
194
198
|
"bullmq": "^5.76.7",
|
|
195
199
|
"bun-types": "^1.3.13",
|
|
196
200
|
"hono": "^4.13.1",
|
|
@@ -206,7 +210,7 @@
|
|
|
206
210
|
"zod": "^4.4.3"
|
|
207
211
|
},
|
|
208
212
|
"devDependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
213
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.212.0",
|
|
210
214
|
"bun-types": "^1.3.13",
|
|
211
215
|
"pino-pretty": "^13.1.3"
|
|
212
216
|
},
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveCodemodScript, runUpgradeCli, type UpgradeCliOut } from "../upgrade-cli";
|
|
6
|
+
|
|
7
|
+
function makeSpyOutput(): {
|
|
8
|
+
readonly out: UpgradeCliOut;
|
|
9
|
+
readonly logs: string[];
|
|
10
|
+
readonly errs: string[];
|
|
11
|
+
} {
|
|
12
|
+
const logs: string[] = [];
|
|
13
|
+
const errs: string[] = [];
|
|
14
|
+
return {
|
|
15
|
+
logs,
|
|
16
|
+
errs,
|
|
17
|
+
out: {
|
|
18
|
+
log: (m: string) => logs.push(m),
|
|
19
|
+
err: (m: string) => errs.push(m),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeTempCwd(files?: Record<string, string>): {
|
|
25
|
+
readonly cwd: string;
|
|
26
|
+
readonly cleanup: () => void;
|
|
27
|
+
} {
|
|
28
|
+
const cwd = mkdtempSync(join(tmpdir(), "kumiko-upgrade-cli-"));
|
|
29
|
+
if (files) {
|
|
30
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
31
|
+
const full = join(cwd, relPath);
|
|
32
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
33
|
+
writeFileSync(full, content, "utf-8");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
cwd,
|
|
38
|
+
cleanup: () => {
|
|
39
|
+
try {
|
|
40
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore — best-effort
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cleanups: Array<() => void> = [];
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
for (const c of cleanups) c();
|
|
51
|
+
cleanups.length = 0;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const CORE_ENTRY = JSON.stringify([
|
|
55
|
+
{
|
|
56
|
+
version: "0.167.0",
|
|
57
|
+
type: "breaking",
|
|
58
|
+
title: "core helper moved",
|
|
59
|
+
migration: "import from /testing",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const FEATURE_ENTRY = JSON.stringify([{ version: "0.166.0", type: "fix", title: "feature fix" }]);
|
|
64
|
+
|
|
65
|
+
function tmp(files: Record<string, string>): string {
|
|
66
|
+
const t = makeTempCwd(files);
|
|
67
|
+
cleanups.push(t.cleanup);
|
|
68
|
+
return t.cwd;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runJson(cwd: string, from: string): Promise<{ pending: Array<{ title: string }> }> {
|
|
72
|
+
const spy = makeSpyOutput();
|
|
73
|
+
const exit = await runUpgradeCli(["--from", from, "--json"], cwd, spy.out);
|
|
74
|
+
expect(exit).toBe(0);
|
|
75
|
+
return JSON.parse(spy.logs.join("\n"));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("upgrade command — framework core changelog", () => {
|
|
79
|
+
test("collects core changes.json from the framework repo layout", async () => {
|
|
80
|
+
const cwd = tmp({
|
|
81
|
+
"packages/framework/src/changes.json": CORE_ENTRY,
|
|
82
|
+
"packages/bundled-features/src/user/changes.json": FEATURE_ENTRY,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await runJson(cwd, "0.165.0");
|
|
86
|
+
|
|
87
|
+
expect(result.pending.map((e) => e.title)).toEqual(["core helper moved", "feature fix"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("finds core changes.json in hoisted node_modules from an app subdir", async () => {
|
|
91
|
+
const cwd = tmp({
|
|
92
|
+
"node_modules/@cosmicdrift/kumiko-framework/src/changes.json": CORE_ENTRY,
|
|
93
|
+
"apps/web/package.json": "{}",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await runJson(`${cwd}/apps/web`, "0.165.0");
|
|
97
|
+
|
|
98
|
+
expect(result.pending.map((e) => e.title)).toEqual(["core helper moved"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("repo file wins over node_modules — no duplicate entries", async () => {
|
|
102
|
+
const cwd = tmp({
|
|
103
|
+
"packages/framework/src/changes.json": CORE_ENTRY,
|
|
104
|
+
"node_modules/@cosmicdrift/kumiko-framework/src/changes.json": CORE_ENTRY,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await runJson(cwd, "0.165.0");
|
|
108
|
+
|
|
109
|
+
expect(result.pending).toHaveLength(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("feature entries are not duplicated by the workspace symlink", async () => {
|
|
113
|
+
const cwd = tmp({
|
|
114
|
+
"packages/bundled-features/src/user/changes.json": FEATURE_ENTRY,
|
|
115
|
+
"node_modules/@cosmicdrift/kumiko-bundled-features/src/user/changes.json": FEATURE_ENTRY,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = await runJson(cwd, "0.165.0");
|
|
119
|
+
|
|
120
|
+
expect(result.pending).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("core entries older than the current version are filtered out", async () => {
|
|
124
|
+
const cwd = tmp({ "packages/framework/src/changes.json": CORE_ENTRY });
|
|
125
|
+
|
|
126
|
+
const result = await runJson(cwd, "0.167.0");
|
|
127
|
+
|
|
128
|
+
expect(result.pending).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("--from is rejected when it isn't a valid semver — no silent 'nothing new'", async () => {
|
|
132
|
+
const cwd = tmp({ "packages/framework/src/changes.json": CORE_ENTRY });
|
|
133
|
+
const spy = makeSpyOutput();
|
|
134
|
+
|
|
135
|
+
const exit = await runUpgradeCli(["--from", "latest", "--json"], cwd, spy.out);
|
|
136
|
+
|
|
137
|
+
expect(exit).toBe(1);
|
|
138
|
+
expect(spy.errs.join("\n")).toContain("Invalid version format");
|
|
139
|
+
expect(spy.logs).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("upgrade command — enterprise package layout", () => {
|
|
144
|
+
// Layout is detected by presence of changes.json, not an "ai-" name
|
|
145
|
+
// prefix — the old heuristic silently dropped every enterprise package
|
|
146
|
+
// whose name didn't start with "ai-" (fw#1605).
|
|
147
|
+
test("collects changes.json from a package without an 'ai-' prefix", async () => {
|
|
148
|
+
const cwd = tmp({
|
|
149
|
+
"packages/billing-designer/src/changes.json": FEATURE_ENTRY,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const result = await runJson(cwd, "0.165.0");
|
|
153
|
+
|
|
154
|
+
expect(result.pending.map((e) => e.title)).toEqual(["feature fix"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("flat layout (no src/ subdir) is also collected", async () => {
|
|
158
|
+
const cwd = tmp({
|
|
159
|
+
"packages/billing-designer/changes.json": FEATURE_ENTRY,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = await runJson(cwd, "0.165.0");
|
|
163
|
+
|
|
164
|
+
expect(result.pending.map((e) => e.title)).toEqual(["feature fix"]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// The repo actually checked out on disk — scripts/codemod/ isn't published,
|
|
169
|
+
// so --apply only ever works against a real local framework checkout.
|
|
170
|
+
const REAL_REPO_ROOT = join(import.meta.dir, "../../../..");
|
|
171
|
+
const REAL_CODEMOD = "scripts/codemod/crypto-shredding-testing-move.ts";
|
|
172
|
+
|
|
173
|
+
function breakingEntryWithCodemod(codemod: string | undefined): string {
|
|
174
|
+
const entry: Record<string, unknown> = {
|
|
175
|
+
version: "0.167.0",
|
|
176
|
+
type: "breaking",
|
|
177
|
+
title: "helper moved",
|
|
178
|
+
migration: "import from /testing",
|
|
179
|
+
};
|
|
180
|
+
if (codemod !== undefined) entry["codemod"] = codemod;
|
|
181
|
+
return JSON.stringify([entry]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const LEGACY_IMPORT_FIXTURE = [
|
|
185
|
+
'import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/crypto";',
|
|
186
|
+
"",
|
|
187
|
+
"resetPiiSubjectKmsForTests();",
|
|
188
|
+
"",
|
|
189
|
+
].join("\n");
|
|
190
|
+
|
|
191
|
+
describe("resolveCodemodScript", () => {
|
|
192
|
+
test("resolves a real script under scripts/codemod/", () => {
|
|
193
|
+
const resolved = resolveCodemodScript(REAL_REPO_ROOT, REAL_CODEMOD);
|
|
194
|
+
expect(resolved).toBe(join(REAL_REPO_ROOT, REAL_CODEMOD));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("rejects an absolute path", () => {
|
|
198
|
+
expect(resolveCodemodScript(REAL_REPO_ROOT, "/etc/passwd.ts")).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("rejects path traversal that escapes scripts/codemod/", () => {
|
|
202
|
+
expect(
|
|
203
|
+
resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/../../package.json.ts"),
|
|
204
|
+
).toBeNull();
|
|
205
|
+
expect(resolveCodemodScript(REAL_REPO_ROOT, "../outside/x.ts")).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("rejects a non-.ts file", () => {
|
|
209
|
+
expect(resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/README.md")).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("rejects a script that doesn't exist", () => {
|
|
213
|
+
expect(resolveCodemodScript(REAL_REPO_ROOT, "scripts/codemod/does-not-exist.ts")).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("rejects an undefined codemod field", () => {
|
|
217
|
+
expect(resolveCodemodScript(REAL_REPO_ROOT, undefined)).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("upgrade command — --apply", () => {
|
|
222
|
+
test("runs the real codemod against a fixture file and writes the marker", async () => {
|
|
223
|
+
const cwd = tmp({
|
|
224
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
225
|
+
"legacy-test-helper.ts": LEGACY_IMPORT_FIXTURE,
|
|
226
|
+
});
|
|
227
|
+
const spy = makeSpyOutput();
|
|
228
|
+
|
|
229
|
+
const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
|
|
230
|
+
repoRoot: REAL_REPO_ROOT,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(exit).toBe(0);
|
|
234
|
+
|
|
235
|
+
const rewritten = readFileSync(join(cwd, "legacy-test-helper.ts"), "utf-8");
|
|
236
|
+
expect(rewritten).toContain('from "@cosmicdrift/kumiko-framework/testing"');
|
|
237
|
+
expect(rewritten).not.toContain('from "@cosmicdrift/kumiko-framework/crypto"');
|
|
238
|
+
|
|
239
|
+
const markerPath = join(cwd, ".kumiko/upgrade-state.json");
|
|
240
|
+
expect(existsSync(markerPath)).toBe(true);
|
|
241
|
+
const marker = JSON.parse(readFileSync(markerPath, "utf-8"));
|
|
242
|
+
expect(marker.version).toBe("0.167.0");
|
|
243
|
+
expect(typeof marker.appliedAt).toBe("string");
|
|
244
|
+
expect(marker.codemods).toEqual([
|
|
245
|
+
{ version: "0.167.0", codemod: REAL_CODEMOD, title: "helper moved" },
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("--dry-run runs the codemod but changes nothing and writes no marker", async () => {
|
|
250
|
+
const cwd = tmp({
|
|
251
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
252
|
+
"legacy-test-helper.ts": LEGACY_IMPORT_FIXTURE,
|
|
253
|
+
});
|
|
254
|
+
const spy = makeSpyOutput();
|
|
255
|
+
|
|
256
|
+
const exit = await runUpgradeCli(["--from", "0.165.0", "--apply", "--dry-run"], cwd, spy.out, {
|
|
257
|
+
repoRoot: REAL_REPO_ROOT,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(exit).toBe(0);
|
|
261
|
+
expect(readFileSync(join(cwd, "legacy-test-helper.ts"), "utf-8")).toBe(LEGACY_IMPORT_FIXTURE);
|
|
262
|
+
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
|
|
263
|
+
expect(spy.logs.join("\n")).toContain("Touched 1 files, moved 1 import(s)");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("rejects a path-traversal codemod field and writes no marker", async () => {
|
|
267
|
+
const cwd = tmp({
|
|
268
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod("../../../etc/passwd.ts"),
|
|
269
|
+
});
|
|
270
|
+
const spy = makeSpyOutput();
|
|
271
|
+
|
|
272
|
+
const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
|
|
273
|
+
repoRoot: REAL_REPO_ROOT,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(exit).toBe(1);
|
|
277
|
+
expect(spy.errs.join("\n")).toContain("invalid codemod path");
|
|
278
|
+
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("fails when the codemod script doesn't exist, writes no marker", async () => {
|
|
282
|
+
const cwd = tmp({
|
|
283
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(
|
|
284
|
+
"scripts/codemod/does-not-exist.ts",
|
|
285
|
+
),
|
|
286
|
+
});
|
|
287
|
+
const spy = makeSpyOutput();
|
|
288
|
+
|
|
289
|
+
const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
|
|
290
|
+
repoRoot: REAL_REPO_ROOT,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(exit).toBe(1);
|
|
294
|
+
expect(spy.errs.join("\n")).toContain("invalid codemod path");
|
|
295
|
+
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("breaking changes without a codemod field are reported as manual, no marker written", async () => {
|
|
299
|
+
const cwd = tmp({
|
|
300
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(undefined),
|
|
301
|
+
});
|
|
302
|
+
const spy = makeSpyOutput();
|
|
303
|
+
|
|
304
|
+
const exit = await runUpgradeCli(["--from", "0.165.0", "--apply"], cwd, spy.out, {
|
|
305
|
+
repoRoot: REAL_REPO_ROOT,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(exit).toBe(0);
|
|
309
|
+
expect(spy.logs.join("\n")).toContain("no codemod, manual migration required");
|
|
310
|
+
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("nothing pending: reports up to date, still bootstraps the marker", async () => {
|
|
314
|
+
const cwd = tmp({
|
|
315
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
316
|
+
});
|
|
317
|
+
const spy = makeSpyOutput();
|
|
318
|
+
|
|
319
|
+
const exit = await runUpgradeCli(["--from", "0.170.0", "--apply"], cwd, spy.out, {
|
|
320
|
+
repoRoot: REAL_REPO_ROOT,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(exit).toBe(0);
|
|
324
|
+
expect(spy.logs.join("\n")).toContain("Nothing new since your version");
|
|
325
|
+
|
|
326
|
+
const markerPath = join(cwd, ".kumiko/upgrade-state.json");
|
|
327
|
+
expect(existsSync(markerPath)).toBe(true);
|
|
328
|
+
const marker = JSON.parse(readFileSync(markerPath, "utf-8"));
|
|
329
|
+
expect(marker.version).toBe("0.170.0");
|
|
330
|
+
expect(marker.codemods).toEqual([]);
|
|
331
|
+
expect(typeof marker.appliedAt).toBe("string");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("nothing pending + --dry-run: reports up to date, writes no marker", async () => {
|
|
335
|
+
const cwd = tmp({
|
|
336
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
337
|
+
});
|
|
338
|
+
const spy = makeSpyOutput();
|
|
339
|
+
|
|
340
|
+
const exit = await runUpgradeCli(["--from", "0.170.0", "--apply", "--dry-run"], cwd, spy.out, {
|
|
341
|
+
repoRoot: REAL_REPO_ROOT,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(exit).toBe(0);
|
|
345
|
+
expect(spy.logs.join("\n")).toContain("Nothing new since your version");
|
|
346
|
+
expect(existsSync(join(cwd, ".kumiko/upgrade-state.json"))).toBe(false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("--dir targets a different directory than cwd", async () => {
|
|
350
|
+
const cwd = tmp({
|
|
351
|
+
"packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
|
|
352
|
+
});
|
|
353
|
+
const target = tmp({ "legacy-test-helper.ts": LEGACY_IMPORT_FIXTURE });
|
|
354
|
+
const spy = makeSpyOutput();
|
|
355
|
+
|
|
356
|
+
const exit = await runUpgradeCli(
|
|
357
|
+
["--from", "0.165.0", "--apply", "--dir", target],
|
|
358
|
+
cwd,
|
|
359
|
+
spy.out,
|
|
360
|
+
{
|
|
361
|
+
repoRoot: REAL_REPO_ROOT,
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
expect(exit).toBe(0);
|
|
366
|
+
const rewritten = readFileSync(join(target, "legacy-test-helper.ts"), "utf-8");
|
|
367
|
+
expect(rewritten).toContain('from "@cosmicdrift/kumiko-framework/testing"');
|
|
368
|
+
expect(existsSync(join(target, ".kumiko/upgrade-state.json"))).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Small arg parser for CLI commands. Not as feature-rich as commander or
|
|
2
|
+
// yargs — just:
|
|
3
|
+
// - Positional args: cmd "value1" "value2"
|
|
4
|
+
// - Flags: --flag (boolean) / --key value
|
|
5
|
+
// - Negatable: --no-flag
|
|
6
|
+
//
|
|
7
|
+
// Copy of bin/commands/arg-parser.ts — registry-free, but upgrade-cli.ts
|
|
8
|
+
// lives in the published @cosmicdrift/kumiko-framework package and can't
|
|
9
|
+
// import from bin/commands/ (outside the package's export surface).
|
|
10
|
+
|
|
11
|
+
export type ParsedArgs = {
|
|
12
|
+
readonly positional: ReadonlyArray<string>;
|
|
13
|
+
readonly flags: ReadonlyMap<string, string | boolean>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function parseArgs(argv: ReadonlyArray<string>): ParsedArgs {
|
|
17
|
+
const positional: string[] = [];
|
|
18
|
+
const flags = new Map<string, string | boolean>();
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (!a) continue;
|
|
23
|
+
if (a.startsWith("--")) {
|
|
24
|
+
const key = a.slice(2);
|
|
25
|
+
// `--no-foo` form
|
|
26
|
+
if (key.startsWith("no-")) {
|
|
27
|
+
flags.set(key.slice(3), false);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
// `--key=value` inline form
|
|
31
|
+
const eq = key.indexOf("=");
|
|
32
|
+
if (eq !== -1) {
|
|
33
|
+
flags.set(key.slice(0, eq), key.slice(eq + 1));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// `--key value` if next isn't a flag, else boolean
|
|
37
|
+
const next = argv[i + 1];
|
|
38
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
39
|
+
flags.set(key, next);
|
|
40
|
+
i++;
|
|
41
|
+
} else {
|
|
42
|
+
flags.set(key, true);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
positional.push(a);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { positional, flags };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getFlag(args: ParsedArgs, name: string): boolean {
|
|
53
|
+
return args.flags.get(name) === true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getStringFlag(args: ParsedArgs, name: string): string | undefined {
|
|
57
|
+
const v = args.flags.get(name);
|
|
58
|
+
return typeof v === "string" ? v : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getNumberFlag(args: ParsedArgs, name: string): number | undefined {
|
|
62
|
+
const v = args.flags.get(name);
|
|
63
|
+
if (typeof v !== "string") return undefined;
|
|
64
|
+
const n = Number.parseInt(v, 10);
|
|
65
|
+
return Number.isNaN(n) ? undefined : n;
|
|
66
|
+
}
|
|
@@ -41,7 +41,7 @@ import { asRawClient } from "../../bun-db";
|
|
|
41
41
|
import { quoteIdent } from "../../crypto/ciphertext-pattern";
|
|
42
42
|
import { configuredEventPiiCatalog } from "../../crypto/event-pii";
|
|
43
43
|
import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
|
|
44
|
-
import { KeyErasedError } from "../../crypto/kms-adapter";
|
|
44
|
+
import { KeyErasedError, KeyNotFoundError } from "../../crypto/kms-adapter";
|
|
45
45
|
import {
|
|
46
46
|
configuredPiiSubjectKms,
|
|
47
47
|
encryptPiiValueForSubject,
|
|
@@ -83,7 +83,8 @@ export type PiiBackfillResult = {
|
|
|
83
83
|
|
|
84
84
|
export type PiiBackfillOptions = {
|
|
85
85
|
readonly batchSize?: number;
|
|
86
|
-
// Scan + count only, write nothing
|
|
86
|
+
// Scan + count only, write nothing — including the subject KMS: outcomes
|
|
87
|
+
// are predicted from a read-only kms.getKey probe, never kms.createKey.
|
|
87
88
|
readonly dryRun?: boolean;
|
|
88
89
|
// Stage 2: fall back to the entity's projection row (by aggregate_id)
|
|
89
90
|
// when a lifecycle event's payload doesn't name the owner field.
|
|
@@ -350,6 +351,7 @@ export async function backfillEventPiiEncryption(
|
|
|
350
351
|
section[field] = PII_ERASED_SENTINEL;
|
|
351
352
|
return "erased";
|
|
352
353
|
}
|
|
354
|
+
if (options.dryRun) return predictEncryptOutcome(subject);
|
|
353
355
|
try {
|
|
354
356
|
section[field] = await encryptPiiValueForSubject(
|
|
355
357
|
kms as LocalKeyKmsAdapter,
|
|
@@ -367,6 +369,21 @@ export async function backfillEventPiiEncryption(
|
|
|
367
369
|
throw e;
|
|
368
370
|
}
|
|
369
371
|
}
|
|
372
|
+
|
|
373
|
+
// getKey alone never mints (unlike getOrCreateDek → createKey on the real
|
|
374
|
+
// path) — a real run's outcome is fully determined by whether a key
|
|
375
|
+
// already exists, so probing it read-only predicts the outcome without
|
|
376
|
+
// writing to the subject-keys store.
|
|
377
|
+
async function predictEncryptOutcome(subject: SubjectId): Promise<FieldOutcome> {
|
|
378
|
+
try {
|
|
379
|
+
await (kms as LocalKeyKmsAdapter).getKey(subject, kmsCtx);
|
|
380
|
+
return "encrypted";
|
|
381
|
+
} catch (e) {
|
|
382
|
+
if (e instanceof KeyErasedError) return "erased";
|
|
383
|
+
if (e instanceof KeyNotFoundError) return "encrypted";
|
|
384
|
+
throw e;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
370
387
|
}
|
|
371
388
|
|
|
372
389
|
function isForgottenSubject(subject: SubjectId, aggregateId: string): boolean {
|