@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.213.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.213.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.213.0",
|
|
210
214
|
"bun-types": "^1.3.13",
|
|
211
215
|
"pino-pretty": "^13.1.3"
|
|
212
216
|
},
|
|
@@ -69,6 +69,51 @@ describe("runSchemaCli — no-DB paths", () => {
|
|
|
69
69
|
expect(cap.err.join("\n")).not.toContain("kumiko-schema");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("generate --help prints usage, exits 0, writes no migration (framework#2191)", async () => {
|
|
73
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
74
|
+
const cap = captureOut();
|
|
75
|
+
const code = await runSchemaCli(["generate", "--help"], appCwd, cap.out);
|
|
76
|
+
expect(code).toBe(0);
|
|
77
|
+
expect(cap.log.join("\n")).toContain("Usage: schema generate <name>");
|
|
78
|
+
expect(cap.err).toHaveLength(0);
|
|
79
|
+
expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("generate -h prints usage, exits 0, writes no migration", async () => {
|
|
83
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
84
|
+
const cap = captureOut();
|
|
85
|
+
const code = await runSchemaCli(["generate", "-h"], appCwd, cap.out);
|
|
86
|
+
expect(code).toBe(0);
|
|
87
|
+
expect(cap.log.join("\n")).toContain("Usage: schema generate <name>");
|
|
88
|
+
expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("generate name starting with -- is rejected, exits 1, writes no migration", async () => {
|
|
92
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
93
|
+
const cap = captureOut();
|
|
94
|
+
const code = await runSchemaCli(["generate", "--evil"], appCwd, cap.out);
|
|
95
|
+
expect(code).toBe(1);
|
|
96
|
+
expect(cap.err.join("\n")).toContain('Invalid migration name "--evil"');
|
|
97
|
+
expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("generate name with path separators is rejected (path traversal)", async () => {
|
|
101
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
102
|
+
const cap = captureOut();
|
|
103
|
+
const code = await runSchemaCli(["generate", "../../evil"], appCwd, cap.out);
|
|
104
|
+
expect(code).toBe(1);
|
|
105
|
+
expect(cap.err.join("\n")).toContain("Invalid migration name");
|
|
106
|
+
expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("generate with a hyphenated name still works", async () => {
|
|
110
|
+
writeSchemaFile(appCwd, "tbl_a");
|
|
111
|
+
const cap = captureOut();
|
|
112
|
+
const code = await runSchemaCli(["generate", "add-user-table"], appCwd, cap.out);
|
|
113
|
+
expect(code).toBe(0);
|
|
114
|
+
expect(existsSync(join(appCwd, "kumiko/migrations/0001_add-user-table.sql"))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
72
117
|
test("generate with missing schema.ts exits 1", async () => {
|
|
73
118
|
const cap = captureOut();
|
|
74
119
|
const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
|
|
@@ -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,96 @@
|
|
|
1
|
+
// Regression guard for the request-locale wiring: X-Locale header (or, when
|
|
2
|
+
// absent, Accept-Language) must reach ctx.locale on real HTTP calls — the
|
|
3
|
+
// AsyncLocalStorage plumbing in request-id-middleware.ts / dispatch-shared.ts
|
|
4
|
+
// can't be exercised via createTestDispatcher, which skips the HTTP layer
|
|
5
|
+
// entirely.
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import {
|
|
9
|
+
createTestUser,
|
|
10
|
+
setupTestStack,
|
|
11
|
+
type TestStack,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { LOCALE_HEADER_NAME } from "../api-constants";
|
|
15
|
+
|
|
16
|
+
const localeProbe = defineFeature("locale-probe", (r) => {
|
|
17
|
+
r.writeHandler(
|
|
18
|
+
"read-locale",
|
|
19
|
+
z.object({}),
|
|
20
|
+
async (_event, ctx) => ({ isSuccess: true, data: { locale: ctx.locale } }),
|
|
21
|
+
{ access: { openToAll: true } },
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function readLocale(stack: TestStack, headers: Record<string, string>): Promise<string> {
|
|
26
|
+
const res = await stack.http.writeWithHeaders(
|
|
27
|
+
"locale-probe:write:read-locale",
|
|
28
|
+
{},
|
|
29
|
+
createTestUser({ id: 1 }),
|
|
30
|
+
headers,
|
|
31
|
+
);
|
|
32
|
+
const body = (await res.json()) as { data: { locale: string } };
|
|
33
|
+
return body.data.locale;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("ctx.locale resolution over real HTTP", () => {
|
|
37
|
+
let stack: TestStack;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
stack = await setupTestStack({ features: [localeProbe] });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await stack.cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("X-Locale header wins", async () => {
|
|
48
|
+
const locale = await readLocale(stack, { [LOCALE_HEADER_NAME]: "de-AT" });
|
|
49
|
+
expect(locale).toBe("de-AT");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("falls back to Accept-Language when X-Locale is absent", async () => {
|
|
53
|
+
const locale = await readLocale(stack, { "accept-language": "fr-FR,fr;q=0.9,en;q=0.8" });
|
|
54
|
+
expect(locale).toBe("fr-FR");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("Accept-Language with only invalid tags falls back to the boot default", async () => {
|
|
58
|
+
const locale = await readLocale(stack, { "accept-language": "*, ;q=0.1" });
|
|
59
|
+
expect(locale).toBe("en");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a malformed/manipulated X-Locale header is rejected, not passed through", async () => {
|
|
63
|
+
const locale = await readLocale(stack, {
|
|
64
|
+
[LOCALE_HEADER_NAME]: "<script>alert(1)</script>",
|
|
65
|
+
});
|
|
66
|
+
expect(locale).toBe("en");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("an invalid X-Locale header falls through to a valid Accept-Language instead of the default", async () => {
|
|
70
|
+
const locale = await readLocale(stack, {
|
|
71
|
+
[LOCALE_HEADER_NAME]: "not-a-real-locale-tag-way-too-long-to-be-valid",
|
|
72
|
+
"accept-language": "es-ES",
|
|
73
|
+
});
|
|
74
|
+
expect(locale).toBe("es-ES");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("ctx.locale falls back to the app's boot-configured defaultLocale", () => {
|
|
79
|
+
let stack: TestStack;
|
|
80
|
+
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
stack = await setupTestStack({
|
|
83
|
+
features: [localeProbe],
|
|
84
|
+
extraContext: { defaultLocale: "ja" },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
await stack.cleanup();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("no header signal at all uses the app's defaultLocale, not the hardcoded default", async () => {
|
|
93
|
+
const locale = await readLocale(stack, {});
|
|
94
|
+
expect(locale).toBe("ja");
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -118,4 +118,11 @@ export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
|
|
|
118
118
|
export const TENANT_HEADER_NAME = "X-Tenant";
|
|
119
119
|
export const TENANT_COOKIE_NAME = "kumiko_tenant";
|
|
120
120
|
|
|
121
|
+
// Client-declared active UI locale (BCP-47). Read once per request in
|
|
122
|
+
// request-id-middleware.ts, before auth, so it reaches public routes
|
|
123
|
+
// (e.g. signup-request) too. Falls back to Accept-Language, then the app's
|
|
124
|
+
// boot-configured defaultLocale, when absent or malformed — see
|
|
125
|
+
// request-locale.ts and dispatch-shared.ts's ctx.locale resolution.
|
|
126
|
+
export const LOCALE_HEADER_NAME = "X-Locale";
|
|
127
|
+
|
|
121
128
|
export type Route = (typeof Routes)[keyof typeof Routes];
|
|
@@ -33,6 +33,11 @@ export type RequestContextData = {
|
|
|
33
33
|
// Raw User-Agent header — audit trails (download tokens, GDPR export
|
|
34
34
|
// access) want it alongside `ip`. Undefined for non-HTTP entry points.
|
|
35
35
|
readonly userAgent?: string;
|
|
36
|
+
// Client-declared active UI locale (BCP-47), resolved once from
|
|
37
|
+
// X-Locale / Accept-Language by requestIdMiddleware — see
|
|
38
|
+
// request-locale.ts. Undefined when neither header carried a valid tag;
|
|
39
|
+
// callers fall back further (dispatch-shared.ts's ctx.locale chain).
|
|
40
|
+
readonly locale?: string;
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
const storage = new AsyncLocalStorage<RequestContextData>();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Context, Next } from "hono";
|
|
2
|
+
import { resolveHeaderLocale } from "../i18n/request-locale";
|
|
3
|
+
import { LOCALE_HEADER_NAME } from "./api-constants";
|
|
2
4
|
import { type RequestContextData, requestContext } from "./request-context";
|
|
3
5
|
|
|
4
6
|
const REQUEST_ID_HEADER = "X-Request-ID";
|
|
@@ -45,6 +47,13 @@ export function buildRequestContextData(c: Context): RequestContextData {
|
|
|
45
47
|
const xff = c.req.header("x-forwarded-for");
|
|
46
48
|
const ip = xff?.split(",")[0]?.trim();
|
|
47
49
|
const userAgent = c.req.header("user-agent");
|
|
50
|
+
// Runs before auth-middleware, so this reaches public routes too (e.g.
|
|
51
|
+
// signup-request) — that's the whole point: the active UI locale must
|
|
52
|
+
// survive to anonymous callers, not just authenticated ones.
|
|
53
|
+
const locale = resolveHeaderLocale({
|
|
54
|
+
headerLocale: c.req.header(LOCALE_HEADER_NAME),
|
|
55
|
+
acceptLanguage: c.req.header("accept-language"),
|
|
56
|
+
});
|
|
48
57
|
|
|
49
58
|
return {
|
|
50
59
|
requestId,
|
|
@@ -52,6 +61,7 @@ export function buildRequestContextData(c: Context): RequestContextData {
|
|
|
52
61
|
...(signal ? { signal } : {}),
|
|
53
62
|
...(ip && ip.length > 0 ? { ip } : {}),
|
|
54
63
|
...(userAgent !== undefined ? { userAgent } : {}),
|
|
64
|
+
...(locale !== undefined ? { locale } : {}),
|
|
55
65
|
};
|
|
56
66
|
}
|
|
57
67
|
|
|
@@ -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
|
+
}
|