@shepherdjerred/helm-types 1.1.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.
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Clean up YAML comment text for use in JSDoc
3
+ */
4
+ export function cleanYAMLComment(comment: string): string {
5
+ const lines = comment.split("\n").map((line) => {
6
+ // Remove leading # symbols
7
+ line = line.replace(/^#+\s*/, "");
8
+ // Remove common Helm chart comment markers
9
+ line = line.replace(/^--\s*/, "");
10
+ line = line.replace(/^##\s*/, "");
11
+ return line.trim();
12
+ });
13
+
14
+ // Filter and clean lines
15
+ const cleaned: string[] = [];
16
+ let inCodeBlock = false;
17
+
18
+ for (const line of lines) {
19
+ const currentLine = line;
20
+
21
+ // Skip empty lines
22
+ if (currentLine.length === 0) {
23
+ if (inCodeBlock) inCodeBlock = false;
24
+ continue;
25
+ }
26
+
27
+ // Skip @default lines (we'll generate our own)
28
+ if (currentLine.startsWith("@default")) continue;
29
+
30
+ // Detect various patterns that indicate this is an example/code block, not documentation
31
+ const isExample =
32
+ /^-{3,}/.test(currentLine) ||
33
+ /^BEGIN .*(KEY|CERTIFICATE)/.test(currentLine) ||
34
+ /^END .*(KEY|CERTIFICATE)/.test(currentLine) ||
35
+ (currentLine.startsWith("-") && (currentLine.includes(":") || /^-\s+\|/.test(currentLine))) ||
36
+ /^\w+:$/.test(currentLine) ||
37
+ /^[\w-]+:\s*$/.test(currentLine) ||
38
+ /^[\w.-]+:\s*\|/.test(currentLine) || // YAML multiline indicator (e.g., "policy.csv: |")
39
+ currentLine.startsWith("|") ||
40
+ currentLine.includes("$ARGOCD_") ||
41
+ currentLine.includes("$KUBE_") ||
42
+ /^\s{2,}/.test(currentLine) ||
43
+ /^echo\s+/.test(currentLine) ||
44
+ /^[pg],\s*/.test(currentLine); // Policy rules like "p, role:..." or "g, subject, ..."
45
+
46
+ if (isExample) {
47
+ inCodeBlock = true;
48
+ continue;
49
+ }
50
+
51
+ // If we're in a code block, skip until we hit normal prose
52
+ if (inCodeBlock) {
53
+ // Check if this line looks like normal prose (sentence case, punctuation)
54
+ const looksLikeProse =
55
+ /^[A-Z][\w\s]+[.!?]$/.test(currentLine) ||
56
+ /^[A-Z][\w\s,'"-]+:?\s*$/.test(currentLine) ||
57
+ currentLine.startsWith("Ref:") ||
58
+ currentLine.startsWith("See:") ||
59
+ currentLine.startsWith("http://") ||
60
+ currentLine.startsWith("https://");
61
+
62
+ if (looksLikeProse) {
63
+ inCodeBlock = false;
64
+ } else {
65
+ continue;
66
+ }
67
+ }
68
+
69
+ // Keep the line
70
+ cleaned.push(currentLine);
71
+ }
72
+
73
+ return cleaned.join("\n").trim();
74
+ }
75
+
76
+ /**
77
+ * Parse YAML comments and associate them with keys
78
+ * Exported for testing purposes
79
+ */
80
+ export function parseYAMLComments(yamlContent: string): Map<string, string> {
81
+ const lines = yamlContent.split("\n");
82
+ const comments = new Map<string, string>();
83
+ let pendingComments: { text: string; indent: number }[] = [];
84
+ const indentStack: { indent: number; key: string }[] = [];
85
+
86
+ for (const line of lines) {
87
+ const currentLine = line;
88
+ const trimmed = currentLine.trim();
89
+
90
+ // Skip empty lines
91
+ if (!trimmed) {
92
+ // Empty lines break comment association only if we already have comments
93
+ // This allows comments separated by blank lines to still be associated
94
+ continue;
95
+ }
96
+
97
+ // Check if line is a comment
98
+ if (trimmed.startsWith("#")) {
99
+ const comment = trimmed.substring(1).trim();
100
+ if (comment) {
101
+ // Track the indentation level of the comment
102
+ const commentIndent = currentLine.search(/\S/);
103
+ pendingComments.push({ text: comment, indent: commentIndent });
104
+ }
105
+ continue;
106
+ }
107
+
108
+ // Check if line has a key
109
+ const keyMatch = /^(\s*)([a-zA-Z0-9_-]+)\s*:/.exec(currentLine);
110
+ if (keyMatch) {
111
+ const indent = keyMatch[1]?.length ?? 0;
112
+ const key = keyMatch[2];
113
+ if (!key) continue;
114
+
115
+ // Update indent stack
116
+ const lastIndent = indentStack[indentStack.length - 1];
117
+ while (indentStack.length > 0 && lastIndent && lastIndent.indent >= indent) {
118
+ indentStack.pop();
119
+ }
120
+
121
+ // Build full key path
122
+ const keyPath = indentStack.length > 0 ? `${indentStack.map((s) => s.key).join(".")}.${key}` : key;
123
+
124
+ // Check for inline comment
125
+ const inlineCommentMatch = /#\s*(.+)$/.exec(currentLine);
126
+ if (inlineCommentMatch?.[1]) {
127
+ pendingComments.push({ text: inlineCommentMatch[1].trim(), indent });
128
+ }
129
+
130
+ // Filter pending comments to only those at the same or shallower indent level as this key
131
+ // This prevents comments from deeper nested properties being associated with a shallower property
132
+ const relevantComments = pendingComments.filter((c) => c.indent <= indent);
133
+
134
+ // Associate relevant comments with this key
135
+ if (relevantComments.length > 0) {
136
+ // Join and clean comments
137
+ const commentText = cleanYAMLComment(relevantComments.map((c) => c.text).join("\n"));
138
+ if (commentText) {
139
+ comments.set(keyPath, commentText);
140
+ }
141
+ }
142
+
143
+ // Clear pending comments after processing
144
+ pendingComments = [];
145
+
146
+ // Add to indent stack
147
+ indentStack.push({ indent, key });
148
+ } else {
149
+ // If we encounter a non-comment, non-key line, clear pending comments
150
+ // This handles list items and other YAML structures
151
+ pendingComments = [];
152
+ }
153
+ }
154
+
155
+ return comments;
156
+ }
package/src/config.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Well-known Kubernetes resource patterns.
3
+ * When a property matches these patterns, we augment the type with standard K8s fields.
4
+ */
5
+ export const K8S_RESOURCE_SPEC_PATTERN = {
6
+ /**
7
+ * Property names that indicate a Kubernetes resource spec (requests/limits pattern)
8
+ */
9
+ resourceSpecNames: ["resources"],
10
+ /**
11
+ * Required sibling properties for a valid resource spec
12
+ */
13
+ resourceSpecFields: ["requests", "limits"] as const,
14
+ };
15
+
16
+ /**
17
+ * Check if a property name indicates a Kubernetes resource spec.
18
+ */
19
+ export function isK8sResourceSpec(propertyName: string): boolean {
20
+ return K8S_RESOURCE_SPEC_PATTERN.resourceSpecNames.includes(propertyName.toLowerCase());
21
+ }
22
+
23
+ /**
24
+ * Configuration for types that should allow arbitrary additional properties.
25
+ * Maps chart names to array of key paths that should be extensible.
26
+ *
27
+ * These are typically config maps, RBAC policies, or other key-value stores
28
+ * where the schema doesn't enumerate all possible keys.
29
+ */
30
+ export const EXTENSIBLE_TYPE_PATTERNS: Record<string, string[]> = {
31
+ "argo-cd": [
32
+ "configs.cm", // Allows accounts.*, and other custom config
33
+ "configs.rbac", // Allows policy.*, custom RBAC rules
34
+ ],
35
+ "kube-prometheus-stack": [
36
+ "grafana", // Allows "grafana.ini" and other quoted config keys
37
+ "grafana.deploymentStrategy", // Allows Kubernetes Deployment strategy objects with flexible fields
38
+ "alertmanager.config.receivers", // Allows various *_configs (pagerduty_configs, etc.) on array elements
39
+ "prometheus-node-exporter", // Allows extraHostVolumeMounts and other node exporter specific configs
40
+ "prometheusNodeExporter", // Also support camelCase variant
41
+ ],
42
+ loki: [
43
+ "loki.limits_config", // Allows retention_period and other limit configs (note: underscore, not camelCase)
44
+ "loki.limitsConfig", // Also support camelCase variant
45
+ "compactor", // Allows various compactor settings
46
+ "minio.persistence", // Storage configs
47
+ ],
48
+ minecraft: [
49
+ "persistence", // Storage class and other persistence options
50
+ ],
51
+ openebs: [
52
+ "zfs-localpv", // ZFS-specific configs
53
+ ],
54
+ "postgres-operator": [
55
+ "configGeneral", // General config allows various settings
56
+ ],
57
+ chartmuseum: [
58
+ "persistence", // Storage options
59
+ ],
60
+ "intel-device-plugins-operator": [
61
+ // Root level for device-specific settings
62
+ "",
63
+ ],
64
+ "prometheus-adapter": [
65
+ "rules", // Allows resource, custom, external and other rule configurations
66
+ ],
67
+ velero: [
68
+ "kubectl.image", // Allows image configuration
69
+ ],
70
+ seaweedfs: [
71
+ "volume.dataDirs", // dataDirs elements support size, storageClass when type is persistentVolumeClaim
72
+ ],
73
+ };
74
+
75
+ /**
76
+ * Pattern-based detection for extensible types.
77
+ * Returns true if the property should allow arbitrary keys.
78
+ */
79
+ export function shouldAllowArbitraryProps(
80
+ keyPath: string,
81
+ chartName: string,
82
+ propertyName: string,
83
+ yamlComment?: string,
84
+ ): boolean {
85
+ // Check configured patterns for this chart
86
+ const patterns = EXTENSIBLE_TYPE_PATTERNS[chartName];
87
+ if (patterns) {
88
+ for (const pattern of patterns) {
89
+ if (pattern === keyPath || (pattern === "" && keyPath.split(".").length === 1)) {
90
+ return true;
91
+ }
92
+ // Also match if keyPath starts with pattern
93
+ if (pattern && keyPath.startsWith(`${pattern}.`)) {
94
+ return true;
95
+ }
96
+ }
97
+ }
98
+
99
+ // Pattern-based detection from property names
100
+ const lowerProp = propertyName.toLowerCase();
101
+ const lowerPath = keyPath.toLowerCase();
102
+
103
+ // Common names that suggest extensibility
104
+ const extensibleNames = [
105
+ "cm", // ConfigMap data
106
+ "data",
107
+ "config",
108
+ "configs",
109
+ "settings",
110
+ "parameters",
111
+ "options",
112
+ "extraenv",
113
+ "annotations",
114
+ "labels",
115
+ "nodeaffinity",
116
+ "toleration",
117
+ ];
118
+
119
+ if (extensibleNames.includes(lowerProp)) {
120
+ return true;
121
+ }
122
+
123
+ // Check for persistence/storage which often has provider-specific fields
124
+ if (lowerPath.includes("persistence") || lowerPath.includes("storage")) {
125
+ return true;
126
+ }
127
+
128
+ // Check YAML comments for hints
129
+ if (yamlComment) {
130
+ const commentLower = yamlComment.toLowerCase();
131
+ if (
132
+ /\b(arbitrary|custom|additional|extra|any)\s+(keys?|properties?|fields?|values?)\b/i.exec(commentLower) ||
133
+ /\bkey[\s-]?value\s+pairs?\b/i.exec(commentLower)
134
+ ) {
135
+ return true;
136
+ }
137
+ }
138
+
139
+ return false;
140
+ }
@@ -0,0 +1,46 @@
1
+ // Main entry point for Helm type generation
2
+ // This file re-exports all functionality from the modular components
3
+
4
+ // Core types
5
+ export type { ChartInfo, JSONSchemaProperty, TypeScriptInterface, TypeProperty } from "./types.js";
6
+
7
+ // Schemas
8
+ export type { HelmValue } from "./schemas.js";
9
+ export {
10
+ StringSchema,
11
+ ActualNumberSchema,
12
+ ActualBooleanSchema,
13
+ NullSchema,
14
+ UndefinedSchema,
15
+ ArraySchema,
16
+ RecordSchema,
17
+ ErrorSchema,
18
+ StringBooleanSchema,
19
+ HelmValueSchema,
20
+ } from "./schemas.js";
21
+
22
+ // Configuration
23
+ export { EXTENSIBLE_TYPE_PATTERNS, shouldAllowArbitraryProps } from "./config.js";
24
+
25
+ // Chart info parsing
26
+ export { parseChartInfoFromVersions } from "./chart-info-parser.js";
27
+
28
+ // YAML comments
29
+ export { cleanYAMLComment, parseYAMLComments } from "./yaml-comments.js";
30
+
31
+ // Chart fetching
32
+ export { fetchHelmChart } from "./chart-fetcher.js";
33
+
34
+ // Type conversion
35
+ export {
36
+ jsonSchemaToTypeScript,
37
+ inferTypeFromValue,
38
+ typesAreCompatible,
39
+ convertToTypeScriptInterface,
40
+ } from "./type-converter.js";
41
+
42
+ // Code generation
43
+ export { generateTypeScriptCode } from "./interface-generator.js";
44
+
45
+ // Utilities
46
+ export { sanitizePropertyName, sanitizeTypeName, capitalizeFirst } from "./utils.js";
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @homelab/helm-types
3
+ *
4
+ * A library for generating TypeScript types from Helm chart values.
5
+ *
6
+ * Core functionality:
7
+ * - Fetch Helm charts from repositories
8
+ * - Parse values.yaml and values.schema.json
9
+ * - Generate TypeScript interfaces with JSDoc comments
10
+ * - Support for nested objects, arrays, and unions
11
+ *
12
+ * This is a general-purpose library that can be used with any Helm chart.
13
+ * Application-specific logic should be kept in your application code.
14
+ */
15
+ export * from "./helm-types.ts";
@@ -0,0 +1,203 @@
1
+ import type { TypeScriptInterface } from "./types.js";
2
+ import { ArraySchema, RecordSchema, StringSchema, ActualNumberSchema, ActualBooleanSchema } from "./schemas.js";
3
+
4
+ /**
5
+ * Generate TypeScript code from interface definition
6
+ */
7
+ export function generateTypeScriptCode(mainInterface: TypeScriptInterface, chartName: string): string {
8
+ const interfaces: TypeScriptInterface[] = [];
9
+
10
+ // Collect all nested interfaces
11
+ collectNestedInterfaces(mainInterface, interfaces);
12
+
13
+ let code = `// Generated TypeScript types for ${chartName} Helm chart\n\n`;
14
+
15
+ // Generate all interfaces
16
+ for (const iface of interfaces) {
17
+ code += generateInterfaceCode(iface);
18
+ code += "\n";
19
+ }
20
+
21
+ // Generate parameter type (flattened dot notation)
22
+ code += generateParameterType(mainInterface, chartName);
23
+
24
+ // Check if any 'any' types were generated and add ESLint disable if needed
25
+ if (code.includes(": any")) {
26
+ code = `// Generated TypeScript types for ${chartName} Helm chart
27
+ /* eslint-disable @typescript-eslint/no-explicit-any */
28
+
29
+ ${code.substring(code.indexOf("\n\n") + 2)}`;
30
+ }
31
+
32
+ return code;
33
+ }
34
+
35
+ function collectNestedInterfaces(iface: TypeScriptInterface, collected: TypeScriptInterface[]): void {
36
+ for (const prop of Object.values(iface.properties)) {
37
+ if (prop.nested) {
38
+ collected.push(prop.nested);
39
+ collectNestedInterfaces(prop.nested, collected);
40
+ }
41
+ }
42
+
43
+ // Add main interface last so dependencies come first
44
+ if (!collected.some((i) => i.name === iface.name)) {
45
+ collected.push(iface);
46
+ }
47
+ }
48
+
49
+ function generateInterfaceCode(iface: TypeScriptInterface): string {
50
+ const hasProperties = Object.keys(iface.properties).length > 0;
51
+
52
+ if (!hasProperties && !iface.allowArbitraryProps) {
53
+ // Use 'object' for empty interfaces instead of '{}'
54
+ return `export type ${iface.name} = object;\n`;
55
+ }
56
+
57
+ let code = `export type ${iface.name} = {\n`;
58
+
59
+ // Add index signature if this type allows arbitrary properties
60
+ if (iface.allowArbitraryProps) {
61
+ code += ` /**\n`;
62
+ code += ` * This type allows arbitrary additional properties beyond those defined below.\n`;
63
+ code += ` * This is common for config maps, custom settings, and extensible configurations.\n`;
64
+ code += ` */\n`;
65
+ code += ` [key: string]: unknown;\n`;
66
+ }
67
+
68
+ for (const [key, prop] of Object.entries(iface.properties)) {
69
+ const optional = prop.optional ? "?" : "";
70
+
71
+ // Generate JSDoc comment if we have description or default
72
+ if (prop.description || prop.default !== undefined) {
73
+ code += ` /**\n`;
74
+
75
+ if (prop.description) {
76
+ // Format multi-line descriptions properly with " * " prefix
77
+ // Escape */ sequences to prevent premature comment closure
78
+ const escapedDescription = prop.description.replace(/\*\//g, "*\\/");
79
+ const descLines = escapedDescription.split("\n");
80
+ for (const line of descLines) {
81
+ code += ` * ${line}\n`;
82
+ }
83
+ }
84
+
85
+ if (prop.default !== undefined) {
86
+ const defaultStr = formatDefaultValue(prop.default);
87
+ if (defaultStr) {
88
+ if (prop.description) code += ` *\n`;
89
+ code += ` * @default ${defaultStr}\n`;
90
+ }
91
+ }
92
+
93
+ code += ` */\n`;
94
+ }
95
+
96
+ code += ` ${key}${optional}: ${prop.type};\n`;
97
+ }
98
+
99
+ code += "};\n";
100
+ return code;
101
+ }
102
+
103
+ /**
104
+ * Format a default value for display in JSDoc
105
+ */
106
+ function formatDefaultValue(value: unknown): string | null {
107
+ if (value === null) return "null";
108
+ if (value === undefined) return null;
109
+
110
+ // Handle arrays
111
+ const arrayCheck = ArraySchema.safeParse(value);
112
+ if (arrayCheck.success) {
113
+ if (arrayCheck.data.length === 0) return "[]";
114
+ if (arrayCheck.data.length <= 3) {
115
+ try {
116
+ return JSON.stringify(arrayCheck.data);
117
+ } catch {
118
+ return "[...]";
119
+ }
120
+ }
121
+ return `[...] (${String(arrayCheck.data.length)} items)`;
122
+ }
123
+
124
+ // Handle objects
125
+ const recordCheck = RecordSchema.safeParse(value);
126
+ if (recordCheck.success) {
127
+ const keys = Object.keys(recordCheck.data);
128
+ if (keys.length === 0) return "{}";
129
+ if (keys.length <= 3) {
130
+ try {
131
+ return JSON.stringify(recordCheck.data);
132
+ } catch {
133
+ return "{...}";
134
+ }
135
+ }
136
+ return `{...} (${String(keys.length)} keys)`;
137
+ }
138
+
139
+ // Primitives
140
+ const stringCheck = StringSchema.safeParse(value);
141
+ if (stringCheck.success) {
142
+ // Truncate long strings
143
+ if (stringCheck.data.length > 50) {
144
+ return `"${stringCheck.data.substring(0, 47)}..."`;
145
+ }
146
+ return `"${stringCheck.data}"`;
147
+ }
148
+
149
+ // Handle other primitives (numbers, booleans, etc.)
150
+ const numberCheck = ActualNumberSchema.safeParse(value);
151
+ if (numberCheck.success) {
152
+ return String(numberCheck.data);
153
+ }
154
+
155
+ const booleanCheck = ActualBooleanSchema.safeParse(value);
156
+ if (booleanCheck.success) {
157
+ return String(booleanCheck.data);
158
+ }
159
+
160
+ // Fallback for unknown types - try JSON.stringify
161
+ try {
162
+ return JSON.stringify(value);
163
+ } catch {
164
+ return "unknown";
165
+ }
166
+ }
167
+
168
+ function generateParameterType(iface: TypeScriptInterface, chartName: string): string {
169
+ const parameterKeys = flattenInterfaceKeys(iface);
170
+
171
+ const normalizedChartName = capitalizeFirst(chartName).replace(/-/g, "");
172
+ let code = `export type ${normalizedChartName}HelmParameters = {\n`;
173
+
174
+ for (const key of parameterKeys) {
175
+ code += ` "${key}"?: string;\n`;
176
+ }
177
+
178
+ code += "};\n";
179
+
180
+ return code;
181
+ }
182
+
183
+ function flattenInterfaceKeys(iface: TypeScriptInterface, prefix = ""): string[] {
184
+ const keys: string[] = [];
185
+
186
+ for (const [key, prop] of Object.entries(iface.properties)) {
187
+ // Remove quotes from key for parameter names
188
+ const cleanKey = key.replace(/"/g, "");
189
+ const fullKey = prefix ? `${prefix}.${cleanKey}` : cleanKey;
190
+
191
+ if (prop.nested) {
192
+ keys.push(...flattenInterfaceKeys(prop.nested, fullKey));
193
+ } else {
194
+ keys.push(fullKey);
195
+ }
196
+ }
197
+
198
+ return keys;
199
+ }
200
+
201
+ function capitalizeFirst(str: string): string {
202
+ return str.charAt(0).toUpperCase() + str.slice(1);
203
+ }
package/src/reset.d.ts ADDED
@@ -0,0 +1 @@
1
+ import "@total-typescript/ts-reset";
package/src/schemas.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Zod schemas for runtime validation
2
+ import { z } from "zod";
3
+
4
+ // Individual Zod schemas for type detection
5
+ export const StringSchema = z.string();
6
+ export const ActualNumberSchema = z.number(); // Runtime number
7
+ export const ActualBooleanSchema = z.boolean(); // Runtime boolean
8
+ export const NullSchema = z.null();
9
+ export const UndefinedSchema = z.undefined();
10
+ export const ArraySchema = z.array(z.unknown());
11
+ export const RecordSchema = z.record(z.string(), z.unknown());
12
+ export const ErrorSchema = z.instanceof(Error);
13
+
14
+ // Custom boolean string parser - only matches actual boolean-like strings
15
+ export const StringBooleanSchema = z.string().refine((val) => {
16
+ const lower = val.toLowerCase();
17
+ return lower === "true" || lower === "false" || lower === "yes" || lower === "no";
18
+ }, "Not a boolean string");
19
+
20
+ // Zod schema for validating YAML values - recursive definition with more flexibility
21
+ export const HelmValueSchema: z.ZodType<Record<string, unknown>> = z.lazy(() =>
22
+ z.record(
23
+ z.string(),
24
+ z.union([
25
+ z.string(),
26
+ z.number(),
27
+ z.boolean(),
28
+ z.null(),
29
+ z.undefined(),
30
+ z.array(z.unknown()), // Allow arrays of any type
31
+ HelmValueSchema,
32
+ z.unknown(), // Allow any other values as fallback
33
+ ]),
34
+ ),
35
+ );
36
+
37
+ export type HelmValue = z.infer<typeof HelmValueSchema>;