create-kumiko-app 0.349.0 → 0.351.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kumiko-app",
3
- "version": "0.349.0",
3
+ "version": "0.351.0",
4
4
  "description": "`bun create kumiko-app <name>` — scaffold a new Kumiko app with an interactive feature picker.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -27,10 +27,10 @@
27
27
  }
28
28
  },
29
29
  "dependencies": {
30
- "@cosmicdrift/kumiko-dev-server": "0.349.0",
31
- "@cosmicdrift/kumiko-framework": "0.349.0",
32
- "@cosmicdrift/kumiko-server-runtime": "0.349.0",
33
- "@cosmicdrift/kumiko-testing": "0.349.0",
30
+ "@cosmicdrift/kumiko-dev-server": "0.351.0",
31
+ "@cosmicdrift/kumiko-framework": "0.351.0",
32
+ "@cosmicdrift/kumiko-server-runtime": "0.351.0",
33
+ "@cosmicdrift/kumiko-testing": "0.351.0",
34
34
  "@inquirer/core": "^10.0.0",
35
35
  "@inquirer/prompts": "^7.4.0"
36
36
  },
@@ -43,6 +43,8 @@
43
43
  "src",
44
44
  "feature-manifest.json",
45
45
  "README.md",
46
- "LICENSE"
46
+ "LICENSE",
47
+ "!bin/__tests__/**",
48
+ "!src/**/__tests__/**"
47
49
  ]
48
50
  }
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ import { FEATURE_CONSTRUCTORS } from "./feature-constructors";
15
15
  import { loadManifest, type Manifest } from "./manifest";
16
16
  import { buildChoices, runPicker } from "./picker";
17
17
 
