@cosmicdrift/kumiko-framework 0.7.0 → 0.8.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @cosmicdrift/kumiko-framework
2
2
 
3
+ ## 0.8.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 4b5f91e: Expose `./package.json` via subpath export so downstream tooling (publish/materialize, app-templates) can derive the installed framework version at runtime without manual version-pin drift.
8
+
9
+ ## 0.8.0
10
+
11
+ ### Minor Changes
12
+
13
+ - f34af9a: Add framework-core env-schema (Sprint 9.2, Migration Phase 1).
14
+
15
+ **New API:**
16
+
17
+ - `frameworkCoreEnvSchema` exported from `@cosmicdrift/kumiko-dev-server` — Zod-object covering the vars read by framework-core: `PORT` (default `"3000"`), `DATABASE_URL`, `REDIS_URL`, `KUMIKO_INSTANCE_ID`, `KUMIKO_SKIP_ES_OPS`. `DATABASE_URL` + `REDIS_URL` carry `.meta({ kumiko: { pulumi: { secret: true } } })` so `KUMIKO_DRY_RUN_ENV=pulumi` emits `--secret` flags. Plus `FrameworkCoreEnv` type via `z.infer`. `NODE_ENV` is excluded: build-prod-bundle inlines it as a literal at build-time (esbuild define), so runtime env-validation can't observe it.
18
+ - `composeEnvSchema({ core, features, extend, optionalFeatures })` accepts a new `core?` option. Keys from `core` are tagged with source `"framework-core"` in the resulting sources map and in `KumikoBootError.format()` output. Conflict detection runs across core/features/extend — a feature or `extend` block that re-declares a core var throws `KumikoBootError` at compose-time.
19
+
20
+ **Why:** Phase 1 of the Sprint 9 env-schema migration (`kumiko-studio/docs/plans/sprint-9-env-schemas.md`). Apps wire `composeEnvSchema({ core: frameworkCoreEnvSchema, features, extend })` into `runProdApp` to get aggregated boot-validation for the vars that framework-core reads. `KUMIKO_DRY_RUN_ENV=pulumi|k8s` then enumerates them with source attribution per row — operators see "(framework-core)" next to `DATABASE_URL` rather than guessing whether the framework or the app is the consumer.
21
+
22
+ **Backward-compat:** Purely additive. `runProdApp`'s existing `requireEnv("DATABASE_URL")` / `process.env["KUMIKO_INSTANCE_ID"]` reads remain unchanged. Apps that don't pass `envSchema` behave exactly as before.
23
+
24
+ **Feature-specific vars (Phase 2):** `JWT_SECRET` (auth-email-password), `KUMIKO_SECRETS_MASTER_KEY_*` (secrets), `SMTP_*` (channel-email-smtp), `STRIPE_*` / `MOLLIE_*` (subscription-\*) stay scoped to their owning feature's `r.envSchema()` and are NOT in `frameworkCoreEnvSchema`.
25
+
26
+ - dff4123: Add Zod-based env-schema declarations and boot-time validation (Sprint 9.1).
27
+
28
+ **New API:**
29
+
30
+ - `r.envSchema(z.object({...}))` — declare per-feature env-vars at registration time.
31
+ - `@cosmicdrift/kumiko-framework/env`: `composeEnvSchema({features, extend, optionalFeatures})` merges feature schemas into one app-wide schema, returning `{schema, sources}`. `parseEnv(schema, env, {sources, pulumiPrefix})` validates `process.env` and throws `KumikoBootError` listing ALL problems at once (aggregated, not first-fail).
32
+ - `@cosmicdrift/kumiko-framework/env/dry-run`: `renderDryRun(composed, mode, opts)` for `human|json|pulumi|k8s` introspection of the required env-vars without booting.
33
+ - `runProdApp({envSchema, pulumiPrefix, bootErrorReporter, envSource})` runs schema validation before any DB/Redis connection. `KUMIKO_DRY_RUN_ENV=1|human|json|pulumi|k8s` prints the inventory and exits.
34
+ - Per-var metadata via Zod's `.meta({ kumiko: { pulumi: { name, generator, secret } } })` for deploy-time tooling overrides.
35
+
36
+ **Backward-compat:** Apps without `envSchema` keep working — existing `requireEnv("DATABASE_URL")` calls in `runProdApp` are untouched. Sprint-9.2-9.5 migrates framework + bundled-features + apps to schema-only env handling.
37
+
38
+ **Why:** 2026-05-21 Studio deploy stacked 7 hacks chasing missing env-vars (10+ pipeline-fail iterations, ended in rollback). Schema-first boot validation surfaces ALL misconfigs upfront with `pulumi config set …` suggestions, replacing the discover-by-failing loop with a single dry-run + secrets-bootstrap pass.
39
+
3
40
  ## 0.7.0
4
41
 
5
42
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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>",
@@ -44,6 +44,14 @@
44
44
  "types": "./src/engine/types/index.ts",
45
45
  "default": "./src/engine/types/index.ts"
46
46
  },
47
+ "./env": {
48
+ "types": "./src/env/index.ts",
49
+ "default": "./src/env/index.ts"
50
+ },
51
+ "./env/dry-run": {
52
+ "types": "./src/env/dry-run.ts",
53
+ "default": "./src/env/dry-run.ts"
54
+ },
47
55
  "./engine/codemod": {
48
56
  "types": "./src/engine/codemod/index.ts",
49
57
  "default": "./src/engine/codemod/index.ts"
@@ -143,7 +151,8 @@
143
151
  "./utils": {
144
152
  "types": "./src/utils/index.ts",
145
153
  "default": "./src/utils/index.ts"
146
- }
154
+ },
155
+ "./package.json": "./package.json"
147
156
  },
148
157
  "dependencies": {
149
158
  "bullmq": "^5.76.7",
@@ -163,7 +172,7 @@
163
172
  "zod": "^4.4.3"
164
173
  },
