@crewhaus/helm-chart 0.1.4 → 0.1.5

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.
@@ -0,0 +1,171 @@
1
+ import { type TargetShape } from "@crewhaus/docker-images";
2
+ /**
3
+ * Section 32 — `@crewhaus/helm-chart`
4
+ *
5
+ * Kubernetes Helm chart for crewhaus deployments. Ships:
6
+ * - The chart itself under `helm/crewhaus/` (Chart.yaml, values.yaml,
7
+ * templates/, _helpers.tpl) — installable via `helm install`.
8
+ * - `renderChart()` — a tiny in-process renderer that supports the
9
+ * subset of helm's templating used in our templates (just enough
10
+ * to assert structural correctness in unit tests without requiring
11
+ * `helm` in CI).
12
+ * - `validateValues()` — Zod-free runtime validator for the
13
+ * values.yaml schema.
14
+ *
15
+ * The in-process renderer is intentionally minimal — it handles
16
+ * variable interpolation (`{{ .Values.foo }}`), the `if`/`else`/`end`
17
+ * directive, the `has` and `eq` predicates, the `quote` and `nindent`
18
+ * filters, and the `include` template call. It does **not** replace
19
+ * helm; the production install path is `helm install crewhaus
20
+ * helm/crewhaus -f values.yaml`.
21
+ */
22
+ import { CrewhausError } from "@crewhaus/errors";
23
+ export declare class HelmChartError extends CrewhausError {
24
+ readonly name = "HelmChartError";
25
+ constructor(message: string, cause?: unknown);
26
+ }
27
+ export declare function chartRoot(): string;
28
+ export declare function templateFiles(): readonly string[];
29
+ export declare function readTemplate(name: string): string;
30
+ export type ChartValues = {
31
+ readonly target: TargetShape;
32
+ readonly nameOverride?: string;
33
+ readonly image: {
34
+ readonly repository?: string;
35
+ readonly tag: string;
36
+ readonly pullPolicy: "IfNotPresent" | "Always" | "Never";
37
+ readonly pullSecrets: ReadonlyArray<{
38
+ readonly name: string;
39
+ }>;
40
+ };
41
+ readonly replicas: number;
42
+ readonly spec: {
43
+ readonly configMap: string;
44
+ readonly fileName: string;
45
+ };
46
+ readonly secrets: {
47
+ readonly anthropic?: {
48
+ readonly secretName: string;
49
+ readonly key: string;
50
+ };
51
+ readonly slack?: {
52
+ readonly signing: {
53
+ readonly secretName: string;
54
+ readonly key: string;
55
+ };
56
+ readonly bot: {
57
+ readonly secretName: string;
58
+ readonly key: string;
59
+ };
60
+ };
61
+ /**
62
+ * Generic provider secret list — one container env var per entry,
63
+ * each sourced from a Kubernetes Secret. The escape hatch for
64
+ * non-Anthropic models (OPENAI_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY,
65
+ * AWS_BEARER_TOKEN_BEDROCK/AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, …).
66
+ * Presets are documented in values.yaml.
67
+ */
68
+ readonly provider?: ReadonlyArray<{
69
+ readonly name: string;
70
+ readonly secretName: string;
71
+ readonly key: string;
72
+ }>;
73
+ };
74
+ /**
75
+ * Plain (non-secret) env vars appended to the container — provider
76
+ * region/endpoint selectors like AWS_REGION, OPENAI_BASE_URL,
77
+ * GOOGLE_CLOUD_PROJECT.
78
+ */
79
+ readonly extraEnv?: ReadonlyArray<{
80
+ readonly name: string;
81
+ readonly value: string;
82
+ }>;
83
+ readonly service: {
84
+ readonly type: "ClusterIP" | "LoadBalancer" | "NodePort";
85
+ readonly port: number;
86
+ readonly targetPort: number;
87
+ };
88
+ readonly ingress: {
89
+ readonly enabled: boolean;
90
+ readonly className: string;
91
+ readonly annotations: Readonly<Record<string, string>>;
92
+ readonly hosts: ReadonlyArray<{
93
+ readonly host: string;
94
+ readonly paths: ReadonlyArray<{
95
+ readonly path: string;
96
+ readonly pathType: string;
97
+ }>;
98
+ }>;
99
+ readonly tls: ReadonlyArray<{
100
+ readonly hosts: readonly string[];
101
+ readonly secretName: string;
102
+ }>;
103
+ };
104
+ readonly otel: {
105
+ readonly enabled: boolean;
106
+ readonly endpoint: string;
107
+ readonly headers: string;
108
+ };
109
+ readonly serviceMonitor: {
110
+ readonly enabled: boolean;
111
+ readonly interval: string;
112
+ readonly labels: Readonly<Record<string, string>>;
113
+ };
114
+ readonly podSecurityContext: Readonly<Record<string, unknown>>;
115
+ readonly securityContext: Readonly<Record<string, unknown>>;
116
+ readonly resources: Readonly<Record<string, unknown>>;
117
+ };
118
+ export declare function defaultValues(): ChartValues;
119
+ export declare function validateValues(values: ChartValues): void;
120
+ export type RenderContext = {
121
+ readonly Values: ChartValues;
122
+ readonly Release: {
123
+ readonly Name: string;
124
+ readonly Service: string;
125
+ };
126
+ readonly Chart: {
127
+ readonly Name: string;
128
+ readonly AppVersion: string;
129
+ };
130
+ };
131
+ export declare function renderContext(values: ChartValues, releaseName?: string): RenderContext;
132
+ type Node = {
133
+ kind: "text";
134
+ value: string;
135
+ } | {
136
+ kind: "expr";
137
+ expr: string;
138
+ } | {
139
+ kind: "if";
140
+ cond: string;
141
+ consequent: Node[];
142
+ alternate: Node[];
143
+ } | {
144
+ kind: "range";
145
+ expr: string;
146
+ body: Node[];
147
+ } | {
148
+ kind: "with";
149
+ expr: string;
150
+ body: Node[];
151
+ } | {
152
+ kind: "define";
153
+ name: string;
154
+ body: Node[];
155
+ } | {
156
+ kind: "include";
157
+ name: string;
158
+ filter?: {
159
+ name: string;
160
+ arg?: string;
161
+ };
162
+ };
163
+ /**
164
+ * Render a single template against (values + release) → YAML string.
165
+ * Supports define/include/if/else/range/with and the filters used in our templates.
166
+ */
167
+ export declare function renderTemplate(text: string, ctx: RenderContext, defines?: Map<string, Node[]>): string;
168
+ export declare function renderChart(values: ChartValues, releaseName?: string): Record<string, string>;
169
+ /** Daemon shapes that get a Service + Ingress rendered. */
170
+ export declare function isDaemonShape(target: TargetShape): boolean;
171
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,489 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { TARGET_SHAPES, isTargetShape } from "@crewhaus/docker-images";
5
+ /**
6
+ * Section 32 — `@crewhaus/helm-chart`
7
+ *
8
+ * Kubernetes Helm chart for crewhaus deployments. Ships:
9
+ * - The chart itself under `helm/crewhaus/` (Chart.yaml, values.yaml,
10
+ * templates/, _helpers.tpl) — installable via `helm install`.
11
+ * - `renderChart()` — a tiny in-process renderer that supports the
12
+ * subset of helm's templating used in our templates (just enough
13
+ * to assert structural correctness in unit tests without requiring
14
+ * `helm` in CI).
15
+ * - `validateValues()` — Zod-free runtime validator for the
16
+ * values.yaml schema.
17
+ *
18
+ * The in-process renderer is intentionally minimal — it handles
19
+ * variable interpolation (`{{ .Values.foo }}`), the `if`/`else`/`end`
20
+ * directive, the `has` and `eq` predicates, the `quote` and `nindent`
21
+ * filters, and the `include` template call. It does **not** replace
22
+ * helm; the production install path is `helm install crewhaus
23
+ * helm/crewhaus -f values.yaml`.
24
+ */
25
+ import { CrewhausError } from "@crewhaus/errors";
26
+ export class HelmChartError extends CrewhausError {
27
+ name = "HelmChartError";
28
+ constructor(message, cause) {
29
+ super("config", message, cause);
30
+ }
31
+ }
32
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
33
+ const CHART_ROOT = join(PACKAGE_ROOT, "helm", "crewhaus");
34
+ const TEMPLATES_DIR = join(CHART_ROOT, "templates");
35
+ export function chartRoot() {
36
+ return CHART_ROOT;
37
+ }
38
+ export function templateFiles() {
39
+ if (!existsSync(TEMPLATES_DIR)) {
40
+ throw new HelmChartError(`templates directory missing at ${TEMPLATES_DIR}`);
41
+ }
42
+ return readdirSync(TEMPLATES_DIR)
43
+ .filter((f) => /\.(ya?ml|tpl)$/.test(f))
44
+ .sort();
45
+ }
46
+ export function readTemplate(name) {
47
+ if (name.includes("/") || name.includes("..")) {
48
+ throw new HelmChartError(`invalid template name: ${name}`);
49
+ }
50
+ const path = join(TEMPLATES_DIR, name);
51
+ if (!existsSync(path)) {
52
+ throw new HelmChartError(`template not found: ${name}`);
53
+ }
54
+ return readFileSync(path, "utf8");
55
+ }
56
+ export function defaultValues() {
57
+ return {
58
+ target: "channel",
59
+ image: { tag: "latest", pullPolicy: "IfNotPresent", pullSecrets: [] },
60
+ replicas: 1,
61
+ spec: { configMap: "crewhaus-spec", fileName: "crewhaus.yaml" },
62
+ secrets: {
63
+ anthropic: { secretName: "crewhaus-creds", key: "ANTHROPIC_AUTH_TOKEN" },
64
+ slack: {
65
+ signing: { secretName: "crewhaus-creds", key: "SLACK_SIGNING_SECRET" },
66
+ bot: { secretName: "crewhaus-creds", key: "SLACK_BOT_TOKEN" },
67
+ },
68
+ provider: [],
69
+ },
70
+ extraEnv: [],
71
+ service: { type: "ClusterIP", port: 80, targetPort: 3000 },
72
+ ingress: {
73
+ enabled: false,
74
+ className: "",
75
+ annotations: {},
76
+ hosts: [],
77
+ tls: [],
78
+ },
79
+ otel: { enabled: true, endpoint: "", headers: "" },
80
+ serviceMonitor: { enabled: true, interval: "30s", labels: {} },
81
+ podSecurityContext: {
82
+ runAsNonRoot: true,
83
+ runAsUser: 10001,
84
+ fsGroup: 10001,
85
+ },
86
+ securityContext: {
87
+ readOnlyRootFilesystem: true,
88
+ allowPrivilegeEscalation: false,
89
+ capabilities: { drop: ["ALL"] },
90
+ },
91
+ resources: {
92
+ requests: { cpu: "100m", memory: "256Mi" },
93
+ limits: { cpu: "1000m", memory: "1Gi" },
94
+ },
95
+ };
96
+ }
97
+ export function validateValues(values) {
98
+ if (!isTargetShape(values.target)) {
99
+ throw new HelmChartError(`unknown target: ${values.target} (allowed: ${TARGET_SHAPES.join(", ")})`);
100
+ }
101
+ if (values.replicas < 0 || !Number.isInteger(values.replicas)) {
102
+ throw new HelmChartError(`replicas must be a non-negative integer; got ${values.replicas}`);
103
+ }
104
+ if (values.service.port <= 0 || values.service.port > 65535) {
105
+ throw new HelmChartError(`service.port out of range: ${values.service.port}`);
106
+ }
107
+ if (!values.image.tag) {
108
+ throw new HelmChartError("image.tag must be non-empty");
109
+ }
110
+ for (const entry of values.secrets.provider ?? []) {
111
+ if (!entry.name || !entry.secretName || !entry.key) {
112
+ throw new HelmChartError(`secrets.provider entries need name + secretName + key; got ${JSON.stringify(entry)}`);
113
+ }
114
+ }
115
+ for (const entry of values.extraEnv ?? []) {
116
+ if (!entry.name || typeof entry.value !== "string") {
117
+ throw new HelmChartError(`extraEnv entries need name + string value; got ${JSON.stringify(entry)}`);
118
+ }
119
+ }
120
+ }
121
+ export function renderContext(values, releaseName = "crewhaus") {
122
+ return {
123
+ Values: values,
124
+ Release: { Name: releaseName, Service: "Helm" },
125
+ Chart: { Name: "crewhaus", AppVersion: "1.0.0" },
126
+ };
127
+ }
128
+ const DAEMON_SHAPES = ["channel", "managed", "crew", "voice", "browser"];
129
+ function tokenise(text) {
130
+ const out = [];
131
+ // Strip helm comments first
132
+ const stripped = text.replace(/\{\{-?\s*\/\*[\s\S]*?\*\/\s*-?\}\}/g, "");
133
+ const re = /\{\{-?\s*([\s\S]*?)\s*-?\}\}/g;
134
+ let last = 0;
135
+ let m = re.exec(stripped);
136
+ while (m !== null) {
137
+ if (m.index > last)
138
+ out.push({ kind: "text", value: stripped.slice(last, m.index) });
139
+ out.push({ kind: "action", value: (m[1] ?? "").trim() });
140
+ last = m.index + m[0].length;
141
+ m = re.exec(stripped);
142
+ }
143
+ if (last < stripped.length)
144
+ out.push({ kind: "text", value: stripped.slice(last) });
145
+ return out;
146
+ }
147
+ function parseAst(tokens) {
148
+ const it = { i: 0 };
149
+ return parseUntil(tokens, it, []);
150
+ }
151
+ function parseUntil(tokens, it, stoppers) {
152
+ const nodes = [];
153
+ while (it.i < tokens.length) {
154
+ const tok = tokens[it.i];
155
+ if (tok === undefined)
156
+ break;
157
+ if (tok.kind === "text") {
158
+ nodes.push({ kind: "text", value: tok.value });
159
+ it.i++;
160
+ continue;
161
+ }
162
+ const action = tok.value;
163
+ if (stoppers.includes(actionKeyword(action))) {
164
+ return nodes;
165
+ }
166
+ it.i++;
167
+ const kw = actionKeyword(action);
168
+ if (kw === "if") {
169
+ const cond = action.slice(2).trim();
170
+ const thenBranch = parseUntil(tokens, it, ["else", "end"]);
171
+ let elseBranch = [];
172
+ const next = tokens[it.i];
173
+ if (next && next.kind === "action" && actionKeyword(next.value) === "else") {
174
+ it.i++;
175
+ elseBranch = parseUntil(tokens, it, ["end"]);
176
+ }
177
+ if (it.i < tokens.length)
178
+ it.i++;
179
+ nodes.push({ kind: "if", cond, consequent: thenBranch, alternate: elseBranch });
180
+ }
181
+ else if (kw === "range") {
182
+ const expr = action.slice(5).trim();
183
+ const body = parseUntil(tokens, it, ["end"]);
184
+ if (it.i < tokens.length)
185
+ it.i++;
186
+ nodes.push({ kind: "range", expr, body });
187
+ }
188
+ else if (kw === "with") {
189
+ const expr = action.slice(4).trim();
190
+ const body = parseUntil(tokens, it, ["end"]);
191
+ if (it.i < tokens.length)
192
+ it.i++;
193
+ nodes.push({ kind: "with", expr, body });
194
+ }
195
+ else if (kw === "define") {
196
+ const m = /^define\s+"([^"]+)"\s*$/.exec(action);
197
+ const name = m?.[1] ?? "";
198
+ const body = parseUntil(tokens, it, ["end"]);
199
+ if (it.i < tokens.length)
200
+ it.i++;
201
+ nodes.push({ kind: "define", name, body });
202
+ }
203
+ else if (kw === "include") {
204
+ const m = /^include\s+"([^"]+)"\s+\.\s*(?:\|\s*(\w+)(?:\s+(\S+))?)?\s*$/.exec(action);
205
+ if (m) {
206
+ const name = m[1] ?? "";
207
+ nodes.push({
208
+ kind: "include",
209
+ name,
210
+ filter: m[2] ? { name: m[2], arg: m[3] } : undefined,
211
+ });
212
+ }
213
+ }
214
+ else if (kw === "end" || kw === "else") {
215
+ // Stray; drop.
216
+ }
217
+ else {
218
+ nodes.push({ kind: "expr", expr: action });
219
+ }
220
+ }
221
+ return nodes;
222
+ }
223
+ function actionKeyword(action) {
224
+ return action.split(/\s+/)[0] ?? "";
225
+ }
226
+ function renderNodes(nodes, ctx, defines) {
227
+ let out = "";
228
+ for (const n of nodes) {
229
+ switch (n.kind) {
230
+ case "text":
231
+ out += n.value;
232
+ break;
233
+ case "expr":
234
+ out += renderExpr(n.expr, ctx);
235
+ break;
236
+ case "if": {
237
+ const cond = evalExpression(n.cond, ctx);
238
+ out += renderNodes(cond ? n.consequent : n.alternate, ctx, defines);
239
+ break;
240
+ }
241
+ case "range": {
242
+ const list = evalExpression(n.expr, ctx);
243
+ if (Array.isArray(list)) {
244
+ for (const item of list) {
245
+ const itemCtx = withDot(ctx, item);
246
+ out += renderNodes(n.body, itemCtx, defines);
247
+ }
248
+ }
249
+ break;
250
+ }
251
+ case "with": {
252
+ const value = evalExpression(n.expr, ctx);
253
+ if (value && (typeof value !== "object" || Object.keys(value).length > 0)) {
254
+ const itemCtx = withDot(ctx, value);
255
+ out += renderNodes(n.body, itemCtx, defines);
256
+ }
257
+ break;
258
+ }
259
+ case "define":
260
+ defines.set(n.name, n.body);
261
+ break;
262
+ case "include": {
263
+ const body = defines.get(n.name);
264
+ let rendered = body === undefined ? `<missing:${n.name}>` : renderNodes(body, ctx, defines).trim();
265
+ const indentArg = n.filter?.arg;
266
+ if (n.filter?.name === "nindent" && indentArg) {
267
+ rendered = `\n${rendered
268
+ .split("\n")
269
+ .map((l) => " ".repeat(Number(indentArg)) + l)
270
+ .join("\n")}`;
271
+ }
272
+ out += rendered;
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+ function renderExpr(expr, ctx) {
280
+ const m = /^(.+?)(?:\s*\|\s*(\w+)(?:\s+(\S+))?)?$/.exec(expr.trim());
281
+ if (!m || !m[1])
282
+ return "";
283
+ const value = evalExpression(m[1].trim(), ctx);
284
+ const filter = m[2];
285
+ const arg = m[3];
286
+ let s = stringifyValue(value);
287
+ switch (filter) {
288
+ case "quote":
289
+ s = JSON.stringify(s);
290
+ break;
291
+ case "upper":
292
+ s = s.toUpperCase();
293
+ break;
294
+ case "trunc":
295
+ if (arg)
296
+ s = s.slice(0, Number(arg));
297
+ break;
298
+ case "trimSuffix":
299
+ if (arg) {
300
+ const suffix = JSON.parse(arg);
301
+ s = s.endsWith(suffix) ? s.slice(0, -suffix.length) : s;
302
+ }
303
+ break;
304
+ case "nindent":
305
+ if (arg)
306
+ s = `\n${s
307
+ .split("\n")
308
+ .map((l) => " ".repeat(Number(arg)) + l)
309
+ .join("\n")}`;
310
+ break;
311
+ case "toYaml":
312
+ s = simpleYaml(value, 0);
313
+ break;
314
+ }
315
+ return s;
316
+ }
317
+ function withDot(ctx, dot) {
318
+ return { ...ctx, _dot: dot };
319
+ }
320
+ /**
321
+ * Render a single template against (values + release) → YAML string.
322
+ * Supports define/include/if/else/range/with and the filters used in our templates.
323
+ */
324
+ export function renderTemplate(text, ctx, defines = new Map()) {
325
+ const tokens = tokenise(text);
326
+ const ast = parseAst(tokens);
327
+ return renderNodes(ast, ctx, defines);
328
+ }
329
+ function evalExpression(expr, ctx) {
330
+ // Function-style helpers we support: has X (list ...), eq A B, default A B, and pipelines like (list ...).
331
+ const trimmed = expr.trim();
332
+ // has X (list ...)
333
+ const hasMatch = /^has\s+([^()]+?)\s*\(\s*list\s+(.+)\s*\)\s*$/.exec(trimmed);
334
+ if (hasMatch?.[1] && hasMatch[2]) {
335
+ const target = stringifyValue(evalExpression(hasMatch[1].trim(), ctx));
336
+ const items = parseListLiteral(hasMatch[2]);
337
+ return items.includes(target);
338
+ }
339
+ // and A B / and A B C
340
+ if (/^and\s+/.test(trimmed)) {
341
+ const parts = splitTopLevel(trimmed.slice(4));
342
+ return parts.every((p) => Boolean(evalExpression(p, ctx)));
343
+ }
344
+ if (/^or\s+/.test(trimmed)) {
345
+ const parts = splitTopLevel(trimmed.slice(3));
346
+ return parts.some((p) => Boolean(evalExpression(p, ctx)));
347
+ }
348
+ // eq A B
349
+ const eqMatch = /^eq\s+(.+?)\s+(.+)$/.exec(trimmed);
350
+ if (eqMatch?.[1] && eqMatch[2]) {
351
+ return (stringifyValue(evalExpression(eqMatch[1].trim(), ctx)) ===
352
+ stringifyValue(evalExpression(eqMatch[2].trim(), ctx)));
353
+ }
354
+ // default A B
355
+ const defMatch = /^default\s+(.+?)\s+(.+)$/.exec(trimmed);
356
+ if (defMatch?.[1] && defMatch[2]) {
357
+ const fallback = evalExpression(defMatch[1].trim(), ctx);
358
+ const value = evalExpression(defMatch[2].trim(), ctx);
359
+ return value === undefined || value === null || value === "" ? fallback : value;
360
+ }
361
+ // (list ...) literal
362
+ if (/^\(\s*list\s/.test(trimmed)) {
363
+ return parseListLiteral(trimmed.replace(/^\(\s*list\s+/, "").replace(/\s*\)$/, ""));
364
+ }
365
+ // String literal
366
+ if (/^".*"$/.test(trimmed))
367
+ return JSON.parse(trimmed);
368
+ // Number literal
369
+ if (/^-?\d+(\.\d+)?$/.test(trimmed))
370
+ return Number(trimmed);
371
+ // .Values.foo / .Release.Name / .Chart.Name dot path
372
+ if (trimmed.startsWith(".")) {
373
+ return resolveDotPath(trimmed, ctx);
374
+ }
375
+ // Bare identifier (could be a chart helper we ignore) → return the literal string
376
+ return trimmed;
377
+ }
378
+ function splitTopLevel(text) {
379
+ const out = [];
380
+ let depth = 0;
381
+ let start = 0;
382
+ for (let i = 0; i < text.length; i++) {
383
+ const c = text[i];
384
+ if (c === "(")
385
+ depth++;
386
+ else if (c === ")")
387
+ depth--;
388
+ else if (c === " " && depth === 0) {
389
+ out.push(text.slice(start, i));
390
+ start = i + 1;
391
+ }
392
+ }
393
+ out.push(text.slice(start));
394
+ return out.filter((s) => s.length > 0);
395
+ }
396
+ function parseListLiteral(text) {
397
+ const out = [];
398
+ const re = /"([^"]*)"/g;
399
+ let m = re.exec(text);
400
+ while (m !== null) {
401
+ out.push(m[1] ?? "");
402
+ m = re.exec(text);
403
+ }
404
+ return out;
405
+ }
406
+ function resolveDotPath(path, ctx) {
407
+ const dotCtx = ctx._dot;
408
+ if (path === "." || path === "")
409
+ return dotCtx ?? ctx;
410
+ // `.foo.bar` reads from `_dot` first if present, else from ctx.
411
+ const parts = path.replace(/^\./, "").split(".");
412
+ let cur = dotCtx ?? ctx;
413
+ for (const p of parts) {
414
+ if (cur && typeof cur === "object" && p in cur) {
415
+ cur = cur[p];
416
+ }
417
+ else if (dotCtx && ctx && typeof ctx === "object" && p in ctx) {
418
+ // Fall back to outer context for absolute paths after we lost track in $-iter.
419
+ cur = ctx[p];
420
+ }
421
+ else {
422
+ return undefined;
423
+ }
424
+ }
425
+ return cur;
426
+ }
427
+ function stringifyValue(v) {
428
+ if (v === undefined || v === null)
429
+ return "";
430
+ if (typeof v === "string")
431
+ return v;
432
+ if (typeof v === "number" || typeof v === "boolean")
433
+ return String(v);
434
+ if (Array.isArray(v))
435
+ return v.map(stringifyValue).join(",");
436
+ return JSON.stringify(v);
437
+ }
438
+ function simpleYaml(v, indent) {
439
+ const pad = " ".repeat(indent);
440
+ if (v === null || v === undefined)
441
+ return "null";
442
+ if (typeof v === "string")
443
+ return JSON.stringify(v);
444
+ if (typeof v === "number" || typeof v === "boolean")
445
+ return String(v);
446
+ if (Array.isArray(v)) {
447
+ if (v.length === 0)
448
+ return "[]";
449
+ return v.map((item) => `${pad}- ${simpleYaml(item, indent + 2).trimStart()}`).join("\n");
450
+ }
451
+ if (typeof v === "object") {
452
+ const entries = Object.entries(v);
453
+ if (entries.length === 0)
454
+ return "{}";
455
+ return entries
456
+ .map(([k, val]) => {
457
+ const inner = simpleYaml(val, indent + 2);
458
+ if (inner.includes("\n"))
459
+ return `${pad}${k}:\n${inner}`;
460
+ return `${pad}${k}: ${inner}`;
461
+ })
462
+ .join("\n");
463
+ }
464
+ return String(v);
465
+ }
466
+ // ─── High-level API: render all templates for a values set ───────────────────
467
+ export function renderChart(values, releaseName = "crewhaus") {
468
+ validateValues(values);
469
+ const ctx = renderContext(values, releaseName);
470
+ const defines = new Map();
471
+ // Pre-load every helpers file so include() resolves.
472
+ for (const file of templateFiles()) {
473
+ if (file.endsWith(".tpl")) {
474
+ // Drains define blocks into the shared map but produces no output.
475
+ renderTemplate(readTemplate(file), ctx, defines);
476
+ }
477
+ }
478
+ const result = {};
479
+ for (const file of templateFiles()) {
480
+ if (file.endsWith(".tpl"))
481
+ continue;
482
+ result[file] = renderTemplate(readTemplate(file), ctx, defines).trim();
483
+ }
484
+ return result;
485
+ }
486
+ /** Daemon shapes that get a Service + Ingress rendered. */
487
+ export function isDaemonShape(target) {
488
+ return DAEMON_SHAPES.includes(target);
489
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/helm-chart",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Kubernetes Helm chart (templates + values schema + ServiceMonitor + OTel collector sidecar) plus an in-process renderChart() for tests (Section 32)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/docker-images": "0.1.4",
16
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/docker-images": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }