@cosmicdrift/kumiko-framework 0.6.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 +37 -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/src/es-ops/README.md +4 -2
- package/src/es-ops/__tests__/context.integration.ts +100 -10
- package/src/es-ops/context.ts +21 -4
- package/src/es-ops/types.ts +14 -1
|
@@ -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
|
+
}
|