165
174
  "devDependencies": {
166
- "@cosmicdrift/kumiko-dispatcher-live": "0.7.0",
175
+ "@cosmicdrift/kumiko-dispatcher-live": "0.8.1",
167
176
  "@types/uuid": "^11.0.0",
168
177
  "bun-types": "^1.3.13",
169
178
  "drizzle-kit": "^0.31.10",
@@ -155,6 +155,9 @@ export function defineFeature<const TName extends string, TExports = undefined>(
155
155
  // closure-let-var nicht mit der `treeActions(...)` registrar-Methode.
156
156
  let treeActions: Readonly<Record<string, TreeActionDef>> | undefined;
157
157
  let treeProvider: TreeChildrenSubscribe | undefined;
158
+ // Optional Zod-schema for env-vars this feature reads at runtime,
159
+ // declared via r.envSchema(). At-most-one per feature.
160
+ let envSchema: z.ZodObject<z.ZodRawShape> | undefined;
158
161
 
159
162
  // Map handler name to entity via colon convention.
160
163
  // "task:create" → entity "task". No colon → standalone handler, no mapping.
@@ -573,6 +576,15 @@ export function defineFeature<const TName extends string, TExports = undefined>(
573
576
  metrics[shortName] = { shortName, ...options };
574
577
  },
575
578
 
579
+ envSchema(schema: z.ZodObject<z.ZodRawShape>): void {
580
+ if (envSchema !== undefined) {
581
+ throw new Error(
582
+ `[Feature ${name}] r.envSchema() called twice — declare one composed Zod-object per feature.`,
583
+ );
584
+ }
585
+ envSchema = schema;
586
+ },
587
+
576
588
  secret(shortName: string, options: SecretOptions): SecretKeyHandle {
577
589
  if (secretKeys[shortName]) {
578
590
  throw new Error(
@@ -874,5 +886,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
874
886
  rawTables,
875
887
  ...(treeActions !== undefined && { treeActions }),
876
888
  ...(treeProvider !== undefined && { treeProvider }),
889
+ ...(envSchema !== undefined && { envSchema }),
877
890
  };
878
891
  }
@@ -50,6 +50,7 @@ export {
50
50
  readScreenStatic,
51
51
  } from "./round4";
52
52
  export {
53
+ extractEnvSchema,
53
54
  extractExposesApi,
54
55
  extractExtendsRegistrar,
55
56
  extractUsesApi,
@@ -1,9 +1,33 @@
1
1
  import type { CallExpression, SourceFile } from "ts-morph";
2
2
  import { SyntaxKind } from "ts-morph";
3
- import type { ExposesApiPattern, ExtendsRegistrarPattern, UsesApiPattern } from "../patterns";
3
+ import type {
4
+ EnvSchemaPattern,
5
+ ExposesApiPattern,
6
+ ExtendsRegistrarPattern,
7
+ UsesApiPattern,
8
+ } from "../patterns";
4
9
  import { sourceLocationFromNode } from "../source-location";
5
10
  import { type ExtractOutput, fail, ok } from "./shared";
6
11
 
12
+ export function extractEnvSchema(
13
+ call: CallExpression,
14
+ sourceFile: SourceFile,
15
+ ): ExtractOutput<EnvSchemaPattern> {
16
+ const arg = call.getArguments()[0];
17
+ if (!arg) {
18
+ return fail(
19
+ "envSchema",
20
+ sourceLocationFromNode(call, sourceFile),
21
+ "expected a single Zod-object schema argument (e.g. z.object({...}))",
22
+ );
23
+ }
24
+ return ok({
25
+ kind: "envSchema",
26
+ source: sourceLocationFromNode(call, sourceFile),
27
+ schemaBody: sourceLocationFromNode(arg, sourceFile),
28
+ });
29
+ }
30
+
7
31
  export function extractExtendsRegistrar(
8
32
  call: CallExpression,
9
33
  sourceFile: SourceFile,
@@ -32,6 +32,7 @@ import {
32
32
  extractDefineEvent,
33
33
  extractEntity,
34
34
  extractEntityHook,
35
+ extractEnvSchema,
35
36
  extractEventMigration,
36
37
  extractExposesApi,
37
38
  extractExtendsRegistrar,
@@ -359,6 +360,9 @@ function dispatchExtractor(
359
360
  return extractTreeActions(call, sourceFile);
360
361
  case "tree":
361
362
  return extractTree(call, sourceFile);
363
+ // Round 7 — env-schema contract (opaque, Zod-expression argument)
364
+ case "envSchema":
365
+ return extractEnvSchema(call, sourceFile);
362
366
  // Unknown method — UnknownPattern signal so Designer/AI surface it
363
367
  // as "custom call" without losing the source location.
364
368
  default:
@@ -387,6 +387,15 @@ export type ExposesApiPattern = {
387
387
  // API exists and needs its own pattern type here.
388
388
  // =============================================================================
389
389
 
390
+ // r.envSchema(z.object({...})) — the env-vars contract for a feature.
391
+ // Argument is a Zod-expression (computed); we keep the source-location of
392
+ // the schema body so Designer / AI render the raw TS code (opaque).
393
+ export type EnvSchemaPattern = {
394
+ readonly kind: "envSchema";
395
+ readonly source: SourceLocation;
396
+ readonly schemaBody: SourceLocation;
397
+ };
398
+
390
399
  export type UnknownPattern = {
391
400
  readonly kind: "unknown";
392
401
  readonly source: SourceLocation;
@@ -435,6 +444,7 @@ export type FeaturePattern =
435
444
  | EventMigrationPattern
436
445
  | ExtendsRegistrarPattern
437
446
  | TreePattern
447
+ | EnvSchemaPattern
438
448
  // Catch-all
439
449
  | UnknownPattern;
440
450
 
@@ -492,6 +502,7 @@ export function getEditability(pattern: FeaturePattern): Editability {
492
502
  case "authClaims":
493
503
  case "extendsRegistrar":
494
504
  case "tree":
505
+ case "envSchema":
495
506
  case "unknown":
496
507
  return "opaque";
497
508
  default: {
@@ -23,6 +23,7 @@ import type {
23
23
  DefineEventPattern,
24
24
  EntityHookPattern,
25
25
  EntityPattern,
26
+ EnvSchemaPattern,
26
27
  EventMigrationPattern,
27
28
  ExposesApiPattern,
28
29
  ExtendsRegistrarPattern,
@@ -134,6 +135,8 @@ export function renderPattern(pattern: FeaturePattern): string {
134
135
  return renderTreeActions(pattern);
135
136
  case "tree":
136
137
  return renderTree(pattern);
138
+ case "envSchema":
139
+ return renderEnvSchema(pattern);
137
140
  case "unknown":
138
141
  return renderUnknown(pattern);
139
142
  default: {
@@ -495,6 +498,10 @@ function renderExtendsRegistrar(p: ExtendsRegistrarPattern): string {
495
498
  return `r.extendsRegistrar(${JSON.stringify(p.extensionName)}, ${p.defBody.raw});`;
496
499
  }
497
500
 
501
+ function renderEnvSchema(p: EnvSchemaPattern): string {
502
+ return `r.envSchema(${p.schemaBody.raw});`;
503
+ }
504
+
498
505
  function renderUsesApi(p: UsesApiPattern): string {
499
506
  return `r.usesApi(${JSON.stringify(p.apiName)});`;
500
507
  }
@@ -59,6 +59,7 @@ const ALL_KINDS: readonly FeaturePatternKind[] = [
59
59
  "exposesApi",
60
60
  "treeActions",
61
61
  "tree",
62
+ "envSchema",
62
63
  "unknown",
63
64
  ];
64
65
 
@@ -346,6 +347,8 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
346
347
  return { kind, source: PLACEHOLDER_LOC, definitions: {} };
347
348
  case "tree":
348
349
  return { kind, source: PLACEHOLDER_LOC, providerBody: PLACEHOLDER_BODY_LOC };
350
+ case "envSchema":
351
+ return { kind, source: PLACEHOLDER_LOC, schemaBody: PLACEHOLDER_BODY_LOC };
349
352
  case "unknown":
350
353
  return { kind, source: PLACEHOLDER_LOC, methodName: "x" };
351
354
  case "usesApi":
@@ -1092,6 +1092,24 @@ const treeSchema: PatternFormSchema = {
1092
1092
  ],
1093
1093
  };
1094
1094
 
1095
+ const envSchemaSchema: PatternFormSchema = {
1096
+ kind: "envSchema",
1097
+ label: { en: "Env schema", de: "Env-Schema" },
1098
+ summary: {
1099
+ en: "Zod-object declaring this feature's required env-vars. Apps merge it via composeEnvSchema for boot-validation.",
1100
+ },
1101
+ category: "advanced",
1102
+ editability: "opaque",
1103
+ fields: [
1104
+ {
1105
+ path: "schemaBody",
1106
+ label: { en: "Schema", de: "Schema" },
1107
+ input: "json-readonly",
1108
+ readOnly: true,
1109
+ },
1110
+ ],
1111
+ };
1112
+
1095
1113
  const unknownSchema: PatternFormSchema = {
1096
1114
  kind: "unknown",
1097
1115
  label: { en: "Unknown call", de: "Unbekannter Call" },
@@ -1153,6 +1171,7 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
1153
1171
  exposesApi: exposesApiSchema,
1154
1172
  treeActions: treeActionsSchema,
1155
1173
  tree: treeSchema,
1174
+ envSchema: envSchemaSchema,
1156
1175
  unknown: unknownSchema,
1157
1176
  } satisfies Readonly<Record<FeaturePatternKind, PatternFormSchema>>;
1158
1177
 
@@ -260,6 +260,12 @@ export type FeatureDefinition = {
260
260
  // system. Keyed by feature-local short name. The registry attaches
261
261
  // featureName on aggregation, lifting RawTableEntry → RawTableDef.
262
262
  readonly rawTables: Readonly<Record<string, RawTableEntry>>;
263
+ // Optional Zod-schema for env-vars this feature reads at runtime.
264
+ // Declared via `r.envSchema(z.object({...}))`. `composeEnvSchema` reads
265
+ // this to build one app-wide schema for boot-validation + dry-run
266
+ // rendering. Absence means the feature reads no env-vars (or hasn't
267
+ // been migrated yet — Sprint-9 migration is add-only per phase).
268
+ readonly envSchema?: z.ZodObject<z.ZodRawShape>;
263
269
  };
264
270
 
265
271
  // --- Feature Registrar (the "r" object in defineFeature) ---
@@ -574,6 +580,19 @@ export type FeatureRegistrar<TFeature extends string = string> = {
574
580
  actions: TActions,
575
581
  ): TreeActionsHandle<TFeature, TActions>;
576
582
 
583
+ // Declare the Zod-schema for env-vars this feature reads at runtime.
584
+ // At-most-one call per feature. composeEnvSchema reads it across all
585
+ // features to build one app-wide schema, which runProdApp parses
586
+ // process.env against at boot. App-Authors can also call
587
+ // `KUMIKO_DRY_RUN_ENV=human|json|pulumi|k8s` to introspect the
588
+ // required env-vars without booting.
589
+ //
590
+ // Convention: keys are SHOUTING_SNAKE_CASE env-var names. Per-var
591
+ // metadata (Pulumi-config-key override, openssl-generator suggestion,
592
+ // k8s-secret hints) goes into `.meta({ kumiko: { pulumi: {...} } })`
593
+ // — see framework/env/index.ts for the meta-shape.
594
+ envSchema(schema: z.ZodObject<z.ZodRawShape>): void;
595
+
577
596
  // Register the tree-provider for this feature — the Subscribe-Function
578
597
  // that emits the top-level Tree-Knoten when the Visual-Workspace
579
598
  // (navigation: "tree") mounts. At-most-one call per feature.
@@ -0,0 +1,283 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../engine/define-feature";
4
+ import { camelCase, composeEnvSchema, KumikoBootError, parseEnv, pulumiConfigKey } from "../index";
5
+
6
+ describe("composeEnvSchema", () => {
7
+ it("merges per-feature schemas and tags sources", () => {
8
+ const secretsFeature = defineFeature("secrets", (r) => {
9
+ r.envSchema(
10
+ z.object({
11
+ KUMIKO_SECRETS_MASTER_KEY_V1: z.string().describe("AES-256 KEK"),
12
+ }),
13
+ );
14
+ });
15
+ const authFeature = defineFeature("auth", (r) => {
16
+ r.envSchema(
17
+ z.object({
18
+ JWT_SECRET: z.string().min(32).describe("Session JWT signing key"),
19
+ }),
20
+ );
21
+ });
22
+
23
+ const { schema, sources } = composeEnvSchema({
24
+ features: [secretsFeature, authFeature],
25
+ extend: z.object({
26
+ STUDIO_ADMIN_EMAIL: z.email(),
27
+ }),
28
+ });
29
+
30
+ expect(Object.keys(schema.shape).sort()).toEqual([
31
+ "JWT_SECRET",
32
+ "KUMIKO_SECRETS_MASTER_KEY_V1",
33
+ "STUDIO_ADMIN_EMAIL",
34
+ ]);
35
+ expect(sources).toEqual({
36
+ JWT_SECRET: "auth",
37
+ KUMIKO_SECRETS_MASTER_KEY_V1: "secrets",
38
+ STUDIO_ADMIN_EMAIL: "app",
39
+ });
40
+ });
41
+
42
+ it("detects feature/feature env-var conflicts", () => {
43
+ const a = defineFeature("feat-a", (r) => {
44
+ r.envSchema(z.object({ JWT_SECRET: z.string() }));
45
+ });
46
+ const b = defineFeature("feat-b", (r) => {
47
+ r.envSchema(z.object({ JWT_SECRET: z.string() }));
48
+ });
49
+
50
+ expect(() => composeEnvSchema({ features: [a, b] })).toThrow(KumikoBootError);
51
+ });
52
+
53
+ it("detects feature/app env-var conflicts", () => {
54
+ const a = defineFeature("feat-a", (r) => {
55
+ r.envSchema(z.object({ DATABASE_URL: z.string() }));
56
+ });
57
+ expect(() =>
58
+ composeEnvSchema({
59
+ features: [a],
60
+ extend: z.object({ DATABASE_URL: z.string() }),
61
+ }),
62
+ ).toThrow(KumikoBootError);
63
+ });
64
+
65
+ it("wraps optionalFeatures' vars as .optional()", () => {
66
+ const smtp = defineFeature("channel-email-smtp", (r) => {
67
+ r.envSchema(z.object({ SMTP_HOST: z.string() }));
68
+ });
69
+ const { schema } = composeEnvSchema({
70
+ features: [smtp],
71
+ optionalFeatures: ["channel-email-smtp"],
72
+ });
73
+ // Parsing without SMTP_HOST should now succeed.
74
+ const result = schema.safeParse({});
75
+ expect(result.success).toBe(true);
76
+ });
77
+
78
+ it("double-wrapping an already-optional field stays parseable", () => {
79
+ // Edge case: feature declares the field optional AND the app marks
80
+ // the whole feature as optionalFeatures. composeEnvSchema wraps again.
81
+ // Zod v4 treats .optional().optional() as idempotent.
82
+ const smtp = defineFeature("channel-email-smtp", (r) => {
83
+ r.envSchema(z.object({ SMTP_HOST: z.string().optional() }));
84
+ });
85
+ const { schema } = composeEnvSchema({
86
+ features: [smtp],
87
+ optionalFeatures: ["channel-email-smtp"],
88
+ });
89
+ expect(schema.safeParse({}).success).toBe(true);
90
+ expect(schema.safeParse({ SMTP_HOST: "mail.example" }).success).toBe(true);
91
+ });
92
+
93
+ it("ignores features without envSchema", () => {
94
+ const noEnv = defineFeature("no-env", () => {
95
+ // declares nothing
96
+ });
97
+ const { schema, sources } = composeEnvSchema({ features: [noEnv] });
98
+ expect(Object.keys(schema.shape)).toEqual([]);
99
+ expect(sources).toEqual({});
100
+ });
101
+
102
+ it("tags `core` vars with source 'framework-core'", () => {
103
+ const core = z.object({
104
+ PORT: z.string().default("3000"),
105
+ DATABASE_URL: z.url(),
106
+ });
107
+ const feature = defineFeature("auth", (r) => {
108
+ r.envSchema(z.object({ JWT_SECRET: z.string().min(32) }));
109
+ });
110
+ const { schema, sources } = composeEnvSchema({
111
+ core,
112
+ features: [feature],
113
+ extend: z.object({ STUDIO_ADMIN_EMAIL: z.email() }),
114
+ });
115
+ expect(Object.keys(schema.shape).sort()).toEqual([
116
+ "DATABASE_URL",
117
+ "JWT_SECRET",
118
+ "PORT",
119
+ "STUDIO_ADMIN_EMAIL",
120
+ ]);
121
+ expect(sources).toEqual({
122
+ DATABASE_URL: "framework-core",
123
+ JWT_SECRET: "auth",
124
+ PORT: "framework-core",
125
+ STUDIO_ADMIN_EMAIL: "app",
126
+ });
127
+ });
128
+
129
+ it("detects core/feature env-var conflicts (feature shadows core)", () => {
130
+ const core = z.object({ PORT: z.string() });
131
+ const sneaky = defineFeature("sneaky", (r) => {
132
+ r.envSchema(z.object({ PORT: z.string() }));
133
+ });
134
+ expect(() => composeEnvSchema({ core, features: [sneaky] })).toThrow(KumikoBootError);
135
+ });
136
+
137
+ it("detects core/extend env-var conflicts (app shadows core)", () => {
138
+ const core = z.object({ DATABASE_URL: z.url() });
139
+ expect(() =>
140
+ composeEnvSchema({
141
+ core,
142
+ features: [],
143
+ extend: z.object({ DATABASE_URL: z.string() }),
144
+ }),
145
+ ).toThrow(KumikoBootError);
146
+ });
147
+ });
148
+
149
+ describe("r.envSchema()", () => {
150
+ it("hangs the schema off the FeatureDefinition", () => {
151
+ const feature = defineFeature("foo", (r) => {
152
+ r.envSchema(z.object({ FOO_BAR: z.string() }));
153
+ });
154
+ expect(feature.envSchema).toBeDefined();
155
+ expect(Object.keys(feature.envSchema!.shape)).toEqual(["FOO_BAR"]);
156
+ });
157
+
158
+ it("throws when called twice", () => {
159
+ expect(() =>
160
+ defineFeature("foo", (r) => {
161
+ r.envSchema(z.object({ A: z.string() }));
162
+ r.envSchema(z.object({ B: z.string() }));
163
+ }),
164
+ ).toThrow(/envSchema\(\) called twice/);
165
+ });
166
+ });
167
+
168
+ describe("parseEnv", () => {
169
+ it("returns the typed value on success", () => {
170
+ const schema = z.object({
171
+ PORT: z.string().regex(/^\d+$/),
172
+ JWT_SECRET: z.string().min(32),
173
+ });
174
+ const value = parseEnv(schema, {
175
+ PORT: "3000",
176
+ JWT_SECRET: "x".repeat(32),
177
+ });
178
+ expect(value.PORT).toBe("3000");
179
+ expect(value.JWT_SECRET.length).toBe(32);
180
+ });
181
+
182
+ it("aggregates ALL errors, not first-fail", () => {
183
+ const schema = z.object({
184
+ DATABASE_URL: z.url(),
185
+ JWT_SECRET: z.string().min(32),
186
+ ADMIN_EMAIL: z.email(),
187
+ });
188
+ try {
189
+ parseEnv(schema, {
190
+ DATABASE_URL: "not-a-url",
191
+ // JWT_SECRET missing
192
+ ADMIN_EMAIL: "not-an-email",
193
+ });
194
+ throw new Error("should have thrown");
195
+ } catch (err) {
196
+ expect(err).toBeInstanceOf(KumikoBootError);
197
+ const boot = err as KumikoBootError;
198
+ expect(boot.errors.length).toBe(3);
199
+ const names = boot.errors.map((e) => e.name).sort();
200
+ expect(names).toEqual(["ADMIN_EMAIL", "DATABASE_URL", "JWT_SECRET"]);
201
+ const jwt = boot.errors.find((e) => e.name === "JWT_SECRET");
202
+ expect(jwt?.kind).toBe("missing");
203
+ }
204
+ });
205
+
206
+ it("strips undefined values so missing vars get a 'missing' kind", () => {
207
+ const schema = z.object({ FOO: z.string() });
208
+ try {
209
+ parseEnv(schema, { FOO: undefined });
210
+ throw new Error("should have thrown");
211
+ } catch (err) {
212
+ const boot = err as KumikoBootError;
213
+ expect(boot.errors[0]!.kind).toBe("missing");
214
+ }
215
+ });
216
+
217
+ it("attaches pulumi suggestions when prefix is set", () => {
218
+ const schema = z.object({
219
+ JWT_SECRET: z
220
+ .string()
221
+ .min(32)
222
+ .meta({ kumiko: { pulumi: { generator: "openssl rand -base64 48", secret: true } } }),
223
+ });
224
+ try {
225
+ parseEnv(schema, {}, { pulumiPrefix: "studio" });
226
+ } catch (err) {
227
+ const boot = err as KumikoBootError;
228
+ expect(boot.errors[0]!.suggestion).toBe(
229
+ 'Set via: pulumi config set --secret studioJwtSecret "$(openssl rand -base64 48)"',
230
+ );
231
+ }
232
+ });
233
+
234
+ it("formats aggregate output with all errors", () => {
235
+ const schema = z.object({
236
+ A: z.string(),
237
+ B: z.string().min(10).describe("Long-ish key"),
238
+ });
239
+ try {
240
+ parseEnv(schema, { B: "short" });
241
+ } catch (err) {
242
+ const formatted = (err as KumikoBootError).format();
243
+ expect(formatted).toContain("Boot failed: 2 env-var problems");
244
+ expect(formatted).toContain("✗ A (required, missing)");
245
+ expect(formatted).toContain("✗ B (invalid)");
246
+ expect(formatted).toContain("Long-ish key");
247
+ }
248
+ });
249
+
250
+ it("populates EnvError.source from options.sources, surfaced in format()", () => {
251
+ const schema = z.object({ JWT_SECRET: z.string().min(32) });
252
+ try {
253
+ parseEnv(schema, {}, { sources: { JWT_SECRET: "auth-email-password" } });
254
+ } catch (err) {
255
+ const boot = err as KumikoBootError;
256
+ expect(boot.errors[0]!.source).toBe("auth-email-password");
257
+ expect(boot.format()).toContain("✗ JWT_SECRET (auth-email-password, required, missing)");
258
+ }
259
+ });
260
+ });
261
+
262
+ describe("pulumiConfigKey + camelCase", () => {
263
+ it("camelCases SCREAMING_SNAKE_CASE", () => {
264
+ expect(camelCase("JWT_SECRET")).toBe("jwtSecret");
265
+ expect(camelCase("STUDIO_ADMIN_EMAIL")).toBe("studioAdminEmail");
266
+ expect(camelCase("KUMIKO_SECRETS_MASTER_KEY_V1")).toBe("kumikoSecretsMasterKeyV1");
267
+ });
268
+
269
+ it("applies a prefix with PascalCase'd tail", () => {
270
+ expect(pulumiConfigKey("JWT_SECRET", undefined, "studio")).toBe("studioJwtSecret");
271
+ });
272
+
273
+ it("uses meta.pulumi.name override when set", () => {
274
+ const f = z.string().meta({ kumiko: { pulumi: { name: "secretsMasterKey" } } });
275
+ expect(pulumiConfigKey("KUMIKO_SECRETS_MASTER_KEY_V1", f, "studio")).toBe(
276
+ "studioSecretsMasterKey",
277
+ );
278
+ });
279
+
280
+ it("returns the camelCase name when no prefix", () => {
281
+ expect(pulumiConfigKey("JWT_SECRET", undefined, undefined)).toBe("jwtSecret");
282
+ });
283
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../engine/define-feature";
4
+ import { renderDryRun } from "../dry-run";
5
+ import { composeEnvSchema } from "../index";
6
+
7
+ function buildComposed() {
8
+ const secretsFeature = defineFeature("secrets", (r) => {
9
+ r.envSchema(
10
+ z.object({
11
+ KUMIKO_SECRETS_MASTER_KEY_V1: z
12
+ .string()
13
+ .describe("AES-256 master-key for tenant-secrets")
14
+ .meta({
15
+ kumiko: {
16
+ pulumi: {
17
+ name: "secretsMasterKey",
18
+ generator: "openssl rand -base64 32",
19
+ secret: true,
20
+ },
21
+ },
22
+ }),
23
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: z
24
+ .string()
25
+ .regex(/^\d+$/)
26
+ .default("1")
27
+ .describe("Active KEK version"),
28
+ }),
29
+ );
30
+ });
31
+ const authFeature = defineFeature("auth-email-password", (r) => {
32
+ r.envSchema(
33
+ z.object({
34
+ JWT_SECRET: z
35
+ .string()
36
+ .min(32)
37
+ .describe("Session JWT signing key (≥32 chars)")
38
+ .meta({
39
+ kumiko: { pulumi: { generator: "openssl rand -base64 48", secret: true } },
40
+ }),
41
+ }),
42
+ );
43
+ });
44
+ const smtpFeature = defineFeature("channel-email-smtp", (r) => {
45
+ r.envSchema(
46
+ z.object({
47
+ SMTP_HOST: z.string().optional().describe("Outbound SMTP host"),
48
+ }),
49
+ );
50
+ });
51
+ return composeEnvSchema({
52
+ features: [secretsFeature, authFeature, smtpFeature],
53
+ extend: z.object({
54
+ STUDIO_ADMIN_EMAIL: z.email().describe("Bootstrap admin user"),
55
+ }),
56
+ });
57
+ }
58
+
59
+ describe("renderDryRun", () => {
60
+ it("human mode groups by required / optional / defaulted with feature attribution", () => {
61
+ const composed = buildComposed();
62
+ const out = renderDryRun(composed, "human");
63
+ expect(out).toContain("Required env-vars:");
64
+ expect(out).toContain("Optional env-vars:");
65
+ expect(out).toContain("Defaulted env-vars:");
66
+ expect(out).toContain("JWT_SECRET");
67
+ expect(out).toContain("(auth-email-password)");
68
+ expect(out).toContain("Session JWT signing key");
69
+ expect(out).toContain("STUDIO_ADMIN_EMAIL");
70
+ expect(out).toContain("(app)");
71
+ expect(out).toContain("(channel-email-smtp)");
72
+ expect(out).toContain('[default: "1"]');
73
+ });
74
+
75
+ it("json mode emits structured data with pulumiName per entry", () => {
76
+ const composed = buildComposed();
77
+ const out = renderDryRun(composed, "json", { pulumiPrefix: "studio" });
78
+ const parsed = JSON.parse(out) as {
79
+ required: { name: string; feature: string; pulumiName: string; description?: string }[];
80
+ optional: { name: string }[];
81
+ withDefault: { name: string; default: unknown }[];
82
+ };
83
+ const jwt = parsed.required.find((r) => r.name === "JWT_SECRET");
84
+ expect(jwt?.feature).toBe("auth-email-password");
85
+ expect(jwt?.pulumiName).toBe("studioJwtSecret");
86
+ expect(jwt?.description).toContain("Session JWT");
87
+ const smtp = parsed.optional.find((r) => r.name === "SMTP_HOST");
88
+ expect(smtp).toBeDefined();
89
+ const ver = parsed.withDefault.find((r) => r.name.endsWith("CURRENT_VERSION"));
90
+ expect(ver?.default).toBe("1");
91
+ });
92
+
93
+ it("pulumi mode emits `pulumi config set` lines, omitting optional+defaulted", () => {
94
+ const composed = buildComposed();
95
+ const out = renderDryRun(composed, "pulumi", { pulumiPrefix: "studio" });
96
+ expect(out).toContain(
97
+ 'pulumi config set --secret studioJwtSecret "$(openssl rand -base64 48)"',
98
+ );
99
+ expect(out).toContain(
100
+ 'pulumi config set --secret studioSecretsMasterKey "$(openssl rand -base64 32)"',
101
+ );
102
+ expect(out).toContain('pulumi config set studioStudioAdminEmail "<set-me>"');
103
+ // Optional + default skipped:
104
+ expect(out).not.toContain("SMTP_HOST");
105
+ expect(out).not.toContain("CURRENT_VERSION");
106
+ });
107
+
108
+ it("k8s mode emits a Secret manifest", () => {
109
+ const composed = buildComposed();
110
+ const out = renderDryRun(composed, "k8s", {
111
+ k8sName: "studio-env",
112
+ k8sNamespace: "studio",
113
+ });
114
+ expect(out).toContain("apiVersion: v1");
115
+ expect(out).toContain("kind: Secret");
116
+ expect(out).toContain("name: studio-env");
117
+ expect(out).toContain("namespace: studio");
118
+ expect(out).toContain('JWT_SECRET: "<set-me>"');
119
+ expect(out).toContain('KUMIKO_SECRETS_MASTER_KEY_V1: "<set-me>"');
120
+ });
121
+ });
@@ -0,0 +1,51 @@
1
+ // Zod v4 runtime-introspection helpers. Each cast is a deliberate
2
+ // schema-walk into Zod's internal shape (`_def`, `.meta()`, `.shape[k]`).
3
+ // Centralised here so the as-cast audit's @cast-boundary schema-walk
4
+ // markers live in one place rather than scattered through callers.
5
+ //
6
+ // All helpers accept the *runtime* Zod instance — TypeScript wrapper
7
+ // (`z.ZodType`) and core (`$ZodType`) are the same object at runtime.
8
+
9
+ import type { z } from "zod";
10
+
11
+ export type ZodDef = {
12
+ readonly innerType?: z.ZodType;
13
+ readonly in?: z.ZodType;
14
+ readonly defaultValue?: unknown;
15
+ };
16
+
17
+ /** Read Zod v4 `.meta()` value. Undefined when no meta is set. */
18
+ export function zodMeta(field: z.ZodType): unknown {
19
+ // @cast-boundary schema-walk
20
+ const fn = (field as { meta?: () => unknown }).meta;
21
+ return typeof fn === "function" ? fn.call(field) : undefined;
22
+ }
23
+
24
+ /** Read the `_def` slot used by ZodDefault/ZodOptional drilling. */
25
+ export function zodDef(field: z.ZodType): ZodDef | undefined {
26
+ // @cast-boundary schema-walk
27
+ return (field as { _def?: ZodDef })._def;
28
+ }
29
+
30
+ /** Read the field-level `.describe()` value. */
31
+ export function zodDescription(field: z.ZodType): string | undefined {
32
+ // @cast-boundary schema-walk
33
+ const desc = (field as { description?: string }).description;
34
+ return typeof desc === "string" && desc.length > 0 ? desc : undefined;
35
+ }
36
+
37
+ /** Treat a ZodObject's `shape` values as `z.ZodType`. Zod v4 typing
38
+ * exposes them as `$ZodType` (core) — same runtime instance. */
39
+ export function zodShape(schema: z.ZodObject<z.ZodRawShape>): Record<string, z.ZodType> {
40
+ // @cast-boundary schema-walk
41
+ return schema.shape as Record<string, z.ZodType>;
42
+ }
43
+
44
+ /** Look up a single field on a ZodObject's shape. */
45
+ export function zodShapeField(
46
+ schema: z.ZodObject<z.ZodRawShape>,
47
+ name: string,
48
+ ): z.ZodType | undefined {
49
+ // @cast-boundary schema-walk
50
+ return schema.shape[name] as z.ZodType | undefined;
51
+ }
@@ -0,0 +1,191 @@
1
+ // Renderers for `KUMIKO_DRY_RUN_ENV=<mode>`. Operators run this against
2
+ // a built app to discover the required env-vars without booting:
3
+ // - `human` — tabular, grouped by required/optional/withDefault
4
+ // - `json` — machine-readable for CI / tooling
5
+ // - `pulumi`— `pulumi config set …`-lines for bootstrap
6
+ // - `k8s` — Secret YAML stub
7
+ //
8
+ // The same schema introspection (`classifyField`, `readKumikoMeta`) used
9
+ // by parseEnv drives the output here — single source of truth.
10
+
11
+ import type { z } from "zod";
12
+ import { zodShape } from "./_zod-introspect";
13
+ import {
14
+ type ComposedEnvSchema,
15
+ classifyField,
16
+ type EnvFieldClass,
17
+ getDefaultValue,
18
+ getFieldDescription,
19
+ pulumiConfigKey,
20
+ readKumikoMeta,
21
+ } from "./index";
22
+
23
+ export type DryRunMode = "human" | "json" | "pulumi" | "k8s";
24
+
25
+ export type DryRunOptions = {
26
+ /** From composeEnvSchema. Drives the per-row feature-attribution. */
27
+ readonly sources?: Readonly<Record<string, string>>;
28
+ /** Prefix for `pulumi config set <prefix>…` keys. Default "". */
29
+ readonly pulumiPrefix?: string;
30
+ /** k8s Secret name. Default "kumiko-env". */
31
+ readonly k8sName?: string;
32
+ /** k8s namespace. Default "default". */
33
+ readonly k8sNamespace?: string;
34
+ };
35
+
36
+ type EnvField = {
37
+ readonly name: string;
38
+ readonly field: z.ZodType;
39
+ readonly klass: EnvFieldClass;
40
+ readonly description?: string;
41
+ readonly defaultValue?: unknown;
42
+ readonly source: string; // feature name or "app" or "unknown"
43
+ };
44
+
45
+ function collectFields(
46
+ schema: z.ZodObject<z.ZodRawShape>,
47
+ sources: Readonly<Record<string, string>>,
48
+ ): readonly EnvField[] {
49
+ const out: EnvField[] = [];
50
+ for (const [name, field] of Object.entries(zodShape(schema))) {
51
+ const f = field;
52
+ const klass = classifyField(f);
53
+ out.push({
54
+ name,
55
+ field: f,
56
+ klass,
57
+ description: getFieldDescription(f),
58
+ ...(klass === "withDefault" ? { defaultValue: getDefaultValue(f) } : {}),
59
+ source: sources[name] ?? "unknown",
60
+ });
61
+ }
62
+ // Sort by class (required first), then by source, then by name. Stable
63
+ // ordering matters for snapshot-tests + grep-ability.
64
+ const order: Record<EnvFieldClass, number> = {
65
+ required: 0,
66
+ optional: 1,
67
+ withDefault: 2,
68
+ };
69
+ return out.sort((a, b) => {
70
+ if (order[a.klass] !== order[b.klass]) return order[a.klass] - order[b.klass];
71
+ if (a.source !== b.source) return a.source.localeCompare(b.source);
72
+ return a.name.localeCompare(b.name);
73
+ });
74
+ }
75
+
76
+ export function renderDryRun(
77
+ composed: ComposedEnvSchema,
78
+ mode: DryRunMode,
79
+ options: DryRunOptions = {},
80
+ ): string {
81
+ const sources = options.sources ?? composed.sources;
82
+ const fields = collectFields(composed.schema, sources);
83
+ switch (mode) {
84
+ case "human":
85
+ return renderHuman(fields);
86
+ case "json":
87
+ return renderJson(fields, options);
88
+ case "pulumi":
89
+ return renderPulumi(fields, options);
90
+ case "k8s":
91
+ return renderK8s(fields, options);
92
+ }
93
+ }
94
+
95
+ function renderHuman(fields: readonly EnvField[]): string {
96
+ const grouped: Record<EnvFieldClass, EnvField[]> = {
97
+ required: [],
98
+ optional: [],
99
+ withDefault: [],
100
+ };
101
+ for (const f of fields) grouped[f.klass].push(f);
102
+
103
+ const lines: string[] = [];
104
+ const sectionTitle: Record<EnvFieldClass, string> = {
105
+ required: "Required env-vars:",
106
+ optional: "Optional env-vars:",
107
+ withDefault: "Defaulted env-vars:",
108
+ };
109
+ const longest = fields.reduce((m, f) => Math.max(m, f.name.length), 0);
110
+ let first = true;
111
+ for (const klass of ["required", "optional", "withDefault"] as const) {
112
+ const items = grouped[klass];
113
+ if (items.length === 0) continue;
114
+ if (!first) lines.push("");
115
+ first = false;
116
+ lines.push(sectionTitle[klass]);
117
+ for (const f of items) {
118
+ const padded = f.name.padEnd(longest, " ");
119
+ const src = `(${f.source})`;
120
+ const dflt =
121
+ f.klass === "withDefault" && f.defaultValue !== undefined
122
+ ? ` [default: ${JSON.stringify(f.defaultValue)}]`
123
+ : "";
124
+ const desc = f.description ? ` — ${f.description}` : "";
125
+ lines.push(` ${padded} ${src}${dflt}${desc}`);
126
+ }
127
+ }
128
+ return `${lines.join("\n")}\n`;
129
+ }
130
+
131
+ function renderJson(fields: readonly EnvField[], options: DryRunOptions): string {
132
+ const required = fields.filter((f) => f.klass === "required");
133
+ const optional = fields.filter((f) => f.klass === "optional");
134
+ const withDefault = fields.filter((f) => f.klass === "withDefault");
135
+
136
+ const toEntry = (f: EnvField) => ({
137
+ name: f.name,
138
+ feature: f.source,
139
+ ...(f.description ? { description: f.description } : {}),
140
+ ...(f.defaultValue !== undefined ? { default: f.defaultValue } : {}),
141
+ pulumiName: pulumiConfigKey(f.name, f.field, options.pulumiPrefix),
142
+ });
143
+
144
+ return `${JSON.stringify(
145
+ {
146
+ required: required.map(toEntry),
147
+ optional: optional.map(toEntry),
148
+ withDefault: withDefault.map(toEntry),
149
+ },
150
+ null,
151
+ 2,
152
+ )}\n`;
153
+ }
154
+
155
+ function renderPulumi(fields: readonly EnvField[], options: DryRunOptions): string {
156
+ // Defaulted vars are skipped — the framework provides them, ops doesn't.
157
+ const lines: string[] = [];
158
+ for (const f of fields) {
159
+ if (f.klass === "withDefault") continue;
160
+ if (f.klass === "optional") continue;
161
+ const meta = readKumikoMeta(f.field);
162
+ const key = pulumiConfigKey(f.name, f.field, options.pulumiPrefix);
163
+ const secretFlag = meta.pulumi?.secret ? " --secret" : "";
164
+ const value = meta.pulumi?.generator ? `"$(${meta.pulumi.generator})"` : `"<set-me>"`;
165
+ const comment = f.description
166
+ ? ` # ${f.name} (${f.source}): ${f.description}`
167
+ : ` # ${f.name} (${f.source})`;
168
+ lines.push(`pulumi config set${secretFlag} ${key} ${value}${comment}`);
169
+ }
170
+ return `${lines.join("\n")}\n`;
171
+ }
172
+
173
+ function renderK8s(fields: readonly EnvField[], options: DryRunOptions): string {
174
+ const name = options.k8sName ?? "kumiko-env";
175
+ const namespace = options.k8sNamespace ?? "default";
176
+ const lines: string[] = [
177
+ "apiVersion: v1",
178
+ "kind: Secret",
179
+ "metadata:",
180
+ ` name: ${name}`,
181
+ ` namespace: ${namespace}`,
182
+ "type: Opaque",
183
+ "stringData:",
184
+ ];
185
+ for (const f of fields) {
186
+ if (f.klass === "withDefault") continue;
187
+ if (f.klass === "optional") continue;
188
+ lines.push(` ${f.name}: "<set-me>"`);
189
+ }
190
+ return `${lines.join("\n")}\n`;
191
+ }
@@ -0,0 +1,349 @@
1
+ // Env-Schema composition for Kumiko apps.
2
+ //
3
+ // Each feature declares its required env-vars via `r.envSchema(z.object({...}))`.
4
+ // `composeEnvSchema({ features, extend })` merges those into one app-wide
5
+ // Zod-object that `runProdApp` validates `process.env` against at boot.
6
+ //
7
+ // On invalid/missing vars `parseEnv` throws `KumikoBootError` with ALL
8
+ // problems aggregated (Zod's safeParse traverses every field before
9
+ // short-circuiting). `runProdApp` catches at its top level and renders
10
+ // via `KumikoBootError.format()` — so apps don't repeat the try/catch.
11
+ //
12
+ // Per-var metadata for deploy-time tooling (Pulumi-config-key override,
13
+ // generator command, k8s hints) lives in Zod's `.meta({ kumiko: {...} })`.
14
+ // Without meta, defaults: `camelCase(envVarName)` for the Pulumi key,
15
+ // `<set-me>` placeholder for the value, no `--secret` flag.
16
+
17
+ import { z } from "zod";
18
+ import type { FeatureDefinition } from "../engine/types/feature";
19
+ import { zodDef, zodDescription, zodMeta, zodShape, zodShapeField } from "./_zod-introspect";
20
+
21
+ // --- Per-env-var metadata (attach via Zod's `.meta()`) ---
22
+
23
+ export type KumikoEnvMeta = {
24
+ readonly pulumi?: {
25
+ /** Override the auto-derived camelCase Pulumi-config-key name. Apps
26
+ * setting `pulumiPrefix: "studio"` and authoring `STUDIO_ADMIN_EMAIL`
27
+ * would otherwise get `studioStudioAdminEmail` — set
28
+ * `.meta({ kumiko: { pulumi: { name: "adminEmail" } } })` to drop the
29
+ * duplicated prefix. */
30
+ readonly name?: string;
31
+ /** Shell expression that generates a value, e.g. `openssl rand -base64 32`.
32
+ * Without this, dry-run-pulumi emits `<set-me>` as the placeholder. */
33
+ readonly generator?: string;
34
+ /** Force the `--secret` flag in `pulumi config set`. Default false. */
35
+ readonly secret?: boolean;
36
+ };
37
+ };
38
+
39
+ function isKumikoMeta(value: unknown): value is KumikoEnvMeta {
40
+ if (value === null || typeof value !== "object") return false;
41
+ // @cast-boundary schema-walk — runtime-shape narrowing of zod-meta payload
42
+ const v = value as { pulumi?: unknown };
43
+ if (v.pulumi === undefined) return true;
44
+ if (v.pulumi === null || typeof v.pulumi !== "object") return false;
45
+ // @cast-boundary schema-walk
46
+ const p = v.pulumi as { name?: unknown; generator?: unknown; secret?: unknown };
47
+ if (p.name !== undefined && typeof p.name !== "string") return false;
48
+ if (p.generator !== undefined && typeof p.generator !== "string") return false;
49
+ if (p.secret !== undefined && typeof p.secret !== "boolean") return false;
50
+ return true;
51
+ }
52
+
53
+ export function readKumikoMeta(field: z.ZodType): KumikoEnvMeta {
54
+ const meta = zodMeta(field);
55
+ if (meta && typeof meta === "object" && meta !== null && "kumiko" in meta) {
56
+ // @cast-boundary schema-walk
57
+ const k = (meta as { kumiko?: unknown }).kumiko;
58
+ if (isKumikoMeta(k)) return k;
59
+ }
60
+ return {};
61
+ }
62
+
63
+ // --- Field-classification helpers (Zod v4 introspection) ---
64
+
65
+ export type EnvFieldClass = "required" | "optional" | "withDefault";
66
+
67
+ export function classifyField(field: z.ZodType): EnvFieldClass {
68
+ // Drill through ZodEffects/ZodPipe wrappers to find the inner kind.
69
+ let current: z.ZodType = field;
70
+ for (let i = 0; i < 8; i++) {
71
+ if (current instanceof z.ZodDefault) return "withDefault";
72
+ if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
73
+ return "optional";
74
+ }
75
+ const inner = zodDef(current);
76
+ if (inner?.innerType) {
77
+ current = inner.innerType;
78
+ continue;
79
+ }
80
+ if (inner?.in) {
81
+ current = inner.in;
82
+ continue;
83
+ }
84
+ break;
85
+ }
86
+ return "required";
87
+ }
88
+
89
+ export function getDefaultValue(field: z.ZodType): unknown {
90
+ let current: z.ZodType = field;
91
+ for (let i = 0; i < 8; i++) {
92
+ if (current instanceof z.ZodDefault) {
93
+ // Zod v4: defaultValue is the raw value (v3 was a factory function).
94
+ // Support both shapes for forward/backward safety.
95
+ const raw = zodDef(current)?.defaultValue;
96
+ // @cast-boundary schema-walk — Zod v3 stored a thunk, v4 a raw value
97
+ return typeof raw === "function" ? (raw as () => unknown)() : raw;
98
+ }
99
+ const inner = zodDef(current);
100
+ if (inner?.innerType) {
101
+ current = inner.innerType;
102
+ continue;
103
+ }
104
+ break;
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ export function getFieldDescription(field: z.ZodType): string | undefined {
110
+ return zodDescription(field);
111
+ }
112
+
113
+ // --- Compose ---
114
+
115
+ export type ComposeEnvSchemaOptions = {
116
+ /** All features whose envSchemas should be merged. */
117
+ readonly features: readonly FeatureDefinition[];
118
+ /** App-specific env-vars (e.g. `STUDIO_ADMIN_EMAIL`). Keys here are
119
+ * tagged as source "app" in the resulting sources map. */
120
+ readonly extend?: z.ZodObject<z.ZodRawShape>;
121
+ /** Feature-names whose env-vars should be auto-`.optional()`-wrapped.
122
+ * Lets an app opt out of e.g. `channel-email-smtp`'s vars without
123
+ * manually `.partial()`-ing each shape at the call-site. */
124
+ readonly optionalFeatures?: readonly string[];
125
+ /** Framework-core env-vars (PORT, DATABASE_URL, REDIS_URL, …) from
126
+ * `@cosmicdrift/kumiko-dev-server`. Keys here are tagged as source
127
+ * "framework-core" in error output. Conflict-detection runs across
128
+ * core/features/extend as one merged pool — same key declared in two
129
+ * layers throws KumikoBootError at compose-time. */
130
+ readonly core?: z.ZodObject<z.ZodRawShape>;
131
+ };
132
+
133
+ export type ComposedEnvSchema = {
134
+ /** The merged Zod schema. Type-erased to `ZodObject<ZodRawShape>` — TS
135
+ * can't variadic-merge N generic feature schemas without tuple gymnastics
136
+ * that hurt readability. For type-safe `z.infer` in app code, build a
137
+ * parallel typed schema manually (see Plan-Doc
138
+ * `kumiko-studio/docs/plans/sprint-9-env-schemas.md` → API-Design)
139
+ * and only pass `composed.schema` to `runProdApp` for validation. */
140
+ readonly schema: z.ZodObject<z.ZodRawShape>;
141
+ /** env-var-name → declaring source-name: feature-name from `r.envSchema()`,
142
+ * "app" from `extend`, or "framework-core" from `core`. */
143
+ readonly sources: Readonly<Record<string, string>>;
144
+ };
145
+
146
+ export function composeEnvSchema(options: ComposeEnvSchemaOptions): ComposedEnvSchema {
147
+ const optionalSet = new Set(options.optionalFeatures ?? []);
148
+ const merged: Record<string, z.ZodType> = {};
149
+ const sources: Record<string, string> = {};
150
+
151
+ // Framework-core first so a feature that accidentally declares the same
152
+ // var (e.g. PORT) gets a clear conflict error citing "framework-core"
153
+ // rather than silently overwriting a deploy-critical default. The
154
+ // conflict-detection makes the order safe in both directions — both
155
+ // throw — but downstream tools that consume `sources` (e.g. the
156
+ // upcoming Phase-5 lint-guard) may rely on this insertion order.
157
+ if (options.core) {
158
+ for (const [key, field] of Object.entries(zodShape(options.core))) {
159
+ merged[key] = field;
160
+ sources[key] = "framework-core";
161
+ }
162
+ }
163
+
164
+ for (const feature of options.features) {
165
+ if (!feature.envSchema) continue;
166
+ const shape = zodShape(feature.envSchema);
167
+ const wrap = optionalSet.has(feature.name);
168
+ for (const [key, field] of Object.entries(shape)) {
169
+ if (merged[key] !== undefined) {
170
+ throw new KumikoBootError([
171
+ {
172
+ name: key,
173
+ kind: "invalid",
174
+ message:
175
+ `env-var conflict: "${key}" declared by both ` +
176
+ `"${sources[key]}" and "${feature.name}" — pick one owner.`,
177
+ },
178
+ ]);
179
+ }
180
+ merged[key] = wrap ? field.optional() : field;
181
+ sources[key] = feature.name;
182
+ }
183
+ }
184
+
185
+ if (options.extend) {
186
+ for (const [key, field] of Object.entries(zodShape(options.extend))) {
187
+ if (merged[key] !== undefined) {
188
+ throw new KumikoBootError([
189
+ {
190
+ name: key,
191
+ kind: "invalid",
192
+ message:
193
+ `env-var conflict: "${key}" declared by both "${sources[key]}" ` +
194
+ `and the app's extend block — rename one.`,
195
+ },
196
+ ]);
197
+ }
198
+ merged[key] = field;
199
+ sources[key] = "app";
200
+ }
201
+ }
202
+
203
+ return {
204
+ schema: z.object(merged),
205
+ sources,
206
+ };
207
+ }
208
+
209
+ // --- Errors ---
210
+
211
+ export type EnvErrorKind = "missing" | "invalid";
212
+
213
+ export type EnvError = {
214
+ readonly name: string;
215
+ readonly kind: EnvErrorKind;
216
+ readonly message: string;
217
+ /** Declaring source-name from `composeEnvSchema`'s sources map:
218
+ * feature-name, "app" from `extend`, or "framework-core" from `core`.
219
+ * Populated when parseEnv received `options.sources`. Surfaced by
220
+ * `KumikoBootError.format()` so operators see WHICH source wants the
221
+ * missing var, not just the var name. */
222
+ readonly source?: string;
223
+ /** "Set via: pulumi config set ..." line. Computed when pulumiPrefix is
224
+ * passed to parseEnv. */
225
+ readonly suggestion?: string;
226
+ };
227
+
228
+ export class KumikoBootError extends Error {
229
+ readonly errors: readonly EnvError[];
230
+
231
+ constructor(errors: readonly EnvError[]) {
232
+ super(`Boot failed: ${errors.length} env-var problem${errors.length === 1 ? "" : "s"}`);
233
+ this.name = "KumikoBootError";
234
+ this.errors = errors;
235
+ }
236
+
237
+ format(): string {
238
+ const lines: string[] = [
239
+ `Boot failed: ${this.errors.length} env-var problem${this.errors.length === 1 ? "" : "s"}`,
240
+ "",
241
+ ];
242
+ for (const err of this.errors) {
243
+ const tag = err.kind === "missing" ? "required, missing" : "invalid";
244
+ const sourceTag = err.source ? `${err.source}, ${tag}` : tag;
245
+ lines.push(` ✗ ${err.name} (${sourceTag})`);
246
+ lines.push(` ${err.message}`);
247
+ if (err.suggestion) {
248
+ lines.push(` ${err.suggestion}`);
249
+ }
250
+ }
251
+ lines.push("");
252
+ lines.push(
253
+ "See: kumiko-platform/docs/runbooks/standard-deploy-app.md#step-1-boot-dry-run-lokal",
254
+ );
255
+ return lines.join("\n");
256
+ }
257
+ }
258
+
259
+ // --- Parse ---
260
+
261
+ export type ParseEnvOptions = {
262
+ /** From composeEnvSchema's return — enables per-var feature-source
263
+ * attribution in suggestions. */
264
+ readonly sources?: Readonly<Record<string, string>>;
265
+ /** When set, error suggestions include `pulumi config set <prefix>...`. */
266
+ readonly pulumiPrefix?: string;
267
+ };
268
+
269
+ export function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(
270
+ schema: S,
271
+ env: Record<string, string | undefined>,
272
+ options: ParseEnvOptions = {},
273
+ ): z.infer<S> {
274
+ // Filter undefined values to keep Zod's required-vs-invalid signal clean.
275
+ // (process.env returns string|undefined; passing undefined would parse
276
+ // as "field is set to undefined" which clouds the missing-key heuristic.)
277
+ const cleaned: Record<string, string> = {};
278
+ for (const [k, v] of Object.entries(env)) {
279
+ if (v !== undefined) cleaned[k] = v;
280
+ }
281
+
282
+ const result = schema.safeParse(cleaned);
283
+ if (result.success) {
284
+ // @cast-boundary schema-walk — z.infer<S> erasure across safeParse result
285
+ return result.data as z.infer<S>;
286
+ }
287
+
288
+ // Map Zod-issues → EnvError[], augmenting with suggestions when meta+prefix.
289
+ const errors: EnvError[] = result.error.issues.map((issue) => {
290
+ const name = String(issue.path[0] ?? "<unknown>");
291
+ const field = zodShapeField(schema, name);
292
+ // Zod v4 dropped the `received` property on invalid_type issues; the
293
+ // canonical signal for "value was missing" is now "the key isn't in
294
+ // the input object". Use input-presence as the missing/invalid switch.
295
+ const isMissing = issue.code === "invalid_type" && !(name in cleaned);
296
+ const kind: EnvErrorKind = isMissing ? "missing" : "invalid";
297
+ const desc = field ? getFieldDescription(field) : undefined;
298
+ const message = desc ? `${issue.message} — ${desc}` : issue.message;
299
+ const suggestion = field ? buildPulumiSuggestion(name, field, options.pulumiPrefix) : undefined;
300
+ const source = options.sources?.[name];
301
+ return {
302
+ name,
303
+ kind,
304
+ message,
305
+ ...(source ? { source } : {}),
306
+ ...(suggestion ? { suggestion } : {}),
307
+ };
308
+ });
309
+
310
+ throw new KumikoBootError(errors);
311
+ }
312
+
313
+ // --- Pulumi-suggestion ---
314
+
315
+ function ucfirst(s: string): string {
316
+ return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
317
+ }
318
+
319
+ export function camelCase(snakeShout: string): string {
320
+ const parts = snakeShout.toLowerCase().split("_").filter(Boolean);
321
+ if (parts.length === 0) return snakeShout.toLowerCase();
322
+ return parts[0] + parts.slice(1).map(ucfirst).join("");
323
+ }
324
+
325
+ export function pulumiConfigKey(
326
+ envName: string,
327
+ field: z.ZodType | undefined,
328
+ prefix: string | undefined,
329
+ ): string {
330
+ const meta = field ? readKumikoMeta(field) : {};
331
+ if (meta.pulumi?.name) {
332
+ return prefix ? prefix + ucfirst(meta.pulumi.name) : meta.pulumi.name;
333
+ }
334
+ const camel = camelCase(envName);
335
+ return prefix ? prefix + ucfirst(camel) : camel;
336
+ }
337
+
338
+ export function buildPulumiSuggestion(
339
+ envName: string,
340
+ field: z.ZodType,
341
+ prefix: string | undefined,
342
+ ): string | undefined {
343
+ if (!prefix) return undefined;
344
+ const meta = readKumikoMeta(field);
345
+ const key = pulumiConfigKey(envName, field, prefix);
346
+ const secretFlag = meta.pulumi?.secret ? " --secret" : "";
347
+ const value = meta.pulumi?.generator ? `"$(${meta.pulumi.generator})"` : `"<set-me>"`;
348
+ return `Set via: pulumi config set${secretFlag} ${key} ${value}`;
349
+ }