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 +12 -0
- package/README.md +2 -0
- package/dist/cli.js +197 -52
- package/dist/lib/helmChartWriter.d.ts +2 -4
- package/dist/lib/helmChartWriter.js +71 -17
- package/dist/lib/security.d.ts +4 -1
- package/dist/lib/security.js +58 -8
- package/dist/lib/templates/umbrella-chart.js +136 -20
- package/dist/lib/umbrellaRutter.js +3 -1
- package/dist/lib/utils/helmHelpers.d.ts +2 -4
- package/package.json +2 -2
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
|
|
172
|
+
const requestedOutDir = outDir ?? defaultOutDir;
|
|
173
|
+
const validatedOutDir = resolveOutputDirectory(requestedOutDir, chartDir, flags?.silent);
|
|
113
174
|
const originalContent = fs.readFileSync(chartFile, 'utf8');
|
|
114
|
-
const
|
|
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(
|
|
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(
|
|
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(
|
|
311
|
+
await cmdUmbrellaInit(workingArgs[0], mergedFlags.silent);
|
|
243
312
|
break;
|
|
244
313
|
case 'add':
|
|
245
|
-
await cmdUmbrellaAdd(
|
|
314
|
+
await cmdUmbrellaAdd(workingArgs[0], mergedFlags.silent);
|
|
246
315
|
break;
|
|
247
316
|
case 'synth':
|
|
248
|
-
await cmdUmbrellaSynth(
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 = /
|
|
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
|
|
323
|
-
if (
|
|
397
|
+
const [, prefix, body, suffix] = match;
|
|
398
|
+
if (body?.includes(`name: '${chartName}'`)) {
|
|
324
399
|
return content;
|
|
325
400
|
}
|
|
326
|
-
const subchartEntry = `{ name: '${chartName}',
|
|
327
|
-
const
|
|
328
|
-
return content.replace(subchartsRegex,
|
|
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
|
|
394
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
550
|
+
},
|
|
551
|
+
],
|
|
552
|
+
[
|
|
553
|
+
'--silent',
|
|
554
|
+
() => {
|
|
442
555
|
flags.silent = true;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
[
|
|
559
|
+
'--env',
|
|
560
|
+
() => {
|
|
561
|
+
const envValue = takeValue('--env');
|
|
562
|
+
if (envValue) {
|
|
447
563
|
flags.env = envValue;
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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.
|
|
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,
|
|
133
|
+
writeSingleAssetFile(outDir, targetDir, directorySegments, fileBaseName, asset.yaml);
|
|
136
134
|
}
|
|
137
135
|
else {
|
|
138
|
-
writeMultipleAssetFiles(outDir, targetDir,
|
|
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,
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
fs.
|
|
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,
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|
package/dist/lib/security.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/security.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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.
|
|
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": "^
|
|
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"
|