@cosmicdrift/kumiko-framework 0.7.0 → 0.8.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/CHANGELOG.md +31 -0
- package/package.json +10 -2
- package/src/engine/define-feature.ts +13 -0
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round5.ts +25 -1
- package/src/engine/feature-ast/parse.ts +4 -0
- package/src/engine/feature-ast/patterns.ts +11 -0
- package/src/engine/feature-ast/render.ts +7 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +3 -0
- package/src/engine/pattern-library/library.ts +19 -0
- package/src/engine/types/feature.ts +19 -0
- package/src/env/__tests__/compose-env-schema.test.ts +283 -0
- package/src/env/__tests__/dry-run.test.ts +121 -0
- package/src/env/_zod-introspect.ts +51 -0
- package/src/env/dry-run.ts +191 -0
- package/src/env/index.ts +349 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-framework
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f34af9a: Add framework-core env-schema (Sprint 9.2, Migration Phase 1).
|
|
8
|
+
|
|
9
|
+
**New API:**
|
|
10
|
+
|
|
11
|
+
- `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.
|
|
12
|
+
- `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.
|
|
13
|
+
|
|
14
|
+
**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.
|
|
15
|
+
|
|
16
|
+
**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.
|
|
17
|
+
|
|
18
|
+
**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`.
|
|
19
|
+
|
|
20
|
+
- dff4123: Add Zod-based env-schema declarations and boot-time validation (Sprint 9.1).
|
|
21
|
+
|
|
22
|
+
**New API:**
|
|
23
|
+
|
|
24
|
+
- `r.envSchema(z.object({...}))` — declare per-feature env-vars at registration time.
|
|
25
|
+
- `@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).
|
|
26
|
+
- `@cosmicdrift/kumiko-framework/env/dry-run`: `renderDryRun(composed, mode, opts)` for `human|json|pulumi|k8s` introspection of the required env-vars without booting.
|
|
27
|
+
- `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.
|
|
28
|
+
- Per-var metadata via Zod's `.meta({ kumiko: { pulumi: { name, generator, secret } } })` for deploy-time tooling overrides.
|
|
29
|
+
|
|
30
|
+
**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.
|
|
31
|
+
|
|
32
|
+
**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.
|
|
33
|
+
|
|
3
34
|
## 0.7.0
|
|
4
35
|
|
|
5
36
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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>",
|
|
@@ -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"
|
|
@@ -163,7 +171,7 @@
|
|
|
163
171
|
"zod": "^4.4.3"
|
|
164
172
|
},
|
|
165
173
|
"devDependencies": {
|
|
166
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
174
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.8.0",
|
|
167
175
|
"@types/uuid": "^11.0.0",
|
|
168
176
|
"bun-types": "^1.3.13",
|
|
169
177
|
"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
|
}
|
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
import type { CallExpression, SourceFile } from "ts-morph";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
|
-
import type {
|
|
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
|
+
}
|
package/src/env/index.ts
ADDED
|
@@ -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
|
+
}
|