18
+ const USAGE = "Usage: bun create kumiko-app <name> [--yes] [--print-manifest] [-h | --help]";
19
+
18
20
  export type CliArgs = {
19
21
  /** App name (kebab-case). Required for `scaffold` mode. */
20
22
  readonly name?: string;
@@ -22,6 +24,8 @@ export type CliArgs = {
22
24
  readonly printManifest?: boolean;
23
25
  /** Skip the interactive picker, take every `recommended:true` feature. */
24
26
  readonly yes?: boolean;
27
+ /** Print usage and exit 0 without scaffolding. */
28
+ readonly help?: boolean;
25
29
  /** Override cwd for scaffoldApp (mostly for the smoke test). */
26
30
  readonly cwd?: string;
27
31
  /** Override stdout sink (default: console.log). */
@@ -30,6 +34,10 @@ export type CliArgs = {
30
34
 
31
35
  export async function runCreate(args: CliArgs): Promise<number> {
32
36
  const log = args.log ?? ((line) => console.log(line));
37
+ if (args.help) {
38
+ log(USAGE);
39
+ return 0;
40
+ }
33
41
  const manifest = loadManifest();
34
42
 
35
43
  if (args.printManifest) {
@@ -38,7 +46,7 @@ export async function runCreate(args: CliArgs): Promise<number> {
38
46
  }
39
47
 
40
48
  if (!args.name) {
41
- log("Usage: bun create kumiko-app <name> [--yes] [--print-manifest]");
49
+ log(USAGE);
42
50
  return 1;
43
51
  }
44
52
 
@@ -99,6 +107,7 @@ export function parseArgv(argv: readonly string[]): CliArgs {
99
107
  for (const arg of argv) {
100
108
  if (arg === "--print-manifest") out.printManifest = true;
101
109
  else if (arg === "--yes" || arg === "-y") out.yes = true;
110
+ else if (arg === "--help" || arg === "-h") out.help = true;
102
111
  else if (!arg.startsWith("-") && out.name === undefined) out.name = arg;
103
112
  }
104
113
  return out;
@@ -1,114 +0,0 @@
1
- // CLI smoke: `bun create kumiko-app demo --yes` scaffolds an app whose
2
- // run-config.ts imports the recommended features. Full boot is out of
3
- // scope here (needs DB/Redis) — the gate is "scaffold succeeds, files
4
- // look right".
5
-
6
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
8
- import { tmpdir } from "node:os";
9
- import { join } from "node:path";
10
- import { parseArgv, runCreate } from "../index";
11
-
12
- describe("create-kumiko-app CLI", () => {
13
- let tmp: string;
14
- let logs: string[];
15
- beforeEach(() => {
16
- tmp = mkdtempSync(join(tmpdir(), "create-kumiko-app-"));
17
- logs = [];
18
- });
19
- afterEach(() => rmSync(tmp, { recursive: true, force: true }));
20
-
21
- test("--yes scaffolds the recommended stack", async () => {
22
- const startedAt = performance.now();
23
- const code = await runCreate({
24
- name: "demo-app",
25
- yes: true,
26
- cwd: tmp,
27
- log: (line) => logs.push(line),
28
- });
29
- const scaffoldMs = performance.now() - startedAt;
30
- expect(code).toBe(0);
31
- // Onboarding regression gate: the scaffold step is the first thing a new
32
- // user waits on. Generous ceiling so slow CI runners pass — what we catch
33
- // is an order-of-magnitude blowup, not seconds. Needs an explicit test
34
- // timeout above the 30s ceiling: bun's default (5s) would fail a slow-
35
- // but-fine CI run on its own timeout before this assertion ever gets to
36
- // fire, making the "ceiling" the opposite of what the comment claims.
37
- expect(scaffoldMs, `scaffold took ${Math.round(scaffoldMs)}ms (ceiling 30s)`).toBeLessThan(
38
- 30_000,
39
- );
40
-
41
- const dest = join(tmp, "demo-app");
42
- expect(existsSync(dest)).toBe(true);
43
- expect(existsSync(join(dest, "package.json"))).toBe(true);
44
- expect(existsSync(join(dest, "bin/main.ts"))).toBe(true);
45
- expect(existsSync(join(dest, "playwright.config.ts"))).toBe(true);
46
- expect(existsSync(join(dest, "e2e/smoke.spec.ts"))).toBe(true);
47
- const pkg = JSON.parse(readFileSync(join(dest, "package.json"), "utf-8")) as {
48
- devDependencies: Record<string, string>;
49
- };
50
- expect(pkg.devDependencies["@cosmicdrift/kumiko-testing"]).toMatch(/^\^\d+\.\d+\.\d+/);
51
-
52
- const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
53
- // Picker-MVP recommended features land in run-config — except the four
54
- // composeFeatures({ includeBundled: true }) auto-mounts (config, user,
55
- // tenant, auth-email-password), which scaffold-app filters out so they
56
- // don't trigger dedupe-warn spam on every dev boot.
57
- expect(cfg).not.toContain("createAuthEmailPasswordFeature");
58
- expect(cfg).not.toContain("createUserFeature");
59
- expect(cfg).not.toContain("createTenantFeature");
60
- expect(cfg).not.toContain("createConfigFeature");
61
- expect(cfg).toContain("createDeliveryFeature");
62
- // mail-transport-smtp is opt-in (not recommended, no transitive require) — should NOT auto-mount.
63
- expect(cfg).not.toContain("mailTransportSmtpFeature");
64
- // issue-1174: user-profile (recommended) requires user-data-rights
65
- // transitively, which trips the GDPR boot-validator unless
66
- // user-data-rights-defaults (the PII export/delete hooks for `user`)
67
- // is auto-included too.
68
- expect(cfg).toContain("createUserDataRightsDefaultsFeature");
69
-
70
- // UX-polish: Next-steps points at `bun dev` (the primary dev path since
71
- // PR #583 introduced bin/dev.ts), not the CI-only `bun run boot` smoke.
72
- const out = logs.join("\n");
73
- expect(out).toContain("bun dev");
74
- expect(out).toContain("docker compose up -d");
75
- expect(out).not.toContain("bun run boot");
76
-
77
- // Setup-impact preview lands before the scaffold actually runs.
78
- expect(out).toMatch(/→ Scaffolding \d+ features? into \.\/demo-app\//);
79
- }, 35_000);
80
-
81
- test("--print-manifest emits JSON, no name needed", async () => {
82
- const code = await runCreate({
83
- printManifest: true,
84
- log: (line) => logs.push(line),
85
- });
86
- expect(code).toBe(0);
87
- const json = JSON.parse(logs.join(""));
88
- expect(Array.isArray(json)).toBe(true);
89
- expect(json.length).toBeGreaterThan(0);
90
- });
91
-
92
- test("missing name prints usage + exits 1", async () => {
93
- const code = await runCreate({ log: (line) => logs.push(line) });
94
- expect(code).toBe(1);
95
- expect(logs.join("\n")).toContain("Usage:");
96
- });
97
- });
98
-
99
- describe("parseArgv", () => {
100
- test("first positional is the app name", () => {
101
- expect(parseArgv(["my-app"]).name).toBe("my-app");
102
- });
103
-
104
- test("--yes / -y flips yes flag", () => {
105
- expect(parseArgv(["--yes"]).yes).toBe(true);
106
- expect(parseArgv(["-y"]).yes).toBe(true);
107
- });
108
-
109
- test("--print-manifest works alone (no name)", () => {
110
- const args = parseArgv(["--print-manifest"]);
111
- expect(args.printManifest).toBe(true);
112
- expect(args.name).toBeUndefined();
113
- });
114
- });
@@ -1,46 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { resolveDeps } from "../dep-resolver";
3
- import type { Manifest } from "../manifest";
4
-
5
- const MANIFEST: Manifest = {
6
- source: "test",
7
- featureCount: 5,
8
- features: [
9
- {
10
- name: "auth-email-password",
11
- description: null,
12
- requires: ["user", "tenant"],
13
- optionalRequires: [],
14
- },
15
- { name: "user", description: null, requires: [], optionalRequires: [] },
16
- { name: "tenant", description: null, requires: ["config"], optionalRequires: [] },
17
- { name: "config", description: null, requires: [], optionalRequires: [] },
18
- { name: "billing", description: null, requires: ["tenant"], optionalRequires: [] },
19
- ],
20
- };
21
-
22
- describe("resolveDeps", () => {
23
- test("transitive requires close (auth → user + tenant + config)", () => {
24
- const result = resolveDeps(["auth-email-password"], MANIFEST);
25
- expect(new Set(result.featureNames)).toEqual(
26
- new Set(["auth-email-password", "user", "tenant", "config"]),
27
- );
28
- expect(new Set(result.autoAdded)).toEqual(new Set(["user", "tenant", "config"]));
29
- });
30
-
31
- test("dedupes overlapping deps", () => {
32
- const result = resolveDeps(["auth-email-password", "billing"], MANIFEST);
33
- expect(result.featureNames.length).toBe(new Set(result.featureNames).size);
34
- expect(new Set(result.featureNames)).toContain("tenant");
35
- });
36
-
37
- test("explicit selection NOT counted as autoAdded", () => {
38
- const result = resolveDeps(["user", "auth-email-password"], MANIFEST);
39
- expect(result.autoAdded).not.toContain("user");
40
- expect(result.autoAdded).toContain("tenant");
41
- });
42
-
43
- test("unknown feature throws (manifest bug)", () => {
44
- expect(() => resolveDeps(["does-not-exist"], MANIFEST)).toThrow(/not in manifest/);
45
- });
46
- });
@@ -1,54 +0,0 @@
1
- // Guards against #1425/1: callExpression (rendered into generated
2
- // run-config.ts) and callArgs (used at runtime by instantiateScaffoldFeatures)
3
- // encode the same call twice by hand. If someone edits one without the
4
- // other, the boot test validates a different feature config than what
5
- // actually gets scaffolded. This derives the expected callExpression from
6
- // exportName + callArgs for every entry and diffs it against the literal.
7
-
8
- import { describe, expect, test } from "bun:test";
9
- import { FEATURE_CONSTRUCTORS } from "../feature-constructors";
10
-
11
- // Renders a value as hand-written TS source (unquoted object keys, quoted
12
- // string values) instead of JSON — JSON.stringify(1) and JSON.stringify("1")
13
- // both survive a whitespace+quote-strip compare as "1", which would hide a
14
- // string/number divergence between callExpression and callArgs. Rendering
15
- // like this and normalizing whitespace only keeps that divergence visible.
16
- function renderTsLiteral(value: unknown): string {
17
- if (Array.isArray(value)) return `[${value.map(renderTsLiteral).join(", ")}]`;
18
- if (value !== null && typeof value === "object") {
19
- const entries = Object.entries(value as Record<string, unknown>).map(
20
- ([key, val]) => `${key}: ${renderTsLiteral(val)}`,
21
- );
22
- return `{ ${entries.join(", ")} }`;
23
- }
24
- return JSON.stringify(value);
25
- }
26
-
27
- function expectedCallExpression(exportName: string, callArgs: readonly unknown[]): string {
28
- const args = callArgs.map(renderTsLiteral).join(", ");
29
- return `${exportName}(${args})`;
30
- }
31
-
32
- describe("FEATURE_CONSTRUCTORS callExpression/callArgs consistency", () => {
33
- for (const [name, entry] of Object.entries(FEATURE_CONSTRUCTORS)) {
34
- test(`${name}: callExpression matches exportName + callArgs`, () => {
35
- if (entry.callArgs !== undefined) {
36
- // callExpression is hand-formatted TS source; compare with
37
- // whitespace stripped only — NOT quotes, so a string arg ("1") vs.
38
- // a number arg (1) still diverges after normalize.
39
- const normalize = (s: string) => s.replace(/\s+/g, "");
40
- expect(normalize(entry.callExpression)).toBe(
41
- normalize(expectedCallExpression(entry.exportName, entry.callArgs)),
42
- );
43
- return;
44
- }
45
- // No callArgs: either `exportName()` (zero-arg factory) or bare
46
- // `exportName` (object export) — both are legal, but the export name
47
- // itself must be the prefix either way.
48
- expect(
49
- entry.callExpression === entry.exportName ||
50
- entry.callExpression === `${entry.exportName}()`,
51
- ).toBe(true);
52
- });
53
- }
54
- });
@@ -1,32 +0,0 @@
1
- // Vendored manifest.json must match the source-of-truth in
2
- // samples/apps/use-all-bundled/feature-manifest.json. The picker reads
3
- // the vendored copy at runtime; a stale copy lets the picker show old
4
- // features or miss new ones. Refresh by running `bun run gen:manifest`
5
- // inside samples/apps/use-all-bundled (writes both copies at once).
6
-
7
- import { describe, expect, test } from "bun:test";
8
- import { readFileSync } from "node:fs";
9
- import { dirname, resolve } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
-
12
- const HERE = dirname(fileURLToPath(import.meta.url));
13
- const VENDOR = resolve(HERE, "..", "..", "feature-manifest.json");
14
- const SOURCE = resolve(
15
- HERE,
16
- "..",
17
- "..",
18
- "..",
19
- "..",
20
- "samples",
21
- "apps",
22
- "use-all-bundled",
23
- "feature-manifest.json",
24
- );
25
-
26
- describe("vendored manifest", () => {
27
- test("byte-identical to samples/apps/use-all-bundled/feature-manifest.json", () => {
28
- const vendor = readFileSync(VENDOR, "utf-8");
29
- const source = readFileSync(SOURCE, "utf-8");
30
- expect(vendor).toBe(source);
31
- });
32
- });
@@ -1,35 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { FEATURE_CONSTRUCTORS } from "../feature-constructors";
3
- import { loadManifest } from "../manifest";
4
- import { buildChoices } from "../picker";
5
-
6
- describe("buildChoices", () => {
7
- const manifest = loadManifest();
8
- const choices = buildChoices(manifest);
9
-
10
- test("only emits features that have a constructor entry", () => {
11
- for (const c of choices) {
12
- expect(Object.hasOwn(FEATURE_CONSTRUCTORS, c.name)).toBe(true);
13
- }
14
- });
15
-
16
- test("every MVP feature is represented (no silent drops)", () => {
17
- const choiceNames = new Set(choices.map((c) => c.name));
18
- for (const name of Object.keys(FEATURE_CONSTRUCTORS)) {
19
- expect(choiceNames.has(name)).toBe(true);
20
- }
21
- });
22
-
23
- test("recommended features land checked by default", () => {
24
- const recommended = choices.filter((c) => c.recommended);
25
- expect(recommended.length).toBeGreaterThan(0);
26
- expect(recommended.map((c) => c.name)).toContain("auth-email-password");
27
- });
28
-
29
- test("category falls back to 'other' when uiHints absent", () => {
30
- for (const c of choices) {
31
- expect(typeof c.category).toBe("string");
32
- expect(c.category.length).toBeGreaterThan(0);
33
- }
34
- });
35
- });
@@ -1,281 +0,0 @@
1
- // issue-1174: `bun create kumiko-app <name> --yes` resolves the recommended
2
- // feature set through the manifest's `requires` graph (dep-resolver.ts), not
3
- // through hand-written app composition. That graph can pull in a feature
4
- // (here: user-data-rights, transitively via user-profile) whose boot-time
5
- // obligations (an EXT_USER_DATA hook per PII entity) no OTHER auto-included
6
- // feature satisfies — cli.test.ts's file-existence checks can't catch that,
7
- // only actually booting the resolved set through validateBoot can.
8
-
9
- import { afterAll, describe, expect, test } from "bun:test";
10
- import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
11
- import { join } from "node:path";
12
- import { frameworkCoreEnvSchema } from "@cosmicdrift/kumiko-dev-server";
13
- import { requireKmsWiring, resolveKmsWiring } from "@cosmicdrift/kumiko-framework/crypto";
14
- import {
15
- createRegistry,
16
- type FeatureDefinition,
17
- type TenantId,
18
- validateBoot as validateBootRaw,
19
- } from "@cosmicdrift/kumiko-framework/engine";
20
- import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
21
- import { withBootValidatorFixture } from "@cosmicdrift/kumiko-framework/testing";
22
- import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
23
- import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
24
- import { resolveDeps } from "../dep-resolver";
25
- import { FEATURE_CONSTRUCTORS } from "../feature-constructors";
26
- import { runCreate } from "../index";
27
- import { loadManifest } from "../manifest";
28
- import { buildChoices } from "../picker";
29
-
30
- function validateBoot(features: readonly FeatureDefinition[]): void {
31
- validateBootRaw(withBootValidatorFixture(features));
32
- }
33
-
34
- async function instantiateResolved(names: readonly string[]): Promise<FeatureDefinition[]> {
35
- const instances: FeatureDefinition[] = [];
36
- for (const name of names) {
37
- const entry = FEATURE_CONSTRUCTORS[name];
38
- if (!entry) continue; // mirrors index.ts:47-49 — auto-mounted core deps have no entry
39
- const mod = (await import(entry.importPath)) as Record<string, unknown>;
40
- const exp = mod[entry.exportName];
41
- if (typeof exp === "function") {
42
- instances.push((exp as (...args: unknown[]) => FeatureDefinition)(...(entry.callArgs ?? [])));
43
- } else {
44
- instances.push(exp as FeatureDefinition);
45
- }
46
- }
47
- return instances;
48
- }
49
-
50
- describe("--yes resolved set boots (issue-1174 regression)", () => {
51
- const manifest = loadManifest();
52
- const recommended = buildChoices(manifest)
53
- .filter((c) => c.recommended)
54
- .map((c) => c.name);
55
- const resolved = resolveDeps(recommended, manifest);
56
-
57
- test("resolves user-data-rights-defaults alongside the transitively-pulled user-data-rights", () => {
58
- expect(resolved.featureNames).toContain("user-data-rights");
59
- expect(resolved.featureNames).toContain("user-data-rights-defaults");
60
- });
61
-
62
- test("the resolved --yes feature set boots without the GDPR PII-hook-coverage error", async () => {
63
- // sessions (auto-included) pulls auth-foundation; #1570 allows zero
64
- // tokenVerifier when sessionStore is mounted — PAT stays opt-in (#1514).
65
- expect(resolved.featureNames).not.toContain("personal-access-tokens");
66
- const instances = await instantiateResolved(resolved.featureNames);
67
- const composed = composeFeatures(instances, { includeBundled: true });
68
- expect(() => validateBoot(composed)).not.toThrow();
69
- expect(() => createRegistry(composed)).not.toThrow();
70
- });
71
- });
72
-
73
- // kumiko-framework#2330: validateBoot/createRegistry above never reach
74
- // runProdApp's PII boot gate (assertPiiBootInvariants) — it's not part of
75
- // validateBoot, it runs a step later. The --yes set mounts PII-annotated
76
- // entities (user, tenant-invitation, fileRef via user-data-rights), so the
77
- // generated bin/main.ts must wire kms/allowPlaintextPii or the first
78
- // `bun run boot` throws BOOT ABORTED. This drives the SAME resolved feature
79
- // set through the actual runProdApp (KUMIKO_DRY_RUN_ENV=boot — no DB/Redis
80
- // connect) to pin both halves of the fix.
81
- describe("--yes resolved set through runProdApp's PII boot gate (issue-2330 regression)", () => {
82
- const manifest = loadManifest();
83
- const recommended = buildChoices(manifest)
84
- .filter((c) => c.recommended)
85
- .map((c) => c.name);
86
- const resolved = resolveDeps(recommended, manifest);
87
-
88
- const auth = {
89
- admin: {
90
- email: "admin@test.local",
91
- password: "change-me-on-first-deploy",
92
- displayName: "Admin",
93
- memberships: [
94
- {
95
- tenantId: "93238e36-e2e5-4e2e-8be4-00004739a98c" as TenantId,
96
- tenantKey: "test",
97
- tenantName: "test",
98
- roles: ["TenantAdmin" as const],
99
- },
100
- ],
101
- },
102
- };
103
-
104
- async function bootWithWiring(wiring: Record<string, unknown>) {
105
- const instances = await instantiateResolved(resolved.featureNames);
106
- const bootFeatures = composeFeatures(instances, { includeBundled: true });
107
- const envSchema = composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures });
108
- return runProdApp({
109
- features: instances,
110
- envSchema,
111
- autoListen: false,
112
- migrations: false,
113
- auth,
114
- envSource: {
115
- KUMIKO_DRY_RUN_ENV: "boot",
116
- JWT_SECRET: "a".repeat(32),
117
- KUMIKO_SECRETS_MASTER_KEY_V1: Buffer.alloc(32, 7).toString("base64"),
118
- DATABASE_URL: "postgres://dummy:dummy@127.0.0.1:1/dummy",
119
- REDIS_URL: "redis://127.0.0.1:1",
120
- },
121
- ...wiring,
122
- });
123
- }
124
-
125
- test("without kms/allowPlaintextPii wiring, boot aborts (pins the bug this issue reports)", async () => {
126
- await expect(bootWithWiring({})).rejects.toThrow(
127
- /BOOT ABORTED.*user.*tenant-invitation.*fileRef/s,
128
- );
129
- });
130
-
131
- test("requireKmsWiring throws the required-trio error when the trio is empty", () => {
132
- expect(() =>
133
- requireKmsWiring(
134
- {},
135
- {
136
- logPrefix: "[test]",
137
- plaintextReason: "should-not-fallback",
138
- },
139
- ),
140
- ).toThrow(
141
- "[test] PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY are required here",
142
- );
143
- });
144
-
145
- test("with resolveKmsWiring's fallback (generated bin/main.ts non-prod path), boot succeeds", async () => {
146
- // Empty object, not process.env: a dev machine with the real trio set
147
- // would otherwise flip this into the ActiveKmsWiring branch and the test
148
- // would stop exercising the plaintext fallback it's meant to pin.
149
- const kmsWiring = resolveKmsWiring(
150
- {},
151
- {
152
- logPrefix: "[test]",
153
- plaintextReason: "no PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY set",
154
- },
155
- );
156
- expect(kmsWiring).toHaveProperty("allowPlaintextPii");
157
- const handle = await bootWithWiring(kmsWiring);
158
- expect(handle).toBeDefined();
159
- });
160
-
161
- test("generated .env.example's unset trio (empty string, not absent) still resolves to the plaintext fallback", async () => {
162
- // dotenv turns `PLATFORM_KEK=` into process.env.PLATFORM_KEK === "" — the
163
- // actual value a user gets from `cp .env.example .env` without filling it
164
- // in. resolveKmsWiring reads raw env (not the zod-validated envSchema),
165
- // and Boolean("") === Boolean(undefined), so this must behave identically
166
- // to the {} case above.
167
- const kmsWiring = resolveKmsWiring({
168
- PLATFORM_KEK: "",
169
- SUBJECT_KEYS_DATABASE_URL: "",
170
- KUMIKO_BLIND_INDEX_KEY: "",
171
- });
172
- expect(kmsWiring).toHaveProperty("allowPlaintextPii");
173
- const handle = await bootWithWiring(kmsWiring);
174
- expect(handle).toBeDefined();
175
- });
176
- });
177
-
178
- // The suite above rebuilds the wiring in-process and pins that the pieces
179
- // are individually correct — it never imports the file scaffoldApp actually
180
- // writes, so a typo in bin/main.ts's import specifier or a `...kmsWiring`
181
- // spread landing in the wrong object literal would stay green. This drives
182
- // the real generated bin/main.ts as a subprocess instead. Fixtures live
183
- // under __tests__/.tmp-fixtures/ (gitignored, same trick as
184
- // dev-server/src/codegen/__tests__/watch.test.ts) so Bun's node_modules
185
- // walk-up from bin/main.ts still finds the repo's hoisted @cosmicdrift/*
186
- // symlinks — a system tmpdir sits outside the workspace and can't resolve
187
- // them (see walkthrough.integration.test.ts's comment on the same issue).
188
- describe("generated bin/main.ts actually boots (issue-2330 regression, real subprocess)", () => {
189
- const FIXTURE_ROOT = join(import.meta.dir, ".tmp-fixtures");
190
- const createdDirs: string[] = [];
191
-
192
- afterAll(() => {
193
- for (const d of createdDirs) {
194
- try {
195
- rmSync(d, { recursive: true, force: true });
196
- } catch {
197
- // ignore — best-effort
198
- }
199
- }
200
- });
201
-
202
- test("KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts exits 0 against the file scaffoldApp wrote", async () => {
203
- mkdirSync(FIXTURE_ROOT, { recursive: true });
204
- const cwd = mkdtempSync(join(FIXTURE_ROOT, "boot-"));
205
- createdDirs.push(cwd);
206
-
207
- const exitCode = await runCreate({ name: "boot-fixture", yes: true, cwd, log: () => {} });
208
- expect(exitCode).toBe(0);
209
-
210
- const appDir = join(cwd, "boot-fixture");
211
- const proc = Bun.spawn({
212
- cmd: ["bun", "bin/main.ts"],
213
- cwd: appDir,
214
- env: {
215
- ...process.env,
216
- KUMIKO_DRY_RUN_ENV: "boot",
217
- JWT_SECRET: "a".repeat(32),
218
- KUMIKO_SECRETS_MASTER_KEY_V1: Buffer.alloc(32, 7).toString("base64"),
219
- DATABASE_URL: "postgres://dummy:dummy@127.0.0.1:1/dummy",
220
- REDIS_URL: "redis://127.0.0.1:1",
221
- // Pinned empty, not inherited from process.env: a dev machine with
222
- // the real trio set would otherwise flip resolveKmsWiring into the
223
- // ActiveKmsWiring branch (and its KMS health check) instead of the
224
- // plaintext fallback this test exercises.
225
- PLATFORM_KEK: "",
226
- SUBJECT_KEYS_DATABASE_URL: "",
227
- KUMIKO_BLIND_INDEX_KEY: "",
228
- },
229
- stdout: "pipe",
230
- stderr: "pipe",
231
- });
232
- const [stdout, stderr, code] = await Promise.all([
233
- new Response(proc.stdout).text(),
234
- new Response(proc.stderr).text(),
235
- proc.exited,
236
- ]);
237
-
238
- expect(code, `stdout:\n${stdout}\nstderr:\n${stderr}`).toBe(0);
239
- expect(`${stdout}${stderr}`).not.toContain("BOOT ABORTED");
240
- expect(stdout).toContain("boot validation OK");
241
- // The scaffold no longer warns itself; the plaintext path must still be loud via the boot gate.
242
- expect(`${stdout}${stderr}`).toContain("stored in PLAINTEXT");
243
- }, 30_000);
244
-
245
- test("NODE_ENV=production with an empty KMS trio aborts the generated bin/main.ts", async () => {
246
- mkdirSync(FIXTURE_ROOT, { recursive: true });
247
- const cwd = mkdtempSync(join(FIXTURE_ROOT, "boot-prod-"));
248
- createdDirs.push(cwd);
249
-
250
- const exitCode = await runCreate({ name: "boot-prod-fixture", yes: true, cwd, log: () => {} });
251
- expect(exitCode).toBe(0);
252
-
253
- const proc = Bun.spawn({
254
- cmd: ["bun", "bin/main.ts"],
255
- cwd: join(cwd, "boot-prod-fixture"),
256
- env: {
257
- ...process.env,
258
- NODE_ENV: "production",
259
- KUMIKO_DRY_RUN_ENV: "boot",
260
- JWT_SECRET: "a".repeat(32),
261
- KUMIKO_SECRETS_MASTER_KEY_V1: Buffer.alloc(32, 7).toString("base64"),
262
- DATABASE_URL: "postgres://dummy:dummy@127.0.0.1:1/dummy",
263
- REDIS_URL: "redis://127.0.0.1:1",
264
- PLATFORM_KEK: "",
265
- SUBJECT_KEYS_DATABASE_URL: "",
266
- KUMIKO_BLIND_INDEX_KEY: "",
267
- },
268
- stdout: "pipe",
269
- stderr: "pipe",
270
- });
271
- const [stdout, stderr, code] = await Promise.all([
272
- new Response(proc.stdout).text(),
273
- new Response(proc.stderr).text(),
274
- proc.exited,
275
- ]);
276
-
277
- expect(code, `stdout:\n${stdout}\nstderr:\n${stderr}`).not.toBe(0);
278
- expect(`${stdout}${stderr}`).toContain("PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL");
279
- expect(`${stdout}${stderr}`).toContain("are required here");
280
- }, 30_000);
281
- });