timonel 2.8.3 → 2.9.1

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/dist/cli.js CHANGED
@@ -1,31 +1,34 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync, execSync } from 'child_process';
3
- import { mkdirSync, existsSync, writeFileSync, rmSync, unlinkSync } from 'fs';
4
- import { join, dirname, isAbsolute, resolve } from 'path';
2
+ import { spawnSync } from 'child_process';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
- import * as fs from 'fs';
7
- import * as path from 'path';
8
6
  import { SecurityUtils } from './lib/security.js';
9
7
  const __filename = fileURLToPath(import.meta.url);
10
- const __dirname = dirname(__filename);
11
- const HELM_INSTALL_URL = 'https://helm.sh/docs/intro/install/';
12
- const HELM_ENV_VAR_MSG = 'Or set HELM_BIN environment variable to helm binary path.';
8
+ const __dirname = path.dirname(__filename);
9
+ const UMBRELLA_CONFIG_FILE = 'umbrella.config.json';
10
+ const UMBRELLA_FILE_NAME = 'umbrella.ts';
11
+ function log(msg, silent = false) {
12
+ if (!silent) {
13
+ console.log(msg);
14
+ }
15
+ }
13
16
  function usageAndExit(msg) {
14
- if (msg)
15
- console.error(SecurityUtils.sanitizeLogMessage(msg));
17
+ if (msg) {
18
+ console.error(`Error: ${msg}`);
19
+ }
16
20
  console.log([
17
- 'timonel (tl) - programmatic Helm chart generator',
21
+ 'Usage: tl <command> [options]',
18
22
  '',
19
- 'Usage:',
20
- ' tl init <chart-name> Scaffold example at charts/<name>/chart.ts',
21
- ' tl synth <projectDir> [outDir] Run charts/<...>/chart.ts and write chart',
22
- ' tl validate <projectDir> Validate chart.ts without generating files',
23
- ' tl diff <projectDir> <chartDir> Compare generated chart with existing',
24
- ' tl deploy <projectDir> <release> Synth and deploy with helm install/upgrade',
25
- ' tl package <chartDir> [outDir] Run `helm package` into outDir (requires Helm)',
23
+ 'Commands:',
24
+ ' tl init <chart-name> Create new chart',
25
+ ' tl synth [outDir] Generate Helm chart',
26
+ ' tl validate Validate chart',
27
+ ' tl deploy <release> [namespace] Deploy chart',
28
+ ' tl templates List available templates',
26
29
  '',
27
30
  'Umbrella Charts:',
28
- ' tl umbrella init <name> Create umbrella chart structure',
31
+ ' tl umbrella init <n> Create umbrella chart structure',
29
32
  ' tl umbrella add <subchart> Add subchart to umbrella',
30
33
  ' tl umbrella synth [outDir] Generate umbrella chart',
31
34
  '',
@@ -34,17 +37,16 @@ function usageAndExit(msg) {
34
37
  ' --silent Suppress output (useful for CI)',
35
38
  ' --env <environment> Use environment-specific values',
36
39
  ' --set <key=value> Override values (can be used multiple times)',
37
- ' --version, -v Show version information',
38
40
  ' --help, -h Show this help message',
39
41
  '',
40
42
  'Examples:',
41
43
  ' tl init my-app',
42
- ' tl synth charts/my-app charts/my-app/dist',
43
- ' tl validate charts/my-app',
44
- ' tl deploy charts/my-app my-release --env prod',
45
- ' tl synth charts/my-app --dry-run --silent',
46
- ' tl synth charts/my-app --set replicas=5 --set image.tag=v2.0.0',
47
- ' tl deploy charts/my-app my-release --set service.port=8080',
44
+ ' tl synth my-app my-app/dist',
45
+ ' tl validate my-app',
46
+ ' tl deploy my-app my-release --env prod',
47
+ ' tl synth my-app --dry-run --silent',
48
+ ' tl synth my-app --set replicas=5 --set image.tag=v2.0.0',
49
+ ' tl deploy my-app my-release --set service.port=8080',
48
50
  ].join('\n'));
49
51
  process.exit(msg ? 1 : 0);
50
52
  }
@@ -53,100 +55,50 @@ async function cmdInit(name, silent = false) {
53
55
  usageAndExit('Missing <chart-name>');
54
56
  const validName = name;
55
57
  if (!SecurityUtils.isValidChartName(validName)) {
56
- usageAndExit('Invalid chart name: must be RFC 1123 compliant (lowercase alphanumeric with hyphens)');
58
+ usageAndExit('Invalid chart name. Must be lowercase, start with a letter, and contain only letters, numbers, and dashes.');
57
59
  }
58
- const sanitizedName = validName;
59
- const base = SecurityUtils.validatePath(join(process.cwd(), 'charts', sanitizedName), process.cwd());
60
- const file = join(base, 'chart.ts');
61
- mkdirSync(base, { recursive: true });
62
- if (existsSync(file)) {
63
- console.error(`File already exists: ${file}`);
64
- process.exit(1);
65
- }
66
- writeFileSync(file, exampleChartTs(validName));
67
- log(SecurityUtils.sanitizeLogMessage(`Scaffold created at ${file}`), silent);
60
+ const base = path.join(process.cwd(), validName);
61
+ const chartFile = path.join(base, 'chart.ts');
62
+ fs.mkdirSync(base, { recursive: true });
63
+ const { generateBasicChart } = await import('./lib/templates/basic-chart.js');
64
+ fs.writeFileSync(chartFile, generateBasicChart(validName));
65
+ log(`Chart created at ${base}`, silent);
66
+ log(`Generated chart.ts file`, silent);
67
+ log(`Run 'tl synth ${validName}' to generate complete Helm chart`, silent);
68
68
  }
69
- async function cmdValidate(projectDir, silent = false) {
70
- if (!projectDir)
71
- usageAndExit('Missing <projectDir>');
72
- const proj = projectDir;
73
- const basePath = isAbsolute(proj) ? dirname(proj) : process.cwd();
74
- const chartTs = isAbsolute(proj)
75
- ? SecurityUtils.validatePath(join(proj, 'chart.ts'), basePath)
76
- : SecurityUtils.validatePath(join(process.cwd(), proj, 'chart.ts'), process.cwd());
77
- if (!existsSync(chartTs)) {
78
- console.error(SecurityUtils.sanitizeLogMessage(`chart.ts not found at ${chartTs}`));
69
+ async function cmdSynth(chartDirOrOutDir, flags) {
70
+ let chartDir = process.cwd();
71
+ let outDir;
72
+ if (chartDirOrOutDir && fs.existsSync(path.join(chartDirOrOutDir, 'chart.ts'))) {
73
+ chartDir = path.resolve(chartDirOrOutDir);
74
+ }
75
+ else {
76
+ outDir = chartDirOrOutDir;
77
+ }
78
+ const chartFile = path.join(chartDir, 'chart.ts');
79
+ const defaultOutDir = path.join(chartDir, 'dist');
80
+ if (!fs.existsSync(chartFile)) {
81
+ console.error('chart.ts not found. Run `tl init` first.');
79
82
  process.exit(1);
80
83
  }
81
- const helm = process.env['HELM_BIN'] || 'helm';
82
- try {
83
- execSync(`${helm} version --short`, { stdio: 'ignore' });
84
- }
85
- catch {
86
- console.error('✗ Helm not found. Install Helm to enable chart validation.');
87
- console.error(` Install: ${HELM_INSTALL_URL}`);
88
- console.error(` ${HELM_ENV_VAR_MSG}`);
89
- process.exit(1);
90
- }
91
- const tempDir = join(process.cwd(), '.timonel-validate');
92
- try {
93
- await cmdSynth(projectDir, tempDir, { dryRun: false, silent: true, set: {} });
94
- const lintResult = spawnSync(helm, ['lint', tempDir], {
95
- stdio: silent ? 'pipe' : 'inherit',
96
- encoding: 'utf8',
97
- });
98
- if (lintResult.status !== 0) {
99
- if (silent && lintResult.stderr) {
100
- console.error(SecurityUtils.sanitizeLogMessage(lintResult.stderr));
101
- }
102
- console.error('✗ Chart validation failed: Helm lint errors found');
103
- process.exit(1);
104
- }
105
- log('✓ Chart validation passed', silent);
106
- }
107
- catch (error) {
108
- const errorMsg = error instanceof Error ? error.message : String(error);
109
- console.error('✗ Chart validation failed:', SecurityUtils.sanitizeLogMessage(errorMsg));
110
- process.exit(1);
111
- }
112
- finally {
113
- if (existsSync(tempDir)) {
114
- rmSync(tempDir, { recursive: true, force: true });
115
- }
116
- }
117
- }
118
- async function executeTypeScriptChart(resolvedPath, outDir, flags) {
84
+ const resolvedOutDir = outDir ? path.resolve(outDir) : defaultOutDir;
85
+ const originalContent = fs.readFileSync(chartFile, 'utf8');
86
+ const modifiedContent = originalContent.replace(/chart\.writeHelmChart\(['"][^'"]*['"]\)/, `chart.writeHelmChart('${resolvedOutDir}')`);
87
+ const tempChartFile = path.join(chartDir, '.timonel-temp-chart.ts');
88
+ fs.writeFileSync(tempChartFile, modifiedContent);
119
89
  const wrapperScript = `
120
90
  import { pathToFileURL } from 'url';
121
91
 
122
- const mod = await import(pathToFileURL('${resolvedPath}').href);
123
- const runner = mod.default || mod.run || mod.synth;
124
- if (typeof runner !== 'function') {
125
- console.error('chart.ts must export a default/run/synth function');
126
- process.exit(1);
127
- }
128
-
129
- ${flags?.set && Object.keys(flags.set).length > 0
130
- ? `
131
- // Apply --set overrides if provided
132
- if (mod.rutter && typeof mod.rutter.setValues === 'function') {
133
- mod.rutter.setValues(${JSON.stringify(flags.set)});
134
- }
135
- `
136
- : ''}
137
-
138
- const outDir = '${outDir}';
139
- const fs = await import('fs');
140
- fs.mkdirSync(outDir, { recursive: true });
141
- await Promise.resolve(runner(outDir));
142
- console.log('Chart written to ' + outDir);
92
+ // Import the modified chart file which should execute the synthesis directly
93
+ await import(pathToFileURL('${tempChartFile}').href);
143
94
  `;
144
- const wrapperFile = join(process.cwd(), '.timonel-wrapper.mjs');
95
+ const wrapperFile = path.join(chartDir, '.timonel-wrapper.mjs');
145
96
  try {
146
- writeFileSync(wrapperFile, wrapperScript);
147
- const result = spawnSync('npx', ['tsx', wrapperFile], {
97
+ fs.writeFileSync(wrapperFile, wrapperScript);
98
+ const result = spawnSync('npx', ['tsx', wrapperFile].filter(Boolean), {
148
99
  stdio: flags?.silent ? 'pipe' : 'inherit',
149
100
  encoding: 'utf8',
101
+ cwd: chartDir,
150
102
  });
151
103
  if (result.status !== 0) {
152
104
  if (flags?.silent && result.stderr) {
@@ -156,118 +108,77 @@ console.log('Chart written to ' + outDir);
156
108
  }
157
109
  }
158
110
  finally {
159
- if (existsSync(wrapperFile)) {
160
- unlinkSync(wrapperFile);
161
- }
162
- }
163
- }
164
- async function cmdSynth(projectDir, out, flags) {
165
- if (!projectDir)
166
- usageAndExit('Missing <projectDir>');
167
- const proj = projectDir;
168
- const basePath = isAbsolute(proj) ? dirname(proj) : process.cwd();
169
- const chartTs = isAbsolute(proj)
170
- ? SecurityUtils.validatePath(join(proj, 'chart.ts'), basePath)
171
- : SecurityUtils.validatePath(join(process.cwd(), proj, 'chart.ts'), process.cwd());
172
- if (!existsSync(chartTs)) {
173
- console.error(SecurityUtils.sanitizeLogMessage(`chart.ts not found at ${chartTs}`));
174
- process.exit(1);
175
- }
176
- if (!SecurityUtils.isValidTypeScriptFile(chartTs)) {
177
- console.error('Security: Only TypeScript files (.ts) are allowed');
178
- process.exit(1);
179
- }
180
- const resolvedPath = resolve(chartTs);
181
- const outDir = out || join(dirname(chartTs), 'dist');
182
- if (flags?.dryRun) {
183
- log(`[DRY RUN] Would write chart to ${outDir}`, flags.silent);
184
- return;
185
- }
186
- try {
187
- await executeTypeScriptChart(resolvedPath, outDir, flags);
188
- }
189
- catch (error) {
190
- const errorMsg = error instanceof Error ? error.message : String(error);
191
- console.error(SecurityUtils.sanitizeLogMessage(`Failed to execute chart: ${errorMsg}`));
192
- process.exit(1);
193
- }
194
- }
195
- async function cmdDiff(projectDir, chartDir, silent = false) {
196
- if (!projectDir || !chartDir)
197
- usageAndExit('Missing <projectDir> or <chartDir>');
198
- const validProjectDir = projectDir;
199
- const validChartDir = chartDir;
200
- const tempDir = join(process.cwd(), '.timonel-temp');
201
- try {
202
- await cmdSynth(validProjectDir, tempDir);
203
- const diffCmd = process.platform === 'win32' ? 'fc' : 'diff';
204
- const args = process.platform === 'win32'
205
- ? ['/N', validChartDir, tempDir]
206
- : ['-r', validChartDir, tempDir];
207
- log(`> ${diffCmd} ${args.join(' ')}`, silent);
208
- const res = spawnSync(diffCmd, args, { stdio: 'inherit' });
209
- if (res.status === 0) {
210
- log('✓ No differences found', silent);
111
+ if (fs.existsSync(wrapperFile)) {
112
+ fs.unlinkSync(wrapperFile);
211
113
  }
212
- else if (res.status === 1) {
213
- log('⚠ Differences found', silent);
214
- process.exit(1);
114
+ if (fs.existsSync(tempChartFile)) {
115
+ fs.unlinkSync(tempChartFile);
215
116
  }
216
117
  }
217
- finally {
218
- if (existsSync(tempDir)) {
219
- rmSync(tempDir, { recursive: true, force: true });
118
+ }
119
+ async function cmdValidate(flags) {
120
+ const result = spawnSync('helm', ['lint', '.'], {
121
+ stdio: flags?.silent ? 'pipe' : 'inherit',
122
+ encoding: 'utf8',
123
+ });
124
+ if (result.status !== 0) {
125
+ if (flags?.silent && result.stderr) {
126
+ console.error(SecurityUtils.sanitizeLogMessage(result.stderr));
220
127
  }
128
+ process.exit(result.status ?? 1);
221
129
  }
222
130
  }
223
- async function cmdDeploy(projectDir, releaseName, flags) {
224
- if (!projectDir || !releaseName)
225
- usageAndExit('Missing <projectDir> or <release>');
226
- const validProjectDir = projectDir;
227
- const validReleaseName = releaseName;
228
- const helm = process.env['HELM_BIN'] || 'helm';
229
- try {
230
- execSync(`${helm} version --short`, { stdio: 'ignore' });
231
- }
232
- catch {
233
- console.error('✗ Helm not found. Install Helm to enable deployment.');
234
- console.error(` Install: ${HELM_INSTALL_URL}`);
235
- console.error(` ${HELM_ENV_VAR_MSG}`);
236
- process.exit(1);
237
- }
238
- const tempDir = join(process.cwd(), '.timonel-deploy');
239
- try {
240
- await cmdSynth(validProjectDir, tempDir, flags);
241
- const checkCmd = `${helm} status ${releaseName}`;
242
- const releaseExists = spawnSync('sh', ['-c', checkCmd], { stdio: 'ignore' }).status === 0;
243
- const action = releaseExists ? 'upgrade' : 'install';
244
- const args = [action, validReleaseName, tempDir];
245
- if (flags?.env) {
246
- const valuesFile = join(tempDir, `values-${flags.env}.yaml`);
247
- if (existsSync(valuesFile)) {
248
- args.push('-f', valuesFile);
249
- }
250
- }
251
- if (flags?.dryRun) {
252
- args.push('--dry-run');
253
- }
254
- log(`> ${helm} ${args.join(' ')}`, flags?.silent);
255
- const res = spawnSync(helm, args, { stdio: 'inherit' });
256
- if (res.status !== 0) {
257
- process.exit(res.status ?? 1);
131
+ async function cmdDeploy(release, namespace, flags) {
132
+ if (!release)
133
+ usageAndExit('Missing <release>');
134
+ const args = ['upgrade', '--install', release, '.'];
135
+ if (namespace) {
136
+ args.push('--namespace', namespace);
137
+ }
138
+ const result = spawnSync('helm', args.filter((arg) => Boolean(arg)), {
139
+ stdio: flags?.silent ? 'pipe' : 'inherit',
140
+ encoding: 'utf8',
141
+ });
142
+ if (result.status !== 0) {
143
+ if (flags?.silent && result.stderr) {
144
+ console.error(SecurityUtils.sanitizeLogMessage(result.stderr));
258
145
  }
259
- log(`✓ ${action === 'install' ? 'Deployed' : 'Updated'} release ${validReleaseName}`, flags?.silent);
146
+ process.exit(result.status ?? 1);
260
147
  }
261
- finally {
262
- if (existsSync(tempDir)) {
263
- rmSync(tempDir, { recursive: true, force: true });
264
- }
148
+ }
149
+ async function cmdTemplates(flags) {
150
+ const templates = [
151
+ {
152
+ name: 'basic-chart',
153
+ description: 'Basic chart with deployment and service',
154
+ usage: 'tl init my-app',
155
+ },
156
+ {
157
+ name: 'umbrella-chart',
158
+ description: 'Umbrella chart for managing multiple subcharts',
159
+ usage: 'tl umbrella init my-app',
160
+ },
161
+ {
162
+ name: 'subchart',
163
+ description: 'Subchart for use with umbrella charts',
164
+ usage: 'tl umbrella add my-subchart',
165
+ },
166
+ ];
167
+ if (flags?.silent) {
168
+ console.log(JSON.stringify(templates));
169
+ }
170
+ else {
171
+ console.log('Available templates:');
172
+ templates.forEach((t) => {
173
+ console.log(`\n${t.name}`);
174
+ console.log(` Description: ${t.description}`);
175
+ console.log(` Usage: ${t.usage}`);
176
+ });
265
177
  }
266
178
  }
267
179
  async function cmdUmbrella(subcommand, args, flags) {
268
- const UMBRELLA_USAGE_MSG = 'Missing umbrella subcommand (init|add|synth)';
269
180
  if (!subcommand)
270
- usageAndExit(UMBRELLA_USAGE_MSG);
181
+ usageAndExit('Missing umbrella subcommand');
271
182
  switch (subcommand) {
272
183
  case 'init':
273
184
  await cmdUmbrellaInit(args?.[0], flags?.silent);
@@ -282,8 +193,6 @@ async function cmdUmbrella(subcommand, args, flags) {
282
193
  usageAndExit(`Unknown umbrella subcommand: ${subcommand}`);
283
194
  }
284
195
  }
285
- const UMBRELLA_CONFIG_FILE = 'umbrella.config.json';
286
- const UMBRELLA_FILE_NAME = 'umbrella.ts';
287
196
  async function cmdUmbrellaInit(name, silent = false) {
288
197
  const MISSING_NAME_MSG = 'Missing umbrella chart name';
289
198
  if (!name)
@@ -293,64 +202,91 @@ async function cmdUmbrellaInit(name, silent = false) {
293
202
  const umbrellaFile = path.join(base, UMBRELLA_FILE_NAME);
294
203
  const configFile = path.join(base, UMBRELLA_CONFIG_FILE);
295
204
  fs.mkdirSync(base, { recursive: true });
296
- fs.mkdirSync(path.join(base, 'charts'), { recursive: true });
297
- fs.writeFileSync(umbrellaFile, exampleUmbrellaTs(validName));
205
+ const { generateUmbrellaChart } = await import('./lib/templates/umbrella-chart.js');
206
+ fs.writeFileSync(umbrellaFile, generateUmbrellaChart(validName));
298
207
  const config = {
299
208
  name: validName,
300
209
  version: '0.1.0',
301
- description: `${name} umbrella chart`,
210
+ description: `${validName} umbrella chart`,
302
211
  subcharts: [],
303
212
  };
304
213
  fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
214
+ const chartsDir = path.join(base, 'charts');
215
+ fs.mkdirSync(chartsDir, { recursive: true });
305
216
  log(`Umbrella chart structure created at ${base}`, silent);
306
217
  log(`Add subcharts with: tl umbrella add <subchart-name>`, silent);
307
218
  }
308
- function updateUmbrellaTs(subchartPath, subchartName) {
309
- const umbrellaFile = path.join(process.cwd(), UMBRELLA_FILE_NAME);
310
- if (!fs.existsSync(umbrellaFile)) {
311
- console.error('umbrella.ts not found.');
312
- return;
313
- }
314
- let content = fs.readFileSync(umbrellaFile, 'utf8');
315
- const chartName = subchartName || path.basename(subchartPath);
316
- const camelCaseName = chartName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
317
- const importStatement = `import { rutter as ${camelCaseName} } from './charts/${subchartPath}/chart';`;
318
- const importRegex = /import\s+.*?from\s+['"].*?['"];?/g;
319
- const imports = content.match(importRegex) || [];
320
- if (imports.length > 0) {
321
- const lastImport = imports[imports.length - 1];
322
- if (!lastImport) {
323
- throw new Error('No valid import statements found');
219
+ function toCamelCase(str) {
220
+ return str.replace(/-([a-z])/g, (g) => g[1]?.toUpperCase() || '');
221
+ }
222
+ function findLastImportIndex(lines) {
223
+ let lastImportIndex = -1;
224
+ for (let i = 0; i < lines.length; i++) {
225
+ if (lines[i]?.trim().startsWith('import ')) {
226
+ lastImportIndex = i;
324
227
  }
325
- const lastImportIndex = content.lastIndexOf(lastImport);
326
- const insertIndex = lastImportIndex + lastImport.length;
327
- if (!content.includes(importStatement)) {
328
- content = content.slice(0, insertIndex) + '\n' + importStatement + content.slice(insertIndex);
228
+ else if (lines[i]?.trim() && !lines[i]?.trim().startsWith('import ')) {
229
+ break;
329
230
  }
330
231
  }
232
+ return lastImportIndex;
233
+ }
234
+ function addImportStatement(content, importStatement) {
235
+ if (content.includes(importStatement)) {
236
+ return content;
237
+ }
238
+ const lines = content.split('\n');
239
+ const lastImportIndex = findLastImportIndex(lines);
240
+ if (lastImportIndex >= 0) {
241
+ lines.splice(lastImportIndex + 1, 0, importStatement);
242
+ }
243
+ else {
244
+ lines.splice(0, 0, importStatement);
245
+ }
246
+ return lines.join('\n');
247
+ }
248
+ function buildSubchartsContent(subchartsContent, subchartEntry) {
249
+ const hasExistingEntries = /\{\s*name:\s*['"]/.test(subchartsContent || '');
250
+ if (!hasExistingEntries) {
251
+ return `\n // Add your subcharts here:\n ${subchartEntry},\n `;
252
+ }
253
+ const trimmedContent = subchartsContent?.trimEnd() ?? '';
254
+ const needsComma = !trimmedContent.endsWith(',');
255
+ const comma = needsComma ? ',' : '';
256
+ return trimmedContent + `${comma}\n ${subchartEntry}`;
257
+ }
258
+ function addSubchartToArray(content, chartName, camelCaseName) {
331
259
  const subchartsRegex = /subcharts:\s*\[([\s\S]*?)\]/;
332
260
  const match = content.match(subchartsRegex);
333
- if (match) {
334
- const subchartsContent = match[1];
335
- if (subchartsContent === undefined) {
336
- throw new Error('Unable to parse subcharts content');
337
- }
338
- if (!subchartsContent.includes(`name: '${chartName}'`)) {
339
- let newSubchartsContent;
340
- const subchartEntry = `{ name: '${chartName}', rutter: ${camelCaseName} }`;
341
- const hasExistingEntries = /\{\s*name:\s*['"]/.test(subchartsContent);
342
- if (!hasExistingEntries) {
343
- newSubchartsContent = `\n // Add your subcharts here:\n ${subchartEntry},\n `;
344
- }
345
- else {
346
- const trimmedContent = subchartsContent.trimEnd();
347
- newSubchartsContent = trimmedContent + `,\n ${subchartEntry}`;
348
- }
349
- content = content.replace(subchartsRegex, `subcharts: [${newSubchartsContent}\n ]`);
350
- }
261
+ if (!match) {
262
+ return content;
351
263
  }
264
+ const subchartsContent = match[1];
265
+ if (subchartsContent?.includes(`name: '${chartName}'`)) {
266
+ return content;
267
+ }
268
+ const subchartEntry = `{ name: '${chartName}', chart: ${camelCaseName} }`;
269
+ const newSubchartsContent = buildSubchartsContent(subchartsContent, subchartEntry);
270
+ return content.replace(subchartsRegex, `subcharts: [${newSubchartsContent}\n ]`);
271
+ }
272
+ function updateUmbrellaTs(subchartPath, chartName) {
273
+ const umbrellaFile = path.join(process.cwd(), UMBRELLA_FILE_NAME);
274
+ const content = processUmbrellaFile(umbrellaFile, subchartPath, chartName);
352
275
  fs.writeFileSync(umbrellaFile, content);
353
276
  }
277
+ function processUmbrellaFile(umbrellaFile, subchartPath, chartName) {
278
+ let content = fs.readFileSync(umbrellaFile, 'utf8');
279
+ const importData = createImportData(subchartPath, chartName);
280
+ content = addImportStatement(content, importData.importStatement);
281
+ content = addSubchartToArray(content, chartName, importData.camelCaseName);
282
+ return content;
283
+ }
284
+ function createImportData(subchartPath, chartName) {
285
+ const importPath = `./charts/${subchartPath}/chart`;
286
+ const camelCaseName = toCamelCase(chartName);
287
+ const importStatement = `import ${camelCaseName} from '${importPath}';`;
288
+ return { importPath, camelCaseName, importStatement };
289
+ }
354
290
  async function cmdUmbrellaAdd(subchartPath, silent = false) {
355
291
  const MISSING_SUBCHART_MSG = 'Missing subchart name or path';
356
292
  if (!subchartPath)
@@ -366,7 +302,9 @@ async function cmdUmbrellaAdd(subchartPath, silent = false) {
366
302
  const subchartDir = path.join(process.cwd(), 'charts', validSubchartPath);
367
303
  fs.mkdirSync(subchartDir, { recursive: true });
368
304
  const chartFile = path.join(subchartDir, 'chart.ts');
369
- fs.writeFileSync(chartFile, exampleSubchartTs(subchartName));
305
+ const { generateSubchartTemplate } = await import('./lib/templates/subchart.js');
306
+ const subchartContent = generateSubchartTemplate(subchartName);
307
+ fs.writeFileSync(chartFile, subchartContent);
370
308
  config.subcharts.push({
371
309
  name: subchartName,
372
310
  version: '0.1.0',
@@ -376,6 +314,16 @@ async function cmdUmbrellaAdd(subchartPath, silent = false) {
376
314
  updateUmbrellaTs(validSubchartPath, subchartName);
377
315
  log(`Subchart ${subchartName} added to umbrella at path '${validSubchartPath}'`, silent);
378
316
  }
317
+ async function cmdUmbrellaSynth(outDir, flags) {
318
+ const umbrellaFile = path.join(process.cwd(), UMBRELLA_FILE_NAME);
319
+ const defaultOutDir = path.join(process.cwd(), 'dist');
320
+ if (!fs.existsSync(umbrellaFile)) {
321
+ console.error('umbrella.ts not found. Run `tl umbrella init` first.');
322
+ process.exit(1);
323
+ }
324
+ const resolvedOutDir = outDir ? path.resolve(outDir) : defaultOutDir;
325
+ await executeTypeScriptUmbrella(umbrellaFile, resolvedOutDir, flags);
326
+ }
379
327
  async function executeTypeScriptUmbrella(resolvedPath, outDir, flags) {
380
328
  const wrapperScript = `
381
329
  import { pathToFileURL } from 'url';
@@ -396,7 +344,7 @@ console.log('Umbrella chart written to ' + output);
396
344
  const wrapperFile = path.join(process.cwd(), '.timonel-umbrella-wrapper.mjs');
397
345
  try {
398
346
  fs.writeFileSync(wrapperFile, wrapperScript);
399
- const result = spawnSync('npx', ['tsx', wrapperFile], {
347
+ const result = spawnSync('npx', ['tsx', wrapperFile].filter(Boolean), {
400
348
  stdio: flags?.silent ? 'pipe' : 'inherit',
401
349
  encoding: 'utf8',
402
350
  });
@@ -413,453 +361,80 @@ console.log('Umbrella chart written to ' + output);
413
361
  }
414
362
  }
415
363
  }
416
- async function cmdUmbrellaSynth(outDir, flags) {
417
- const configFile = path.join(process.cwd(), UMBRELLA_CONFIG_FILE);
418
- if (!fs.existsSync(configFile)) {
419
- console.error(`${UMBRELLA_CONFIG_FILE} not found. Run \`tl umbrella init\` first.`);
420
- process.exit(1);
421
- }
422
- const umbrellaFile = path.join(process.cwd(), UMBRELLA_FILE_NAME);
423
- if (!fs.existsSync(umbrellaFile)) {
424
- console.error('umbrella.ts not found.');
425
- process.exit(1);
426
- }
427
- if (!SecurityUtils.isValidTypeScriptFile(umbrellaFile)) {
428
- console.error('Security: Only TypeScript files (.ts) are allowed');
429
- process.exit(1);
430
- }
431
- const validatedPath = SecurityUtils.validatePath(umbrellaFile, process.cwd());
432
- const resolvedPath = path.resolve(validatedPath);
433
- const output = outDir || path.join(process.cwd(), 'dist');
434
- if (flags?.dryRun) {
435
- log(`[DRY RUN] Would write umbrella chart to ${output}`, flags.silent);
436
- return;
437
- }
438
- try {
439
- await executeTypeScriptUmbrella(resolvedPath, output, flags);
440
- }
441
- catch (error) {
442
- const errorMsg = error instanceof Error ? error.message : String(error);
443
- console.error(SecurityUtils.sanitizeLogMessage(`Failed to execute umbrella: ${errorMsg}`));
444
- process.exit(1);
445
- }
446
- }
447
- async function cmdPackage(chartDir, out, silent = false) {
448
- if (!chartDir)
449
- usageAndExit('Missing <chartDir>');
450
- const validChartDir = chartDir;
451
- const src = path.isAbsolute(validChartDir)
452
- ? validChartDir
453
- : path.join(process.cwd(), validChartDir);
454
- const chartYaml = path.join(src, 'Chart.yaml');
455
- if (!fs.existsSync(chartYaml)) {
456
- console.error(`Chart.yaml not found in ${src}`);
457
- process.exit(1);
458
- }
459
- const outDir = out ? (path.isAbsolute(out) ? out : path.join(process.cwd(), out)) : src;
460
- fs.mkdirSync(outDir, { recursive: true });
461
- const helm = process.env['HELM_BIN'] || 'helm';
462
- const args = ['package', src, '-d', outDir];
463
- log(`> ${helm} ${args.join(' ')}`, silent);
464
- const res = spawnSync(helm, args, { stdio: 'inherit' });
465
- if (res.error) {
466
- console.error(`✗ Failed to execute Helm: ${res.error.message}`);
467
- console.error(` Install: ${HELM_INSTALL_URL}`);
468
- console.error(` ${HELM_ENV_VAR_MSG}`);
469
- process.exit(1);
470
- }
471
- if (res.status !== 0) {
472
- process.exit(res.status ?? 1);
473
- }
474
- }
475
- function exampleUmbrellaTs(name) {
476
- return `import { createUmbrella } from 'timonel';
477
- import { Rutter } from 'timonel';
478
-
479
- // Import subcharts (these will be created with 'tl umbrella add')
480
- // import { rutter as fluentBit } from './charts/fluent-bit/chart';
481
- // import { rutter as openObserve } from './charts/openobserve/chart';
482
-
483
- // Create umbrella chart
484
- const umbrella = createUmbrella({
485
- meta: {
486
- name: '${name}',
487
- version: '0.1.0',
488
- description: '${name} umbrella chart',
489
- appVersion: '1.0.0',
490
- },
491
- subcharts: [
492
- // Add your subcharts here:
493
- // { name: 'fluent-bit', rutter: fluentBit },
494
- // { name: 'openobserve', rutter: openObserve },
495
- ],
496
- defaultValues: {
497
- global: {
498
- // Global values shared across subcharts
499
- },
500
- },
501
- envValues: {
502
- dev: {
503
- // Development environment overrides
504
- },
505
- prod: {
506
- // Production environment overrides
507
- },
508
- },
509
- });
510
-
511
- export default function run(outDir: string) {
512
- umbrella.write(outDir);
513
- }
514
- `;
515
- }
516
- function exampleSubchartTs(name) {
517
- return `import { Rutter } from 'timonel';
518
- import { valuesRef, helm } from 'timonel/lib/helm';
519
-
520
- // Create a new Rutter instance
521
- const rutter = new Rutter({
522
- meta: {
523
- name: '${name}',
524
- version: '0.1.0',
525
- description: '${name} subchart',
526
- appVersion: '1.0.0',
527
- },
528
- defaultValues: {
529
- image: { repository: 'nginx', tag: '1.27' },
530
- replicas: 1,
531
- service: { port: 80 },
532
- },
533
- });
534
-
535
- // Add Deployment using addManifest with native Kubernetes object
536
- rutter.addManifest({
537
- apiVersion: 'apps/v1',
538
- kind: 'Deployment',
539
- metadata: {
540
- name: '${name}',
541
- labels: {
542
- app: '${name}'
543
- }
544
- },
545
- spec: {
546
- replicas: valuesRef('replicas'),
547
- selector: {
548
- matchLabels: {
549
- app: '${name}'
550
- }
551
- },
552
- template: {
553
- metadata: {
554
- labels: {
555
- app: '${name}'
556
- }
557
- },
558
- spec: {
559
- containers: [
560
- {
561
- name: '${name}',
562
- image: String(valuesRef('image.repository')) + ':' + String(valuesRef('image.tag')),
563
- ports: [
564
- {
565
- name: 'http',
566
- containerPort: 80,
567
- protocol: 'TCP'
568
- }
569
- ]
364
+ function parseFlags(args) {
365
+ const flags = {};
366
+ while (args.length > 0 && args[0]?.startsWith('-')) {
367
+ const flag = args.shift();
368
+ switch (flag) {
369
+ case '--dry-run':
370
+ flags.dryRun = true;
371
+ break;
372
+ case '--silent':
373
+ flags.silent = true;
374
+ break;
375
+ case '--env': {
376
+ const envValue = args.shift();
377
+ if (envValue)
378
+ flags.env = envValue;
379
+ break;
570
380
  }
571
- ]
572
- }
573
- }
574
- }
575
- }, '${name}-deployment');
576
-
577
- // Add Service using addManifest with native Kubernetes object
578
- rutter.addManifest({
579
- apiVersion: 'v1',
580
- kind: 'Service',
581
- metadata: {
582
- name: '${name}',
583
- labels: {
584
- app: '${name}'
585
- }
586
- },
587
- spec: {
588
- type: 'ClusterIP',
589
- ports: [
590
- {
591
- port: valuesRef('service.port'),
592
- targetPort: 'http',
593
- protocol: 'TCP',
594
- name: 'http'
595
- }
596
- ],
597
- selector: {
598
- app: '${name}'
599
- }
600
- }
601
- }, '${name}-service');
602
-
603
- export default function run(outDir: string) {
604
- rutter.write(outDir);
605
- }
606
-
607
- // Export rutter for umbrella chart
608
- export { rutter };
609
- `;
610
- }
611
- function exampleChartTs(name) {
612
- return `import { Rutter } from 'timonel';
613
- import { valuesRef, helm } from 'timonel/lib/helm';
614
-
615
- // Define chart metadata and default/env values
616
- const rutter = new Rutter({
617
- meta: {
618
- name: '${name}',
619
- version: '0.1.0',
620
- description: 'Example Helm chart generated with timonel + cdk8s',
621
- appVersion: '1.0.0',
622
- },
623
- defaultValues: {
624
- image: { repository: 'nginx', tag: '1.27' },
625
- replicas: 1,
626
- service: { port: 80 },
627
- ingress: { enabled: false, host: 'example.com', className: 'nginx' },
628
- namespace: { create: false, name: '${name}' },
629
- },
630
- envValues: {
631
- dev: { replicas: 1 },
632
- prod: { replicas: 3 },
633
- },
634
- });
635
-
636
- // Add Deployment using addManifest with native Kubernetes object
637
- rutter.addManifest({
638
- apiVersion: 'apps/v1',
639
- kind: 'Deployment',
640
- metadata: {
641
- name: '${name}',
642
- labels: {
643
- app: '${name}'
644
- }
645
- },
646
- spec: {
647
- replicas: valuesRef('replicas'),
648
- selector: {
649
- matchLabels: {
650
- app: '${name}'
651
- }
652
- },
653
- template: {
654
- metadata: {
655
- labels: {
656
- app: '${name}'
657
- }
658
- },
659
- spec: {
660
- containers: [
661
- {
662
- name: '${name}',
663
- image: String(valuesRef('image.repository')) + ':' + String(valuesRef('image.tag')),
664
- ports: [
665
- {
666
- name: 'http',
667
- containerPort: 80,
668
- protocol: 'TCP'
669
- }
670
- ],
671
- env: [
672
- {
673
- name: 'APP_NAME',
674
- value: '${name}'
675
- },
676
- {
677
- name: 'RELEASE',
678
- value: helm.releaseName
679
- }
680
- ]
681
- }
682
- ]
683
- }
684
- }
685
- }
686
- }, '${name}-deployment');
687
-
688
- // Add Service using addManifest with native Kubernetes object
689
- rutter.addManifest({
690
- apiVersion: 'v1',
691
- kind: 'Service',
692
- metadata: {
693
- name: '${name}',
694
- labels: {
695
- app: '${name}'
696
- }
697
- },
698
- spec: {
699
- type: 'ClusterIP',
700
- ports: [
701
- {
702
- port: valuesRef('service.port'),
703
- targetPort: 'http',
704
- protocol: 'TCP',
705
- name: 'http'
706
- }
707
- ],
708
- selector: {
709
- app: '${name}'
710
- }
711
- }
712
- }, '${name}-service');
713
-
714
- // Add Ingress using addConditionalManifest with native Kubernetes object
715
- rutter.addConditionalManifest({
716
- apiVersion: 'networking.k8s.io/v1',
717
- kind: 'Ingress',
718
- metadata: {
719
- name: '${name}',
720
- labels: {
721
- app: '${name}'
722
- }
723
- },
724
- spec: {
725
- ingressClassName: String(valuesRef('ingress.className')),
726
- rules: [
727
- {
728
- host: String(valuesRef('ingress.host')),
729
- http: {
730
- paths: [
731
- {
732
- path: '/',
733
- pathType: 'Prefix',
734
- backend: {
735
- service: {
736
- name: '${name}',
737
- port: {
738
- number: valuesRef('service.port')
739
- }
740
- }
741
- }
742
- }
743
- ]
744
- }
745
- }
746
- ]
747
- }
748
- }, valuesRef('ingress.enabled'), '${name}-ingress');
749
-
750
- // Add Namespace using addConditionalManifest
751
- rutter.addConditionalManifest({
752
- apiVersion: 'v1',
753
- kind: 'Namespace',
754
- metadata: {
755
- name: String(valuesRef('namespace.name')),
756
- labels: {
757
- app: '${name}',
758
- 'app.kubernetes.io/name': '${name}',
759
- 'app.kubernetes.io/instance': helm.releaseName
760
- }
761
- }
762
- }, valuesRef('namespace.create'), '${name}-namespace');
763
-
764
- export default function run(outDir: string) {
765
- rutter.write(outDir);
766
- }
767
- `;
768
- }
769
- function parseArgs() {
770
- const argv = process.argv.slice(2);
771
- const flags = { dryRun: false, silent: false, set: {} };
772
- const args = [];
773
- let cmd = '';
774
- for (let i = 0; i < argv.length; i++) {
775
- const arg = argv[i];
776
- if (arg === '--dry-run') {
777
- flags.dryRun = true;
778
- }
779
- else if (arg === '--silent') {
780
- flags.silent = true;
781
- }
782
- else if (arg === '--env' && i + 1 < argv.length) {
783
- const nextArg = argv[++i];
784
- if (nextArg)
785
- flags.env = nextArg;
786
- }
787
- else if (arg === '--version-bump' && i + 1 < argv.length) {
788
- const nextArg = argv[++i];
789
- if (nextArg)
790
- flags.versionBump = nextArg;
791
- }
792
- else if (arg === '--set' && i + 1 < argv.length) {
793
- const setValue = argv[++i];
794
- if (setValue) {
795
- const [key, value] = setValue.split('=', 2);
796
- if (key && value !== undefined) {
797
- flags.set[key] = value;
381
+ case '--set': {
382
+ const setValue = args.shift();
383
+ if (setValue) {
384
+ flags.set = flags.set || [];
385
+ flags.set.push(setValue);
798
386
  }
387
+ break;
799
388
  }
389
+ case '--version':
390
+ case '-v':
391
+ console.log('Timonel v0.1.0');
392
+ process.exit(0);
393
+ break;
394
+ case '--help':
395
+ case '-h':
396
+ usageAndExit();
397
+ break;
398
+ default:
399
+ usageAndExit(`Unknown flag: ${flag}`);
800
400
  }
801
- else if (arg === '--version' || arg === '-v') {
802
- showVersion();
803
- process.exit(0);
804
- }
805
- else if (arg === '--help' || arg === '-h') {
806
- usageAndExit();
807
- }
808
- else if (arg && !arg.startsWith('--')) {
809
- if (!cmd) {
810
- cmd = arg;
811
- }
812
- else {
813
- args.push(arg);
814
- }
815
- }
816
- }
817
- return { cmd, args, flags };
818
- }
819
- function showVersion() {
820
- const packagePath = path.join(__dirname, '..', 'package.json');
821
- try {
822
- const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
823
- console.log(`timonel v${packageJson.version}`);
824
- }
825
- catch {
826
- console.log('timonel (version unknown)');
827
- }
828
- }
829
- function log(message, silent = false) {
830
- if (!silent) {
831
- console.log(message);
832
401
  }
402
+ return flags;
833
403
  }
834
- async function main() {
835
- const { cmd, args, flags } = parseArgs();
836
- switch (cmd) {
404
+ async function executeCommand(command, args, flags) {
405
+ switch (command) {
837
406
  case 'init':
838
407
  await cmdInit(args[0], flags.silent);
839
408
  break;
840
- case 'validate':
841
- await cmdValidate(args[0], flags.silent);
842
- break;
843
409
  case 'synth':
844
- await cmdSynth(args[0], args[1], flags);
410
+ await cmdSynth(args[0], flags);
845
411
  break;
846
- case 'diff':
847
- await cmdDiff(args[0], args[1], flags.silent);
412
+ case 'validate':
413
+ await cmdValidate(flags);
848
414
  break;
849
415
  case 'deploy':
850
416
  await cmdDeploy(args[0], args[1], flags);
851
417
  break;
852
- case 'package':
853
- await cmdPackage(args[0], args[1], flags.silent);
418
+ case 'templates':
419
+ await cmdTemplates(flags);
854
420
  break;
855
421
  case 'umbrella':
856
422
  await cmdUmbrella(args[0], args.slice(1), flags);
857
423
  break;
424
+ case undefined:
425
+ usageAndExit('Missing command');
426
+ break;
858
427
  default:
859
- usageAndExit();
428
+ usageAndExit(`Unknown command: ${command}`);
860
429
  }
861
430
  }
862
- main().catch((err) => {
863
- console.error(err);
431
+ async function main() {
432
+ const args = process.argv.slice(2);
433
+ const command = args.shift();
434
+ const flags = parseFlags(args);
435
+ await executeCommand(command, args, flags);
436
+ }
437
+ main().catch((error) => {
438
+ console.error('Error:', error.message);
864
439
  process.exit(1);
865
440
  });