@shepherdjerred/helm-types 0.0.0-dev.706

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