spectrum-cli 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/chart.js ADDED
@@ -0,0 +1,821 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { execSync } = require('child_process');
5
+ const readline = require('readline');
6
+ const { logSuccess, logError, execCommand, execSilent, colors } = require('./utils');
7
+ const { runCommand } = require('./command-executor');
8
+ const {
9
+ requireGitRepo,
10
+ requireCleanWorkingTree,
11
+ requireOnMainBranch,
12
+ requireCurrentBranchUpToDateWithRemote,
13
+ requireRemoteOrigin,
14
+ requireRemoteReachable,
15
+ requireSingleChart,
16
+ requireTagMissing,
17
+ requireHelmReleaseFiles,
18
+ requireSingleValuesYaml,
19
+ requireIngressPathSections,
20
+ requireSourcePathDirectory,
21
+ requireNextProject,
22
+ requireBuildCommandSupport,
23
+ SEMVER_PATTERN
24
+ } = require('./preflight');
25
+
26
+ const CHARTS_DIR = 'charts';
27
+ const FIXED_ASSET_PATHS = [
28
+ '/_next/image$',
29
+ '/_next/static/media/[a-z0-9_\\.\\-]+\\.(woff|woff2|ttf|otf|eot)$',
30
+ '/_next/static/chunks/[a-z0-9_\\.\\-]+\\.(css|js)$',
31
+ '/_next/static/[a-zA-Z0-9_-]+/_ssgManifest.js$',
32
+ '/_next/static/[a-zA-Z0-9_-]+/_buildManifest.js$',
33
+ '/_next/static/[a-zA-Z0-9_-]+/_clientMiddlewareManifest.json$',
34
+ '/[^?]+\\.(?:ico|svg|png|jpg|jpeg|gif|webp|txt|map)$'
35
+ ];
36
+
37
+ function getChartFiles() {
38
+ try {
39
+ if (!fs.existsSync(CHARTS_DIR)) {
40
+ return [];
41
+ }
42
+
43
+ const entries = fs.readdirSync(CHARTS_DIR, { withFileTypes: true });
44
+ const chartFiles = entries
45
+ .filter((entry) => entry.isDirectory())
46
+ .map((entry) => `${CHARTS_DIR}/${entry.name}/Chart.yaml`)
47
+ .filter((chartFilePath) => fs.existsSync(chartFilePath));
48
+
49
+ return chartFiles;
50
+ } catch (error) {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ function getChartName(chartFilePath) {
56
+ try {
57
+ if (!chartFilePath || !fs.existsSync(chartFilePath)) {
58
+ return null;
59
+ }
60
+
61
+ const chartYaml = fs.readFileSync(chartFilePath, 'utf8');
62
+ const nameMatch = chartYaml.match(/^\s*name:\s*([^\s#]+)\s*$/m);
63
+ return nameMatch ? nameMatch[1] : null;
64
+ } catch (error) {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function isSemver(version) {
70
+ return SEMVER_PATTERN.test(version);
71
+ }
72
+
73
+ function normalizeList(list) {
74
+ const result = [];
75
+ const seen = new Set();
76
+ for (const item of (Array.isArray(list) ? list : [])) {
77
+ const value = String(item || '').trim();
78
+ if (!value || seen.has(value)) {
79
+ continue;
80
+ }
81
+ seen.add(value);
82
+ result.push(value);
83
+ }
84
+ return result;
85
+ }
86
+
87
+ function ensureEndsWithDollar(value) {
88
+ const text = String(value || '').trim();
89
+ if (!text) {
90
+ return '';
91
+ }
92
+ return text.endsWith('$') ? text : `${text}$`;
93
+ }
94
+
95
+ function normalizeToBeList(list) {
96
+ return normalizeList(list.map(ensureEndsWithDollar));
97
+ }
98
+
99
+ function segmentToPattern(segment) {
100
+ if (segment === 'index') {
101
+ return '';
102
+ }
103
+ if (/^\[\[\.\.\..+\]\]$/.test(segment)) {
104
+ return '.*';
105
+ }
106
+ if (/^\[\.\.\..+\]$/.test(segment)) {
107
+ return '.+';
108
+ }
109
+ if (/^\[.+\]$/.test(segment)) {
110
+ return '[^/]+';
111
+ }
112
+ return segment;
113
+ }
114
+
115
+ function toRouteFromFileSegments(segments) {
116
+ const transformed = segments.map(segmentToPattern).filter(Boolean);
117
+ const route = `/${transformed.join('/')}`.replace(/\/+/g, '/');
118
+ return route === '' ? '/' : route;
119
+ }
120
+
121
+ function parseApiRouteFromAppFile(filePath) {
122
+ const rel = filePath.replace(/\\/g, '/').replace(/^app\/api\//, '').replace(/(^|\/)route\.[^.]+$/, '');
123
+ if (!rel) {
124
+ return '/api';
125
+ }
126
+ return `/api/${toRouteFromFileSegments(rel.split('/')).replace(/^\//, '')}`.replace(/\/+$/, '');
127
+ }
128
+
129
+ function parseApiRouteFromPagesFile(filePath) {
130
+ const rel = filePath.replace(/\\/g, '/').replace(/^pages\/api\//, '').replace(/\.[^.]+$/, '');
131
+ const route = toRouteFromFileSegments(rel.split('/'));
132
+ return `/api${route === '/' ? '' : route}`.replace(/\/+$/, '') || '/api';
133
+ }
134
+
135
+ function parsePageRouteFromPagesFile(filePath) {
136
+ const rel = filePath.replace(/\\/g, '/').replace(/^pages\//, '').replace(/\.[^.]+$/, '');
137
+ const chunks = rel.split('/').filter(Boolean);
138
+ const first = chunks[0] || '';
139
+ const excludedFiles = new Set(['_app', '_document', '_error', '404', '500']);
140
+ if (first === 'api' || excludedFiles.has(first)) {
141
+ return null;
142
+ }
143
+ return toRouteFromFileSegments(chunks);
144
+ }
145
+
146
+ function parsePageRouteFromAppFile(filePath) {
147
+ const rel = filePath.replace(/\\/g, '/').replace(/^app\//, '').replace(/(^|\/)page\.[^.]+$/, '');
148
+ if (rel.startsWith('api/')) {
149
+ return null;
150
+ }
151
+ if (!rel) {
152
+ return '/';
153
+ }
154
+ const chunks = rel
155
+ .split('/')
156
+ .filter(Boolean)
157
+ .filter((part) => !/^\(.+\)$/.test(part) && !part.startsWith('@'));
158
+ return toRouteFromFileSegments(chunks);
159
+ }
160
+
161
+ function collectFilesRecursively(dirPath, matcher) {
162
+ if (!fs.existsSync(dirPath)) {
163
+ return [];
164
+ }
165
+
166
+ const stack = [dirPath];
167
+ const found = [];
168
+ while (stack.length > 0) {
169
+ const current = stack.pop();
170
+ let entries = [];
171
+ try {
172
+ entries = fs.readdirSync(current, { withFileTypes: true });
173
+ } catch (error) {
174
+ return found;
175
+ }
176
+
177
+ for (const entry of entries) {
178
+ const fullPath = path.join(current, entry.name);
179
+ if (entry.isDirectory()) {
180
+ stack.push(fullPath);
181
+ continue;
182
+ }
183
+ if (entry.isFile() && matcher(fullPath)) {
184
+ found.push(fullPath);
185
+ }
186
+ }
187
+ }
188
+ return found;
189
+ }
190
+
191
+ function collectRoutesFromFilesystem(sourcePath) {
192
+ const api = [];
193
+ const pages = [];
194
+ const appDirCandidates = [path.join(sourcePath, 'app'), path.join(sourcePath, 'src', 'app')];
195
+ const pagesDirCandidates = [path.join(sourcePath, 'pages'), path.join(sourcePath, 'src', 'pages')];
196
+
197
+ for (const appDir of appDirCandidates) {
198
+ const appApiFiles = collectFilesRecursively(
199
+ path.join(appDir, 'api'),
200
+ (file) => /[\\/]route\.(js|jsx|ts|tsx|mjs|cjs)$/.test(file)
201
+ );
202
+ for (const absFile of appApiFiles) {
203
+ const rel = path.relative(path.join(appDir, 'api'), absFile).replace(/\\/g, '/');
204
+ api.push(parseApiRouteFromAppFile(`app/api/${rel}`));
205
+ }
206
+
207
+ const appPageFiles = collectFilesRecursively(appDir, (file) => /[\\/]page\.(js|jsx|ts|tsx|mdx)$/.test(file));
208
+ for (const absFile of appPageFiles) {
209
+ const rel = path.relative(appDir, absFile).replace(/\\/g, '/');
210
+ const route = parsePageRouteFromAppFile(`app/${rel}`);
211
+ if (route) {
212
+ pages.push(route);
213
+ }
214
+ }
215
+ }
216
+
217
+ for (const pagesDir of pagesDirCandidates) {
218
+ const pagesApiFiles = collectFilesRecursively(path.join(pagesDir, 'api'), (file) => /\.(js|jsx|ts|tsx|mjs|cjs)$/.test(file));
219
+ for (const absFile of pagesApiFiles) {
220
+ const rel = path.relative(path.join(pagesDir, 'api'), absFile).replace(/\\/g, '/');
221
+ api.push(parseApiRouteFromPagesFile(`pages/api/${rel}`));
222
+ }
223
+
224
+ const pagesFiles = collectFilesRecursively(pagesDir, (file) => /\.(js|jsx|ts|tsx|mdx)$/.test(file));
225
+ for (const absFile of pagesFiles) {
226
+ const rel = path.relative(pagesDir, absFile).replace(/\\/g, '/');
227
+ const route = parsePageRouteFromPagesFile(`pages/${rel}`);
228
+ if (route) {
229
+ pages.push(route);
230
+ }
231
+ }
232
+ }
233
+
234
+ return {
235
+ api: normalizeList(api),
236
+ pages: normalizeList(pages)
237
+ };
238
+ }
239
+
240
+ function readJsonIfExists(filePath) {
241
+ if (!fs.existsSync(filePath)) {
242
+ return null;
243
+ }
244
+ try {
245
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
246
+ } catch (error) {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ function collectRoutesFromBuildArtifacts(sourcePath) {
252
+ const api = [];
253
+ const pages = [];
254
+
255
+ const routesManifest = readJsonIfExists(path.join(sourcePath, '.next', 'routes-manifest.json'));
256
+ if (routesManifest) {
257
+ const combined = []
258
+ .concat(routesManifest.staticRoutes || [])
259
+ .concat(routesManifest.dynamicRoutes || [])
260
+ .concat(routesManifest.dataRoutes || []);
261
+ for (const routeInfo of combined) {
262
+ const route = routeInfo.page || routeInfo.pathname || routeInfo.route;
263
+ if (!route || typeof route !== 'string') {
264
+ continue;
265
+ }
266
+ if (route.startsWith('/api')) {
267
+ api.push(route);
268
+ } else {
269
+ pages.push(route);
270
+ }
271
+ }
272
+ }
273
+
274
+ const appRoutesManifest = readJsonIfExists(path.join(sourcePath, '.next', 'server', 'app-path-routes-manifest.json'))
275
+ || readJsonIfExists(path.join(sourcePath, '.next', 'server', 'app-paths-manifest.json'))
276
+ || readJsonIfExists(path.join(sourcePath, '.next', 'app-path-routes-manifest.json'));
277
+ if (appRoutesManifest && typeof appRoutesManifest === 'object') {
278
+ Object.entries(appRoutesManifest).forEach(([key, routeValue]) => {
279
+ const route = typeof routeValue === 'string' ? routeValue : key;
280
+ if (typeof route !== 'string') {
281
+ return;
282
+ }
283
+ if (route.startsWith('/_')) {
284
+ return;
285
+ }
286
+ if (route.startsWith('/api')) {
287
+ api.push(route);
288
+ } else {
289
+ pages.push(route);
290
+ }
291
+ });
292
+ }
293
+
294
+ const pagesManifest = readJsonIfExists(path.join(sourcePath, '.next', 'server', 'pages-manifest.json'));
295
+ if (pagesManifest && typeof pagesManifest === 'object') {
296
+ Object.keys(pagesManifest).forEach((route) => {
297
+ if (route === '/_app' || route === '/_document' || route === '/_error' || route === '/404' || route === '/500') {
298
+ return;
299
+ }
300
+ if (route.startsWith('/api')) {
301
+ api.push(route);
302
+ } else {
303
+ pages.push(route);
304
+ }
305
+ });
306
+ }
307
+
308
+ return {
309
+ api: normalizeList(api),
310
+ pages: normalizeList(pages)
311
+ };
312
+ }
313
+
314
+ function runBuildInSourcePath(sourcePath, packageManager) {
315
+ const manager = packageManager || 'npm';
316
+ const hasNodeModules = fs.existsSync(path.join(sourcePath, 'node_modules'));
317
+ const hasPackageLock = fs.existsSync(path.join(sourcePath, 'package-lock.json'));
318
+ const hasYarnLock = fs.existsSync(path.join(sourcePath, 'yarn.lock'));
319
+ const hasBunLock = fs.existsSync(path.join(sourcePath, 'bun.lockb'));
320
+ const installCommand = manager === 'yarn'
321
+ ? (hasYarnLock ? 'yarn install --frozen-lockfile' : 'yarn install')
322
+ : manager === 'bun'
323
+ ? 'bun install'
324
+ : (hasPackageLock ? 'npm ci' : 'npm install');
325
+ const buildCommand = manager === 'yarn' ? 'yarn build' : manager === 'bun' ? 'bun run build' : 'npm run build';
326
+
327
+ try {
328
+ if (!hasNodeModules) {
329
+ execSync(installCommand, { cwd: sourcePath, stdio: 'pipe', timeout: 600000 });
330
+ } else if (!hasPackageLock && !hasYarnLock && !hasBunLock) {
331
+ execSync(installCommand, { cwd: sourcePath, stdio: 'pipe', timeout: 600000 });
332
+ }
333
+
334
+ execSync(buildCommand, { cwd: sourcePath, stdio: 'pipe', timeout: 900000 });
335
+ return true;
336
+ } catch (error) {
337
+ return false;
338
+ }
339
+ }
340
+
341
+ function buildGitLikeDiff(asIsList, toBeList, title) {
342
+ const asIs = normalizeList(asIsList);
343
+ const toBe = normalizeList(toBeList);
344
+ const asIsSet = new Set(asIs);
345
+ const toBeSet = new Set(toBe);
346
+ const removed = asIs.filter((item) => !toBeSet.has(item));
347
+ const added = toBe.filter((item) => !asIsSet.has(item));
348
+ const unchanged = asIs.filter((item) => toBeSet.has(item));
349
+
350
+ const lines = [
351
+ `--- AS_IS/${title}`,
352
+ `+++ TO_BE/${title}`,
353
+ `@@ ${title} @@`
354
+ ];
355
+ removed.forEach((item) => lines.push(`-${item}`));
356
+ added.forEach((item) => lines.push(`+${item}`));
357
+ if (removed.length === 0 && added.length === 0) {
358
+ lines.push(' (no changes)');
359
+ }
360
+
361
+ return {
362
+ removed,
363
+ added,
364
+ unchanged,
365
+ text: lines.join('\n')
366
+ };
367
+ }
368
+
369
+ function printSection(title, asIsList, toBeList) {
370
+ const asIs = normalizeList(asIsList);
371
+ const toBe = normalizeList(toBeList);
372
+ const diff = buildGitLikeDiff(asIs, toBe, title);
373
+
374
+ console.log(`\nСравнение путей ${title} (КАК ЕСТЬ -> КАК ДОЛЖНО БЫТЬ):`);
375
+ console.log(`--- AS_IS/${title}`);
376
+ console.log(`+++ TO_BE/${title}`);
377
+ console.log(`@@ ${title} @@`);
378
+ if (diff.removed.length === 0 && diff.added.length === 0) {
379
+ console.log(' (без изменений)');
380
+ } else {
381
+ diff.removed.forEach((item) => console.log(`${colors.yellow}-${item}${colors.reset}`));
382
+ diff.added.forEach((item) => console.log(`${colors.red}+${item}${colors.reset}`));
383
+ }
384
+ console.log(`${title} сводка: добавлено=${diff.added.length}, удалено=${diff.removed.length}, без изменений=${diff.unchanged.length}`);
385
+
386
+ return diff;
387
+ }
388
+
389
+ function chartVerify(sourcePath) {
390
+ return runCommand({
391
+ name: 'chart verify',
392
+ checks: [
393
+ { name: 'git-repo', run: requireGitRepo },
394
+ { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
395
+ { name: 'single-values-yaml', run: requireSingleValuesYaml },
396
+ { name: 'values-ingress-sections', run: (ctx) => requireIngressPathSections(ctx.valuesYamlPath) },
397
+ { name: 'source-path-directory', run: () => requireSourcePathDirectory(sourcePath) },
398
+ { name: 'next-project', run: (ctx) => requireNextProject(ctx.sourcePath) },
399
+ { name: 'build-command-support', run: (ctx) => requireBuildCommandSupport(ctx.sourcePath) }
400
+ ],
401
+ steps: [
402
+ {
403
+ name: 'collect-routes',
404
+ run: (ctx) => {
405
+ const fromBuild = collectRoutesFromBuildArtifacts(ctx.sourcePath);
406
+ let api = normalizeList(fromBuild.api);
407
+ let pages = normalizeList(fromBuild.pages);
408
+
409
+ if (api.length === 0 || pages.length === 0) {
410
+ const didBuild = runBuildInSourcePath(ctx.sourcePath, ctx.sourcePackageManager);
411
+ if (didBuild) {
412
+ logSuccess('🛠️', 'Сборка успешно завершена в %s.', ctx.sourcePath);
413
+ const rebuilt = collectRoutesFromBuildArtifacts(ctx.sourcePath);
414
+ api = normalizeList(api.concat(rebuilt.api));
415
+ pages = normalizeList(pages.concat(rebuilt.pages));
416
+ } else {
417
+ logError('⚠️', 'Сборка в %s не удалась. Переходим к маршрутам из файловой системы.', ctx.sourcePath);
418
+ }
419
+ }
420
+
421
+ const fromFs = collectRoutesFromFilesystem(ctx.sourcePath);
422
+ api = normalizeList(api.concat(fromFs.api));
423
+ pages = normalizeList(pages.concat(fromFs.pages));
424
+
425
+ ctx.toBeIngressPaths = {
426
+ api: normalizeToBeList(api),
427
+ pages: normalizeToBeList(pages),
428
+ assets: normalizeToBeList(FIXED_ASSET_PATHS)
429
+ };
430
+ return true;
431
+ }
432
+ },
433
+ {
434
+ name: 'compare-and-report',
435
+ run: (ctx) => {
436
+ const asIs = ctx.valuesIngressPaths || { api: [], pages: [], assets: [] };
437
+ const toBe = ctx.toBeIngressPaths || { api: [], pages: [], assets: [] };
438
+
439
+ const apiDiff = printSection('api', asIs.api, toBe.api);
440
+ const pagesDiff = printSection('pages', asIs.pages, toBe.pages);
441
+ const assetsDiff = printSection('assets', asIs.assets, toBe.assets);
442
+
443
+ const hasChanges = (
444
+ apiDiff.added.length + apiDiff.removed.length +
445
+ pagesDiff.added.length + pagesDiff.removed.length +
446
+ assetsDiff.added.length + assetsDiff.removed.length
447
+ ) > 0;
448
+
449
+ if (hasChanges) {
450
+ logError('❌', '[chart verify] Обнаружены различия.');
451
+ return false;
452
+ }
453
+
454
+ logSuccess('✅', '[chart verify] КАК ЕСТЬ совпадает с КАК ДОЛЖНО БЫТЬ.');
455
+ return true;
456
+ }
457
+ }
458
+ ]
459
+ });
460
+ }
461
+
462
+ function chartCreateTag(version) {
463
+ return runCommand({
464
+ name: 'chart create',
465
+ checks: [
466
+ { name: 'git-repo', run: requireGitRepo },
467
+ { name: 'clean-working-tree', run: requireCleanWorkingTree },
468
+ { name: 'on-main-branch', run: requireOnMainBranch },
469
+ { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
470
+ {
471
+ name: 'valid-semver',
472
+ run: () => {
473
+ if (!isSemver(version)) {
474
+ return { ok: false, reason: `Версия "${version}" не соответствует semver.` };
475
+ }
476
+ return { ok: true, data: { version } };
477
+ }
478
+ },
479
+ { name: 'single-chart', run: requireSingleChart },
480
+ {
481
+ name: 'tag-missing',
482
+ run: (ctx) => requireTagMissing(`chart-${ctx.chartName}-${ctx.version}`)
483
+ }
484
+ ],
485
+ steps: [
486
+ {
487
+ name: 'create-tag',
488
+ run: (ctx) => {
489
+ const tagName = `chart-${ctx.chartName}-${ctx.version}`;
490
+ if (!execCommand(`git tag "${tagName}"`)) {
491
+ logError('❌', 'Не удалось создать тег %s.', tagName);
492
+ return false;
493
+ }
494
+ logSuccess('🔖', 'Создан тег %s.', tagName);
495
+ return true;
496
+ }
497
+ },
498
+ {
499
+ name: 'push-tag',
500
+ run: (ctx) => {
501
+ const tagName = `chart-${ctx.chartName}-${ctx.version}`;
502
+ if (!execCommand(`git push origin "${tagName}"`)) {
503
+ logError('❌', 'Не удалось отправить тег %s в origin.', tagName);
504
+ return false;
505
+ }
506
+ logSuccess('🚀', 'Тег %s отправлен в origin.', tagName);
507
+ return true;
508
+ }
509
+ }
510
+ ]
511
+ });
512
+ }
513
+
514
+ function compareSemver(a, b) {
515
+ const parse = (value) => {
516
+ const [rawMain, rawMeta] = String(value || '').split('+', 2);
517
+ const [rawVersion, rawPre] = rawMain.split('-', 2);
518
+ const versionParts = rawVersion.split('.').map((part) => Number(part));
519
+ const preParts = rawPre ? rawPre.split('.') : [];
520
+ return { versionParts, preParts, hasPre: Boolean(rawPre), meta: rawMeta || '' };
521
+ };
522
+ const pa = parse(a);
523
+ const pb = parse(b);
524
+
525
+ for (let i = 0; i < 3; i += 1) {
526
+ const diff = (pa.versionParts[i] || 0) - (pb.versionParts[i] || 0);
527
+ if (diff !== 0) return diff;
528
+ }
529
+
530
+ if (pa.hasPre && !pb.hasPre) return -1;
531
+ if (!pa.hasPre && pb.hasPre) return 1;
532
+ if (!pa.hasPre && !pb.hasPre) return 0;
533
+
534
+ const len = Math.max(pa.preParts.length, pb.preParts.length);
535
+ for (let i = 0; i < len; i += 1) {
536
+ const aa = pa.preParts[i];
537
+ const bb = pb.preParts[i];
538
+ if (aa === undefined) return -1;
539
+ if (bb === undefined) return 1;
540
+ const aNum = /^\d+$/.test(aa);
541
+ const bNum = /^\d+$/.test(bb);
542
+ if (aNum && bNum) {
543
+ const diff = Number(aa) - Number(bb);
544
+ if (diff !== 0) return diff;
545
+ continue;
546
+ }
547
+ if (aNum && !bNum) return -1;
548
+ if (!aNum && bNum) return 1;
549
+ if (aa < bb) return -1;
550
+ if (aa > bb) return 1;
551
+ }
552
+
553
+ return 0;
554
+ }
555
+
556
+ function getLatestRemoteChartVersion(chartName) {
557
+ const safeChartName = String(chartName || '').trim();
558
+ if (!safeChartName) return null;
559
+
560
+ const prefix = `chart-${safeChartName}-`;
561
+ let output = '';
562
+ try {
563
+ output = execSync(`git ls-remote --tags origin "refs/tags/${prefix}*"`, { stdio: 'pipe', encoding: 'utf8' });
564
+ } catch (error) {
565
+ return null;
566
+ }
567
+
568
+ const versions = String(output || '')
569
+ .split('\n')
570
+ .map((line) => line.trim())
571
+ .filter(Boolean)
572
+ .map((line) => line.split(/\s+/)[1] || '')
573
+ .map((ref) => ref.replace('refs/tags/', ''))
574
+ .filter((tag) => tag.startsWith(prefix))
575
+ .map((tag) => tag.slice(prefix.length))
576
+ .filter((version) => isSemver(version));
577
+
578
+ if (versions.length === 0) {
579
+ return null;
580
+ }
581
+
582
+ versions.sort(compareSemver);
583
+ return versions[versions.length - 1];
584
+ }
585
+
586
+ function updateHelmReleaseVersion(filePath, nextVersion) {
587
+ const content = fs.readFileSync(filePath, 'utf8');
588
+ const lines = content.split('\n');
589
+ const keyStack = [];
590
+ const oldVersions = [];
591
+ let changed = false;
592
+
593
+ const updatedLines = lines.map((line) => {
594
+ const keyMatch = line.match(/^(\s*)([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
595
+ if (!keyMatch || line.trim().startsWith('#')) {
596
+ return line;
597
+ }
598
+
599
+ const indent = keyMatch[1].length;
600
+ const key = keyMatch[2];
601
+
602
+ while (keyStack.length > 0 && keyStack[keyStack.length - 1].indent >= indent) {
603
+ keyStack.pop();
604
+ }
605
+
606
+ const parentPath = keyStack.map((item) => item.key).join('.');
607
+ keyStack.push({ key, indent });
608
+ if (!(parentPath === 'spec.chart.spec' && key === 'version')) {
609
+ return line;
610
+ }
611
+
612
+ const valuePart = keyMatch[3];
613
+ const valueMatch = valuePart.match(/^(\s*)(['"]?)([^'"#\s]+)\2(\s*(?:#.*)?)$/);
614
+ if (!valueMatch) {
615
+ return line;
616
+ }
617
+
618
+ const oldVersion = valueMatch[3];
619
+ oldVersions.push(oldVersion);
620
+ if (oldVersion === nextVersion) {
621
+ return line;
622
+ }
623
+
624
+ changed = true;
625
+ const spacing = valueMatch[1] || ' ';
626
+ return `${keyMatch[1]}version:${spacing}${valueMatch[2]}${nextVersion}${valueMatch[2]}${valueMatch[4]}`;
627
+ });
628
+
629
+ if (changed) {
630
+ fs.writeFileSync(filePath, updatedLines.join('\n'));
631
+ }
632
+
633
+ const uniqueOld = Array.from(new Set(oldVersions));
634
+ return {
635
+ filePath,
636
+ oldVersion: uniqueOld.length > 0 ? uniqueOld.join(', ') : 'unknown',
637
+ newVersion: nextVersion,
638
+ changed
639
+ };
640
+ }
641
+
642
+ function printDeployChanges(changes) {
643
+ changes.forEach((item) => {
644
+ const icon = item.changed ? '●' : '○';
645
+ const color = item.changed ? colors.green : '\x1b[90m';
646
+ console.log(
647
+ `${color}${icon} ${item.filePath}: ${item.oldVersion} -> ${item.newVersion}${colors.reset}`
648
+ );
649
+ });
650
+ }
651
+
652
+ function waitForEnter(promptText) {
653
+ return new Promise((resolve) => {
654
+ const rl = readline.createInterface({
655
+ input: process.stdin,
656
+ output: process.stdout
657
+ });
658
+ rl.question(promptText, () => {
659
+ rl.close();
660
+ resolve(true);
661
+ });
662
+ });
663
+ }
664
+
665
+ function splitGitNameOnly(output) {
666
+ if (output === null || output === undefined) {
667
+ return null;
668
+ }
669
+ return String(output)
670
+ .split('\n')
671
+ .map((line) => line.trim().replace(/\\/g, '/'))
672
+ .filter(Boolean);
673
+ }
674
+
675
+ function chartDeploy() {
676
+ return runCommand({
677
+ name: 'chart deploy',
678
+ checks: [
679
+ { name: 'git-repo', run: requireGitRepo },
680
+ { name: 'clean-working-tree', run: requireCleanWorkingTree },
681
+ { name: 'on-main-branch', run: requireOnMainBranch },
682
+ { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
683
+ { name: 'remote-origin', run: requireRemoteOrigin },
684
+ { name: 'remote-reachable', run: requireRemoteReachable },
685
+ { name: 'single-chart', run: requireSingleChart },
686
+ { name: 'helmrelease-files', run: requireHelmReleaseFiles }
687
+ ],
688
+ steps: [
689
+ {
690
+ name: 'resolve-latest-chart-version',
691
+ run: (ctx) => {
692
+ const latestVersion = getLatestRemoteChartVersion(ctx.chartName);
693
+ if (!latestVersion) {
694
+ logError('❌', 'Не удалось найти удаленный тег чарта для %s.', ctx.chartName);
695
+ return false;
696
+ }
697
+ ctx.latestChartVersion = latestVersion;
698
+ logSuccess('🏷️', 'Последняя удаленная версия чарта для %s: %s.', ctx.chartName, latestVersion);
699
+ return true;
700
+ }
701
+ },
702
+ {
703
+ name: 'update-helmrelease-files',
704
+ run: (ctx) => {
705
+ const changes = (ctx.helmReleaseFiles || []).map((filePath) => updateHelmReleaseVersion(filePath, ctx.latestChartVersion));
706
+ ctx.deployChanges = changes;
707
+ ctx.updatedFiles = changes.filter((item) => item.changed).map((item) => item.filePath);
708
+
709
+ printDeployChanges(changes);
710
+ if (ctx.updatedFiles.length === 0) {
711
+ ctx.noUpdates = true;
712
+ logSuccess('✅', 'Все файлы helmrelease.yaml уже используют последнюю версию чарта.');
713
+ return true;
714
+ }
715
+
716
+ return true;
717
+ }
718
+ },
719
+ {
720
+ name: 'confirm-publish',
721
+ run: async (ctx) => {
722
+ if (ctx.noUpdates) {
723
+ return true;
724
+ }
725
+ await waitForEnter('Готов ли ты к публикации? Нажми Enter для продолжения (Ctrl+C для отмены): ');
726
+ return true;
727
+ }
728
+ },
729
+ {
730
+ name: 'commit-and-push',
731
+ run: (ctx) => {
732
+ if (ctx.noUpdates) {
733
+ return true;
734
+ }
735
+ const files = ctx.updatedFiles || [];
736
+ const normalizedFiles = files.map((filePath) => String(filePath || '').replace(/\\/g, '/'));
737
+ if (files.length === 0) {
738
+ logSuccess('✅', 'Файлы не были изменены.');
739
+ return true;
740
+ }
741
+
742
+ const stagedBeforeAdd = splitGitNameOnly(execSilent('git diff --cached --name-only'));
743
+ const unstagedBeforeAdd = splitGitNameOnly(execSilent('git diff --name-only'));
744
+ if (stagedBeforeAdd === null || unstagedBeforeAdd === null) {
745
+ logError('❌', 'Не удалось проверить измененные файлы перед коммитом.');
746
+ return false;
747
+ }
748
+ if (stagedBeforeAdd.length > 0) {
749
+ logError('❌', 'Обнаружены неожиданные изменения в индексе: %s', stagedBeforeAdd.join(', '));
750
+ return false;
751
+ }
752
+
753
+ const expectedSet = new Set(normalizedFiles);
754
+ const unexpectedChanges = unstagedBeforeAdd.filter((filePath) => !expectedSet.has(filePath));
755
+ if (unexpectedChanges.length > 0) {
756
+ logError('❌', 'Обнаружены неожиданные изменения файлов: %s', unexpectedChanges.join(', '));
757
+ return false;
758
+ }
759
+
760
+ const missingExpectedChanges = normalizedFiles.filter((filePath) => !unstagedBeforeAdd.includes(filePath));
761
+ if (missingExpectedChanges.length > 0) {
762
+ logError('❌', 'В diff отсутствуют ожидаемые обновленные файлы: %s', missingExpectedChanges.join(', '));
763
+ return false;
764
+ }
765
+
766
+ const quoted = normalizedFiles.map((filePath) => `"${filePath}"`).join(' ');
767
+ if (!execCommand(`git add ${quoted}`)) {
768
+ logError('❌', 'Не удалось добавить обновленные файлы helmrelease.');
769
+ return false;
770
+ }
771
+
772
+ const stagedAfterAdd = splitGitNameOnly(execSilent('git diff --cached --name-only'));
773
+ if (stagedAfterAdd === null) {
774
+ logError('❌', 'Не удалось проверить staged-файлы после git add.');
775
+ return false;
776
+ }
777
+ const unexpectedStaged = stagedAfterAdd.filter((filePath) => !expectedSet.has(filePath));
778
+ const missingStaged = normalizedFiles.filter((filePath) => !stagedAfterAdd.includes(filePath));
779
+ if (unexpectedStaged.length > 0 || missingStaged.length > 0) {
780
+ if (unexpectedStaged.length > 0) {
781
+ logError('❌', 'Обнаружены неожиданные staged-файлы: %s', unexpectedStaged.join(', '));
782
+ }
783
+ if (missingStaged.length > 0) {
784
+ logError('❌', 'Ожидаемые файлы не добавлены в индекс: %s', missingStaged.join(', '));
785
+ }
786
+ return false;
787
+ }
788
+
789
+ if (!execCommand('git commit -m "🚀 Деплой сервиса."')) {
790
+ logError('❌', 'Не удалось создать коммит деплоя.');
791
+ return false;
792
+ }
793
+ if (!execCommand('git push')) {
794
+ logError('❌', 'Не удалось отправить коммит деплоя.');
795
+ return false;
796
+ }
797
+
798
+ logSuccess('🚀', 'Коммит деплоя отправлен.');
799
+ return true;
800
+ }
801
+ }
802
+ ]
803
+ });
804
+ }
805
+
806
+ module.exports = {
807
+ chartCreateTag,
808
+ chartDeploy,
809
+ chartVerify,
810
+ normalizeList,
811
+ normalizeToBeList,
812
+ collectRoutesFromFilesystem,
813
+ collectRoutesFromBuildArtifacts,
814
+ buildGitLikeDiff,
815
+ getChartName,
816
+ getChartFiles,
817
+ isSemver,
818
+ compareSemver,
819
+ getLatestRemoteChartVersion,
820
+ updateHelmReleaseVersion
821
+ };