timonel 2.12.0 → 2.12.2-beta.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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [2.12.2-beta.1](https://github.com/KenkoGeek/timonel/compare/v2.12.1...v2.12.2-beta.1) (2025-10-04)
2
+
3
+ ### Bug Fixes
4
+
5
+ - **cli:** handle flags after positional arguments ([#142](https://github.com/KenkoGeek/timonel/issues/142)) ([36ac024](https://github.com/KenkoGeek/timonel/commit/36ac024fa7c2a454a184394337a65ebae46a65f9))
6
+
7
+ ## [2.12.1](https://github.com/KenkoGeek/timonel/compare/v2.12.0...v2.12.1) (2025-09-28)
8
+
9
+ ### Bug Fixes
10
+
11
+ - **cli:** new feature for cli ([a8c5326](https://github.com/KenkoGeek/timonel/commit/a8c532617616bfedada20d28bcc4fe94ea165321))
12
+
1
13
  # [2.12.0](https://github.com/KenkoGeek/timonel/compare/v2.11.0...v2.12.0) (2025-09-28)
2
14
 
3
15
  ### Bug Fixes
package/README.md CHANGED
@@ -137,6 +137,8 @@ export const umbrella = new UmbrellaChartTemplate(umbrellaConfig);
137
137
  patterns and practices
138
138
  - **[Contributing](https://github.com/KenkoGeek/timonel/wiki/Contributing)** - Development setup
139
139
  and guidelines
140
+ - **[Timonel Examples Repository](https://github.com/KenkoGeek/timonel-examples)** - Curated
141
+ collection of ready-to-run Timonel sample projects
140
142
 
141
143
  ## 🔧 Troubleshooting
142
144
 
package/dist/cli.js CHANGED
@@ -3,10 +3,66 @@ import { spawnSync } from 'child_process';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
+ import { createRequire } from 'module';
6
7
  import { SecurityUtils } from './lib/security.js';
7
8
  import { createLogger } from './lib/utils/logger.js';
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
11
+ const esmRequire = createRequire(import.meta.url);
12
+ function getLocalTsxCliPath() {
13
+ try {
14
+ return esmRequire.resolve('tsx/cli');
15
+ }
16
+ catch (error) {
17
+ const sanitizedMessage = SecurityUtils.sanitizeLogMessage('Unable to locate the local tsx CLI module required to execute commands.');
18
+ console.error(sanitizedMessage);
19
+ if (error instanceof Error && error.message) {
20
+ console.error(SecurityUtils.sanitizeLogMessage(error.message));
21
+ }
22
+ process.exit(1);
23
+ }
24
+ }
25
+ function escapeForSingleQuotedLiteral(value) {
26
+ return value
27
+ .replace(/\\/g, '\\\\')
28
+ .replace(/'/g, "\\'")
29
+ .replace(/\r/g, '\\r')
30
+ .replace(/\n/g, '\\n')
31
+ .replace(/\t/g, '\\t');
32
+ }
33
+ function resolveOutputDirectory(requestedOutDir, baseDir, silent) {
34
+ let validatedOutDir;
35
+ try {
36
+ validatedOutDir = SecurityUtils.validatePath(requestedOutDir, baseDir, {
37
+ allowAbsolute: true,
38
+ });
39
+ }
40
+ catch (error) {
41
+ if (!silent) {
42
+ console.error(SecurityUtils.sanitizeLogMessage(error.message));
43
+ }
44
+ process.exit(1);
45
+ }
46
+ if (fs.existsSync(validatedOutDir)) {
47
+ try {
48
+ const stats = fs.statSync(validatedOutDir);
49
+ if (!stats.isDirectory()) {
50
+ if (!silent) {
51
+ console.error(`Invalid output path: ${SecurityUtils.sanitizeLogMessage(validatedOutDir)} must be a directory`);
52
+ }
53
+ process.exit(1);
54
+ }
55
+ }
56
+ catch (error) {
57
+ if (!silent) {
58
+ console.error(SecurityUtils.sanitizeLogMessage(error.message));
59
+ }
60
+ process.exit(1);
61
+ }
62
+ }
63
+ return validatedOutDir;
64
+ }
65
+ const TSX_CLI_PATH = getLocalTsxCliPath();
10
66
  const UMBRELLA_CONFIG_FILE = 'umbrella.config.json';
11
67
  const UMBRELLA_FILE_NAME = 'umbrella.ts';
12
68
  const PACKAGE_JSON_FILE = 'package.json';
@@ -64,6 +120,7 @@ function usageAndExit(msg, silent = false) {
64
120
  ' --silent Suppress output (useful for CI)',
65
121
  ' --env <environment> Use environment-specific values',
66
122
  ' --set <key=value> Override values (can be used multiple times)',
123
+ ' --mode <dependencies|inline> Umbrella synth mode (default: dependencies)',
67
124
  ' --help, -h Show this help message',
68
125
  '',
69
126
  'Examples:',
@@ -94,7 +151,7 @@ async function cmdInit(name, silent = false) {
94
151
  log(`Generated chart.ts file`, silent);
95
152
  log(`Run 'tl synth ${validName}' to generate complete Helm chart`, silent);
96
153
  }
97
- async function cmdSynth(chartDirOrOutDir, flags) {
154
+ async function cmdSynth(chartDirOrOutDir, flags, explicitOutDir) {
98
155
  let chartDir = process.cwd();
99
156
  let outDir;
100
157
  if (chartDirOrOutDir && fs.existsSync(path.join(chartDirOrOutDir, 'chart.ts'))) {
@@ -103,27 +160,35 @@ async function cmdSynth(chartDirOrOutDir, flags) {
103
160
  else {
104
161
  outDir = chartDirOrOutDir;
105
162
  }
163
+ if (explicitOutDir) {
164
+ outDir = explicitOutDir;
165
+ }
106
166
  const chartFile = path.join(chartDir, 'chart.ts');
107
167
  const defaultOutDir = path.join(chartDir, 'dist');
108
168
  if (!fs.existsSync(chartFile)) {
109
169
  console.error('chart.ts not found. Run `tl init` first.');
110
170
  process.exit(1);
111
171
  }
112
- const resolvedOutDir = outDir ? path.resolve(outDir) : defaultOutDir;
172
+ const requestedOutDir = outDir ?? defaultOutDir;
173
+ const validatedOutDir = resolveOutputDirectory(requestedOutDir, chartDir, flags?.silent);
113
174
  const originalContent = fs.readFileSync(chartFile, 'utf8');
114
- const modifiedContent = originalContent.replace(/chart\.writeHelmChart\(['"][^'"]*['"]\)/, `chart.writeHelmChart('${resolvedOutDir}')`);
175
+ const safeOutDirLiteral = escapeForSingleQuotedLiteral(validatedOutDir);
176
+ let modifiedContent = originalContent.replace(/chart\.writeHelmChart\(['"][^'"]*['"]\)/, `chart.writeHelmChart('${safeOutDirLiteral}')`);
177
+ if (modifiedContent === originalContent) {
178
+ modifiedContent = originalContent.replace(/chart\.write\(['"][^'"]*['"]\)/, `chart.write('${safeOutDirLiteral}')`);
179
+ }
115
180
  const tempChartFile = path.join(chartDir, '.timonel-temp-chart.ts');
116
181
  fs.writeFileSync(tempChartFile, modifiedContent);
117
182
  const wrapperScript = `
118
183
  import { pathToFileURL } from 'url';
119
184
 
120
185
  // Import the modified chart file which should execute the synthesis directly
121
- await import(pathToFileURL('${tempChartFile}').href);
186
+ await import(pathToFileURL(${JSON.stringify(tempChartFile)}).href);
122
187
  `;
123
188
  const wrapperFile = path.join(chartDir, '.timonel-wrapper.mjs');
124
189
  try {
125
190
  fs.writeFileSync(wrapperFile, wrapperScript);
126
- const result = spawnSync('npx', ['tsx', wrapperFile].filter(Boolean), {
191
+ const result = spawnSync(process.execPath, [TSX_CLI_PATH, wrapperFile], {
127
192
  stdio: flags?.silent ? 'pipe' : 'inherit',
128
193
  encoding: 'utf8',
129
194
  cwd: chartDir,
@@ -234,18 +299,22 @@ async function cmdTemplates(flags) {
234
299
  });
235
300
  }
236
301
  }
302
+ const UMBRELLA_SYNTH_MODES = ['dependencies', 'inline'];
237
303
  async function cmdUmbrella(subcommand, args, flags) {
238
304
  if (!subcommand)
239
305
  usageAndExit('Missing umbrella subcommand');
306
+ const workingArgs = [...(args ?? [])];
307
+ const subcommandFlags = parseFlags(workingArgs);
308
+ const mergedFlags = mergeCliFlags(flags, subcommandFlags);
240
309
  switch (subcommand) {
241
310
  case 'init':
242
- await cmdUmbrellaInit(args?.[0], flags?.silent);
311
+ await cmdUmbrellaInit(workingArgs[0], mergedFlags.silent);
243
312
  break;
244
313
  case 'add':
245
- await cmdUmbrellaAdd(args?.[0], flags?.silent);
314
+ await cmdUmbrellaAdd(workingArgs[0], mergedFlags.silent);
246
315
  break;
247
316
  case 'synth':
248
- await cmdUmbrellaSynth(args?.[0], flags);
317
+ await cmdUmbrellaSynth(workingArgs[0], mergedFlags);
249
318
  break;
250
319
  default:
251
320
  usageAndExit(`Unknown umbrella subcommand: ${subcommand}`);
@@ -304,28 +373,54 @@ function addImportStatement(content, importStatement) {
304
373
  return lines.join('\n');
305
374
  }
306
375
  function buildSubchartsContent(subchartsContent, subchartEntry) {
307
- const hasExistingEntries = /\{\s*name:\s*['"]/.test(subchartsContent || '');
308
- if (!hasExistingEntries) {
309
- return `\n // Add your subcharts here:\n ${subchartEntry},\n `;
310
- }
311
- const trimmedContent = subchartsContent?.trimEnd() ?? '';
312
- const needsComma = !trimmedContent.endsWith(',');
313
- const comma = needsComma ? ',' : '';
314
- return trimmedContent + `${comma}\n ${subchartEntry}`;
376
+ const existingEntries = (subchartsContent || '')
377
+ .split('\n')
378
+ .map((line) => line.trim())
379
+ .filter((line) => line && !line.startsWith('//'))
380
+ .map((line) => line.replace(/,$/, ''));
381
+ if (existingEntries.includes(subchartEntry)) {
382
+ return subchartsContent ?? '';
383
+ }
384
+ const entries = [...existingEntries, subchartEntry];
385
+ const lines = [' // Add your subcharts here:'];
386
+ for (const entry of entries) {
387
+ lines.push(` ${entry},`);
388
+ }
389
+ return `\n${lines.join('\n')}\n`;
315
390
  }
316
391
  function addSubchartToArray(content, chartName, camelCaseName) {
317
- const subchartsRegex = /subcharts:\s*\[([\s\S]*?)\]/;
392
+ const subchartsRegex = /(const SUBCHARTS[\s\S]*?=\s*\[)([\s\S]*?)(\];)/;
318
393
  const match = content.match(subchartsRegex);
319
394
  if (!match) {
320
395
  return content;
321
396
  }
322
- const subchartsContent = match[1];
323
- if (subchartsContent?.includes(`name: '${chartName}'`)) {
397
+ const [, prefix, body, suffix] = match;
398
+ if (body?.includes(`name: '${chartName}'`)) {
324
399
  return content;
325
400
  }
326
- const subchartEntry = `{ name: '${chartName}', chart: ${camelCaseName} }`;
327
- const newSubchartsContent = buildSubchartsContent(subchartsContent, subchartEntry);
328
- return content.replace(subchartsRegex, `subcharts: [${newSubchartsContent}\n ]`);
401
+ const subchartEntry = `{ name: '${chartName}', factory: ${camelCaseName} }`;
402
+ const newBody = buildSubchartsContent(body, subchartEntry);
403
+ return content.replace(subchartsRegex, `${prefix}${newBody}${suffix}`);
404
+ }
405
+ function mergeCliFlags(base, override) {
406
+ const merged = { ...(base || {}) };
407
+ if (!override) {
408
+ return merged;
409
+ }
410
+ if (override.dryRun !== undefined)
411
+ merged.dryRun = override.dryRun;
412
+ if (override.silent !== undefined)
413
+ merged.silent = override.silent;
414
+ if (override.env !== undefined)
415
+ merged.env = override.env;
416
+ if (override.mode !== undefined)
417
+ merged.mode = override.mode;
418
+ const baseSet = base?.set ?? [];
419
+ const overrideSet = override.set ?? [];
420
+ if (baseSet.length || overrideSet.length) {
421
+ merged.set = [...baseSet, ...overrideSet];
422
+ }
423
+ return merged;
329
424
  }
330
425
  function updateUmbrellaTs(subchartPath, chartName) {
331
426
  const umbrellaFile = path.join(process.cwd(), UMBRELLA_FILE_NAME);
@@ -390,30 +485,36 @@ async function cmdUmbrellaSynth(outDir, flags) {
390
485
  console.error('umbrella.ts not found. Run `tl umbrella init` first.');
391
486
  process.exit(1);
392
487
  }
393
- const resolvedOutDir = outDir ? path.resolve(outDir) : defaultOutDir;
394
- await executeTypeScriptUmbrella(umbrellaFile, resolvedOutDir, flags);
488
+ const synthMode = flags?.mode ?? 'dependencies';
489
+ if (!UMBRELLA_SYNTH_MODES.includes(synthMode)) {
490
+ usageAndExit('Invalid mode. Use "dependencies" or "inline".', flags?.silent);
491
+ }
492
+ await executeTypeScriptUmbrella(umbrellaFile, outDir ?? defaultOutDir, synthMode, flags);
395
493
  }
396
- async function executeTypeScriptUmbrella(resolvedPath, outDir, flags) {
494
+ async function executeTypeScriptUmbrella(resolvedPath, outDir, mode, flags) {
495
+ const umbrellaBase = path.dirname(resolvedPath);
496
+ const validatedOutDir = resolveOutputDirectory(outDir, umbrellaBase, flags?.silent);
397
497
  const wrapperScript = `
398
498
  import { pathToFileURL } from 'url';
399
499
 
400
- const mod = await import(pathToFileURL('${resolvedPath}').href);
500
+ const mod = await import(pathToFileURL(${JSON.stringify(resolvedPath)}).href);
401
501
  const runner = mod.default || mod.run || mod.synth;
402
502
  if (typeof runner !== 'function') {
403
503
  console.error('umbrella.ts must export a default/run/synth function');
404
504
  process.exit(1);
405
505
  }
406
506
 
407
- const output = '${outDir}';
507
+ const output = ${JSON.stringify(validatedOutDir)};
508
+ const synthOptions = { mode: ${JSON.stringify(mode)} };
408
509
  const fs = await import('fs');
409
510
  fs.mkdirSync(output, { recursive: true });
410
- await Promise.resolve(runner(output));
511
+ await Promise.resolve(runner(output, synthOptions));
411
512
  console.log('Umbrella chart written to ' + output);
412
513
  `;
413
514
  const wrapperFile = path.join(process.cwd(), '.timonel-umbrella-wrapper.mjs');
414
515
  try {
415
516
  fs.writeFileSync(wrapperFile, wrapperScript);
416
- const result = spawnSync('npx', ['tsx', wrapperFile].filter(Boolean), {
517
+ const result = spawnSync(process.execPath, [TSX_CLI_PATH, wrapperFile], {
417
518
  stdio: flags?.silent ? 'pipe' : 'inherit',
418
519
  encoding: 'utf8',
419
520
  });
@@ -432,37 +533,81 @@ console.log('Umbrella chart written to ' + output);
432
533
  }
433
534
  function parseFlags(args) {
434
535
  const flags = {};
435
- while (args.length > 0 && args[0]?.startsWith('-')) {
436
- const flag = args.shift();
437
- switch (flag) {
438
- case '--dry-run':
536
+ const positionalArguments = [];
537
+ const takeValue = (flagName) => {
538
+ const value = args.shift();
539
+ if (value === undefined) {
540
+ usageAndExit(`Missing value for ${flagName}`, flags.silent);
541
+ return '';
542
+ }
543
+ return value;
544
+ };
545
+ const flagHandlers = new Map([
546
+ [
547
+ '--dry-run',
548
+ () => {
439
549
  flags.dryRun = true;
440
- break;
441
- case '--silent':
550
+ },
551
+ ],
552
+ [
553
+ '--silent',
554
+ () => {
442
555
  flags.silent = true;
443
- break;
444
- case '--env': {
445
- const envValue = args.shift();
446
- if (envValue)
556
+ },
557
+ ],
558
+ [
559
+ '--env',
560
+ () => {
561
+ const envValue = takeValue('--env');
562
+ if (envValue) {
447
563
  flags.env = envValue;
448
- break;
449
- }
450
- case '--set': {
451
- const setValue = args.shift();
564
+ }
565
+ },
566
+ ],
567
+ [
568
+ '--set',
569
+ () => {
570
+ const setValue = takeValue('--set');
452
571
  if (setValue) {
453
572
  flags.set = flags.set || [];
454
573
  flags.set.push(setValue);
455
574
  }
456
- break;
457
- }
458
- case '--help':
459
- case '-h':
460
- usageAndExit(undefined, flags.silent);
461
- break;
462
- default:
463
- usageAndExit(`Unknown flag: ${flag}`, flags.silent);
575
+ },
576
+ ],
577
+ [
578
+ '--mode',
579
+ () => {
580
+ const modeValue = takeValue('--mode');
581
+ if (!UMBRELLA_SYNTH_MODES.includes(modeValue)) {
582
+ usageAndExit('Invalid mode. Use "dependencies" or "inline".', flags.silent);
583
+ }
584
+ flags.mode = modeValue;
585
+ },
586
+ ],
587
+ ]);
588
+ const helpFlags = new Set(['--help', '-h']);
589
+ while (args.length > 0) {
590
+ const token = args.shift();
591
+ if (token === undefined) {
592
+ break;
593
+ }
594
+ if (!token.startsWith('-')) {
595
+ positionalArguments.push(token);
596
+ continue;
597
+ }
598
+ if (helpFlags.has(token)) {
599
+ usageAndExit(undefined, flags.silent);
600
+ return flags;
601
+ }
602
+ const handler = flagHandlers.get(token);
603
+ if (handler) {
604
+ handler();
605
+ continue;
464
606
  }
607
+ usageAndExit(`Unknown flag: ${token}`, flags.silent);
465
608
  }
609
+ args.length = 0;
610
+ args.push(...positionalArguments);
466
611
  return flags;
467
612
  }
468
613
  async function executeCommand(command, args, flags) {
@@ -471,7 +616,7 @@ async function executeCommand(command, args, flags) {
471
616
  await cmdInit(args[0], flags.silent);
472
617
  break;
473
618
  case 'synth':
474
- await cmdSynth(args[0], flags);
619
+ await cmdSynth(args[0], flags, args[1]);
475
620
  break;
476
621
  case 'validate':
477
622
  await cmdValidate(flags);
@@ -1,3 +1,5 @@
1
+ import type { HelperDefinition as ExternalHelperDefinition } from './utils/helmHelpers/types.js';
2
+ export type HelperDefinition = ExternalHelperDefinition;
1
3
  export interface HelmChartMeta {
2
4
  name: string;
3
5
  version: string;
@@ -43,10 +45,6 @@ export interface HelmChartWriteOptions {
43
45
  notesTpl?: string;
44
46
  valuesSchema?: Record<string, unknown>;
45
47
  }
46
- export interface HelperDefinition {
47
- name: string;
48
- body: string;
49
- }
50
48
  export declare class HelmChartWriter {
51
49
  static write(opts: HelmChartWriteOptions): void;
52
50
  private static createDirectories;
@@ -15,7 +15,9 @@ export class HelmChartWriter {
15
15
  assetCount: assets.length,
16
16
  operation: 'helm_write_start',
17
17
  });
18
- const validatedOutDir = SecurityUtils.validatePath(outDir, process.cwd());
18
+ const validatedOutDir = SecurityUtils.validatePath(outDir, process.cwd(), {
19
+ allowAbsolute: true,
20
+ });
19
21
  this.createDirectories(validatedOutDir);
20
22
  this.writeChartYaml(validatedOutDir, meta);
21
23
  this.writeValuesFiles(validatedOutDir, defaultValues, envValues);
@@ -72,7 +74,7 @@ export class HelmChartWriter {
72
74
  }
73
75
  else if (Array.isArray(helpersTpl)) {
74
76
  content = helpersTpl
75
- .map((h) => [`{{- define "${h.name}" -}}`, h.body.trimEnd(), '{{- end }}', ''].join('\n'))
77
+ .map((h) => [`{{- define "${h.name}" -}}`, h.template.trimEnd(), '{{- end }}', ''].join('\n'))
76
78
  .join('\n');
77
79
  }
78
80
  fs.writeFileSync(path.join(outDir, 'templates', '_helpers.tpl'), content);
@@ -124,18 +126,14 @@ function splitDocs(yamlStr) {
124
126
  }
125
127
  function writeAssets(outDir, assets) {
126
128
  for (const asset of assets) {
127
- const sanitizedId = asset.id.replace(/[^a-zA-Z0-9-_]/g, '');
128
- if (sanitizedId !== asset.id) {
129
- console.error(`Invalid asset ID detected: ${SecurityUtils.sanitizeLogMessage(asset.id)}`);
130
- throw new Error(`Invalid asset ID: ${SecurityUtils.sanitizeLogMessage(asset.id)}`);
131
- }
132
129
  const targetDir = getTargetDirectory(asset.target);
133
130
  try {
131
+ const { directorySegments, fileBaseName } = resolveAssetPath(asset.id);
134
132
  if (asset.singleFile) {
135
- writeSingleAssetFile(outDir, targetDir, sanitizedId, asset.yaml);
133
+ writeSingleAssetFile(outDir, targetDir, directorySegments, fileBaseName, asset.yaml);
136
134
  }
137
135
  else {
138
- writeMultipleAssetFiles(outDir, targetDir, sanitizedId, asset.yaml);
136
+ writeMultipleAssetFiles(outDir, targetDir, directorySegments, fileBaseName, asset.yaml);
139
137
  }
140
138
  }
141
139
  catch (error) {
@@ -147,16 +145,72 @@ function writeAssets(outDir, assets) {
147
145
  function getTargetDirectory(target) {
148
146
  return target === 'crds' ? 'crds' : 'templates';
149
147
  }
150
- function writeSingleAssetFile(outDir, targetDir, assetId, yaml) {
151
- const filename = `${assetId}.yaml`;
152
- fs.mkdirSync(path.join(outDir, targetDir), { recursive: true });
153
- fs.writeFileSync(path.join(outDir, targetDir, filename), yaml + '\n');
148
+ function writeSingleAssetFile(outDir, targetDir, directorySegments, fileBaseName, yaml) {
149
+ const chartSubdir = path.join(outDir, targetDir, ...directorySegments);
150
+ SecurityUtils.validatePath(chartSubdir, outDir);
151
+ fs.mkdirSync(chartSubdir, { recursive: true });
152
+ const filename = `${fileBaseName}.yaml`;
153
+ const absolutePath = path.join(chartSubdir, filename);
154
+ SecurityUtils.validatePath(absolutePath, outDir);
155
+ fs.writeFileSync(absolutePath, yaml.endsWith('\n') ? yaml : `${yaml}\n`);
154
156
  }
155
- function writeMultipleAssetFiles(outDir, targetDir, assetId, yaml) {
157
+ function writeMultipleAssetFiles(outDir, targetDir, directorySegments, fileBaseName, yaml) {
158
+ const chartSubdir = path.join(outDir, targetDir, ...directorySegments);
159
+ SecurityUtils.validatePath(chartSubdir, outDir);
160
+ fs.mkdirSync(chartSubdir, { recursive: true });
156
161
  const parts = splitDocs(yaml);
157
162
  parts.forEach((doc, index) => {
158
- const filename = `${assetId}${parts.length > 1 ? `-${index + 1}` : ''}.yaml`;
159
- fs.mkdirSync(path.join(outDir, targetDir), { recursive: true });
160
- fs.writeFileSync(path.join(outDir, targetDir, filename), doc + '\n');
163
+ const suffix = parts.length > 1 ? `-${index + 1}` : '';
164
+ const filename = `${fileBaseName}${suffix}.yaml`;
165
+ const absolutePath = path.join(chartSubdir, filename);
166
+ SecurityUtils.validatePath(absolutePath, outDir);
167
+ fs.writeFileSync(absolutePath, doc.endsWith('\n') ? doc : `${doc}\n`);
168
+ });
169
+ }
170
+ function resolveAssetPath(assetId) {
171
+ if (!assetId || typeof assetId !== 'string') {
172
+ throw new Error('Asset ID must be a non-empty string');
173
+ }
174
+ if (assetId.includes('\0')) {
175
+ throw new Error('Asset ID cannot contain null bytes');
176
+ }
177
+ const normalized = assetId.replace(/\\+/g, '/');
178
+ const rawSegments = normalized.split('/');
179
+ if (rawSegments.length === 0) {
180
+ throw new Error('Asset ID must resolve to at least one segment');
181
+ }
182
+ const segments = rawSegments.map((segment) => {
183
+ if (!segment || segment.trim().length === 0) {
184
+ throw new Error('Asset path segments cannot be empty');
185
+ }
186
+ if (segment === '.' || segment === '..') {
187
+ throw new Error('Asset path segments cannot be relative references');
188
+ }
189
+ if (/[<>:"|?*]/.test(segment)) {
190
+ throw new Error('Asset path contains unsupported characters');
191
+ }
192
+ const hasControlCharacters = Array.from(segment).some((character) => {
193
+ const codePoint = character.codePointAt(0);
194
+ if (typeof codePoint !== 'number') {
195
+ return false;
196
+ }
197
+ if (codePoint < 0x20 || codePoint === 0x7f) {
198
+ return true;
199
+ }
200
+ if (codePoint >= 0x80 && codePoint <= 0x9f) {
201
+ return true;
202
+ }
203
+ return false;
204
+ });
205
+ if (hasControlCharacters) {
206
+ throw new Error('Asset path contains control characters');
207
+ }
208
+ return segment;
161
209
  });
210
+ const fileBaseName = segments[segments.length - 1];
211
+ const directorySegments = segments.slice(0, -1);
212
+ if (!fileBaseName) {
213
+ throw new Error('Asset ID must include a filename segment');
214
+ }
215
+ return { directorySegments, fileBaseName };
162
216
  }
@@ -1,5 +1,8 @@
1
+ export interface PathValidationOptions {
2
+ allowAbsolute?: boolean;
3
+ }
1
4
  export declare class SecurityUtils {
2
- static validatePath(inputPath: string, allowedBasePath: string): string;
5
+ static validatePath(inputPath: string, allowedBasePath: string, options?: PathValidationOptions): string;
3
6
  static sanitizeLogMessage(message: string): string;
4
7
  static sanitizeEnvironmentName(env: string): string;
5
8
  static isValidTypeScriptFile(filePath: string): boolean;
@@ -1,18 +1,68 @@
1
1
  import * as path from 'path';
2
2
  export class SecurityUtils {
3
- static validatePath(inputPath, allowedBasePath) {
3
+ static validatePath(inputPath, allowedBasePath, options) {
4
4
  if (!inputPath || typeof inputPath !== 'string') {
5
5
  throw new Error('Invalid path: path must be a non-empty string');
6
6
  }
7
- if (inputPath.includes('../') ||
8
- inputPath.includes('..\\') ||
9
- inputPath.includes('%2e%2e%2f') ||
10
- inputPath.includes('%2e%2e%5c')) {
11
- throw new Error('Invalid path: path traversal sequences detected');
7
+ if (inputPath.includes('\0')) {
8
+ throw new Error('Invalid path: null bytes are not permitted');
9
+ }
10
+ const allowAbsolute = Boolean(options?.allowAbsolute);
11
+ const candidates = new Set();
12
+ const registerCandidate = (value) => {
13
+ if (typeof value === 'string' && value.length > 0) {
14
+ candidates.add(value);
15
+ }
16
+ };
17
+ const addCandidate = (value) => {
18
+ if (typeof value !== 'string' || value.length === 0) {
19
+ return;
20
+ }
21
+ registerCandidate(value);
22
+ try {
23
+ const normalized = value.normalize('NFKC');
24
+ registerCandidate(normalized);
25
+ }
26
+ catch {
27
+ }
28
+ };
29
+ addCandidate(inputPath);
30
+ let decoded = inputPath;
31
+ for (let iteration = 0; iteration < 5; iteration += 1) {
32
+ try {
33
+ const nextDecoded = decodeURIComponent(decoded);
34
+ if (nextDecoded === decoded) {
35
+ break;
36
+ }
37
+ decoded = nextDecoded;
38
+ addCandidate(decoded);
39
+ }
40
+ catch {
41
+ break;
42
+ }
43
+ }
44
+ const containsTraversalSequence = (candidate) => {
45
+ const lowered = candidate.toLowerCase();
46
+ if (lowered.includes('%2e%2f') || lowered.includes('%2e%5c') || lowered.includes('%2f%2e')) {
47
+ return true;
48
+ }
49
+ const normalized = candidate.replace(/\\+/g, '/');
50
+ const segments = normalized.split('/');
51
+ return segments.some((segment) => {
52
+ const loweredSegment = segment.toLowerCase();
53
+ return loweredSegment === '..' || loweredSegment === '%2e%2e';
54
+ });
55
+ };
56
+ for (const candidate of candidates) {
57
+ if (containsTraversalSequence(candidate)) {
58
+ throw new Error('Invalid path: path traversal sequences detected');
59
+ }
12
60
  }
13
61
  const resolvedInput = path.resolve(inputPath);
14
62
  const resolvedBase = path.resolve(allowedBasePath);
15
- if (!resolvedInput.startsWith(resolvedBase + path.sep) && resolvedInput !== resolvedBase) {
63
+ const relativePath = path.relative(resolvedBase, resolvedInput);
64
+ const isInsideBase = relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
65
+ if (!(allowAbsolute && path.isAbsolute(inputPath)) && !isInsideBase) {
16
66
  throw new Error(`Invalid path: path must be within ${resolvedBase}`);
17
67
  }
18
68
  return resolvedInput;
@@ -41,7 +91,7 @@ export class SecurityUtils {
41
91
  return sanitized;
42
92
  }
43
93
  static isValidTypeScriptFile(filePath) {
44
- const allowedExtensions = ['.ts', '.tsx'];
94
+ const allowedExtensions = ['.ts', '.tsx', '.cts', '.mts'];
45
95
  const ext = path.extname(filePath);
46
96
  return allowedExtensions.includes(ext);
47
97
  }
@@ -7,36 +7,152 @@ import { generateHelpersTemplate } from '../utils/helmHelpers.js';
7
7
  import { createFlexibleSubchart } from './flexible-subchart.js';
8
8
  export function generateUmbrellaChart(name) {
9
9
  return `import { App } from 'cdk8s';
10
- import { UmbrellaChart } from 'timonel';
10
+ import { Rutter } from 'timonel';
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
12
+ import { join } from 'path';
13
+ import * as jsYaml from 'js-yaml';
11
14
  // Import subcharts - add your subchart imports here
12
15
 
13
- /**
14
- * Synthesizes the umbrella chart with all subcharts
15
- * @param outDir Output directory for generated charts
16
- * @since 2.11.0
17
- */
18
- export function synth(outDir: string) {
16
+ type SynthMode = 'dependencies' | 'inline';
17
+
18
+ interface SynthOptions {
19
+ mode?: SynthMode;
20
+ }
21
+
22
+ const DEFAULT_MODE: SynthMode = 'dependencies';
23
+
24
+ const SUBCHARTS: Array<{ name: string; factory: () => Rutter }> = [
25
+ // Add your subcharts here:
26
+ // { name: 'my-subchart', factory: mySubchart },
27
+ ];
28
+
29
+ function resolveMode(options?: SynthOptions): SynthMode {
30
+ const explicit = options?.mode;
31
+ if (explicit === 'dependencies' || explicit === 'inline') {
32
+ return explicit;
33
+ }
34
+ const envMode = process.env.TIMONEL_UMBRELLA_MODE;
35
+ if (envMode === 'dependencies' || envMode === 'inline') {
36
+ return envMode;
37
+ }
38
+ return DEFAULT_MODE;
39
+ }
40
+
41
+ function readYamlFile(filePath: string): Record<string, unknown> {
42
+ if (!existsSync(filePath)) {
43
+ return {};
44
+ }
45
+ const content = jsYaml.load(readFileSync(filePath, 'utf8'));
46
+ return content && typeof content === 'object' ? (content as Record<string, unknown>) : {};
47
+ }
48
+
49
+ export function synth(outDir: string, options?: SynthOptions) {
50
+ const mode = resolveMode(options);
19
51
  const app = new App({
20
52
  outdir: outDir,
21
53
  outputFileExtension: '.yaml',
22
- yamlOutputType: 'FILE_PER_RESOURCE'
54
+ yamlOutputType: 'FILE_PER_RESOURCE',
23
55
  });
24
56
 
25
- const chart = new UmbrellaChart(app, '${name}', {
26
- name: '${name}',
27
- version: '0.1.0',
28
- description: '${name} umbrella chart',
29
- services: [],
30
- subcharts: [
31
- // Add your subcharts here:
32
- ]
57
+ const umbrella = new Rutter({
58
+ meta: {
59
+ name: '${name}',
60
+ version: '0.1.0',
61
+ description: '${name} umbrella chart',
62
+ appVersion: '1.0.0',
63
+ type: 'application',
64
+ },
65
+ scope: app,
66
+ defaultValues: {
67
+ namespace: 'default',
68
+ createNamespace: false,
69
+ },
33
70
  });
34
71
 
35
- // Generate Helm chart files
36
- chart.writeHelmChart(outDir);
37
-
38
- // Also generate CDK8s YAML files
72
+ umbrella.addConditionalManifest(
73
+ {
74
+ apiVersion: 'v1',
75
+ kind: 'Namespace',
76
+ metadata: {
77
+ name: '{{ .Values.namespace | default .Release.Namespace }}',
78
+ },
79
+ },
80
+ 'createNamespace',
81
+ 'namespace',
82
+ );
83
+
84
+ umbrella.write(outDir);
85
+
86
+ const chartPath = join(outDir, 'Chart.yaml');
87
+ const valuesPath = join(outDir, 'values.yaml');
88
+ const templatesDir = join(outDir, 'templates');
89
+ mkdirSync(templatesDir, { recursive: true });
90
+
91
+ const chartDoc = readYamlFile(chartPath);
92
+ const valuesDoc = readYamlFile(valuesPath);
93
+
94
+ if (mode === 'dependencies') {
95
+ const chartsDir = join(outDir, 'charts');
96
+ mkdirSync(chartsDir, { recursive: true });
97
+
98
+ const dependencies: Array<{ name: string; version: string; repository: string }> = [];
99
+
100
+ SUBCHARTS.forEach((subchart) => {
101
+ const instance = subchart.factory();
102
+ const targetDir = join(chartsDir, subchart.name);
103
+ rmSync(targetDir, { recursive: true, force: true });
104
+ instance.write(targetDir);
105
+ const meta = instance.getMeta();
106
+ const version = meta.version ?? '0.1.0';
107
+ dependencies.push({
108
+ name: subchart.name,
109
+ version,
110
+ repository: 'file://./charts/' + subchart.name,
111
+ });
112
+ const subchartValues = readYamlFile(join(targetDir, 'values.yaml'));
113
+ if (Object.keys(subchartValues).length > 0) {
114
+ valuesDoc[subchart.name] = subchartValues;
115
+ }
116
+ });
117
+
118
+ chartDoc.dependencies = dependencies;
119
+ } else {
120
+ const chartsDir = join(outDir, 'charts');
121
+ if (existsSync(chartsDir)) {
122
+ rmSync(chartsDir, { recursive: true, force: true });
123
+ }
124
+ delete chartDoc.dependencies;
125
+
126
+ SUBCHARTS.forEach((subchart) => {
127
+ const instance = subchart.factory();
128
+ const tempDir = join(outDir, '.timonel-inline-' + subchart.name);
129
+ rmSync(tempDir, { recursive: true, force: true });
130
+ instance.write(tempDir);
131
+
132
+ const subTemplatesDir = join(tempDir, 'templates');
133
+ if (existsSync(subTemplatesDir)) {
134
+ const targetTemplatesDir = join(templatesDir, subchart.name);
135
+ mkdirSync(targetTemplatesDir, { recursive: true });
136
+ for (const file of readdirSync(subTemplatesDir)) {
137
+ copyFileSync(join(subTemplatesDir, file), join(targetTemplatesDir, file));
138
+ }
139
+ }
140
+
141
+ const subchartValues = readYamlFile(join(tempDir, 'values.yaml'));
142
+ if (Object.keys(subchartValues).length > 0) {
143
+ valuesDoc[subchart.name] = subchartValues;
144
+ }
145
+
146
+ rmSync(tempDir, { recursive: true, force: true });
147
+ });
148
+ }
149
+
150
+ writeFileSync(chartPath, jsYaml.dump(chartDoc));
151
+ writeFileSync(valuesPath, jsYaml.dump(valuesDoc));
152
+
39
153
  app.synth();
154
+
155
+ console.log('✅ Umbrella chart generated in ' + mode + ' mode!');
40
156
  }
41
157
 
42
158
  // Auto-execute when run directly
@@ -16,7 +16,9 @@ export class UmbrellaRutter {
16
16
  }
17
17
  }
18
18
  write(outDir) {
19
- const validatedOutDir = SecurityUtils.validatePath(outDir, process.cwd());
19
+ const validatedOutDir = SecurityUtils.validatePath(outDir, process.cwd(), {
20
+ allowAbsolute: true,
21
+ });
20
22
  mkdirSync(validatedOutDir, { recursive: true });
21
23
  mkdirSync(join(validatedOutDir, 'charts'), { recursive: true });
22
24
  for (const subchart of this.props.subcharts) {
@@ -1,7 +1,5 @@
1
- export interface HelperDefinition {
2
- name: string;
3
- template: string;
4
- }
1
+ import type { HelperDefinition } from './helmHelpers/types.js';
2
+ export type { HelperDefinition } from './helmHelpers/types.js';
5
3
  export declare const STANDARD_HELPERS: HelperDefinition[];
6
4
  export declare const FILE_ACCESS_HELPERS: HelperDefinition[];
7
5
  export declare const TEMPLATE_FUNCTION_HELPERS: HelperDefinition[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "timonel",
3
3
  "type": "module",
4
- "version": "2.12.0",
4
+ "version": "2.12.2-beta.1",
5
5
  "description": "Timonel: programmatic Helm chart generator using cdk8s (TypeScript)",
6
6
  "bin": {
7
7
  "timonel": "dist/cli.js",
@@ -92,7 +92,7 @@
92
92
  "constructs": "^10.4.2",
93
93
  "handlebars": "^4.7.8",
94
94
  "js-yaml": "^4.1.0",
95
- "pino": "^9.11.0",
95
+ "pino": "^10.0.0",
96
96
  "pino-pretty": "^13.1.1",
97
97
  "ts-node": "^10.9.2",
98
98
  "yaml": "^2.8.1"