spectrum-cli 3.2.0 → 4.0.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +56 -26
  3. package/index.js +6 -174
  4. package/package.json +4 -4
  5. package/src/changelog/commands.js +44 -0
  6. package/src/changelog/format.js +51 -0
  7. package/src/changelog/fragments.js +223 -0
  8. package/src/{hotfix-changelog.js → changelog/hotfix.js} +2 -2
  9. package/src/changelog/index.js +7 -0
  10. package/src/{open-release.js → changelog/open-release.js} +6 -6
  11. package/src/changelog/release-block.js +80 -0
  12. package/src/chart/deploy.js +216 -0
  13. package/src/chart/helmrelease.js +90 -0
  14. package/src/chart/index.js +30 -0
  15. package/src/chart/metadata.js +114 -0
  16. package/src/chart/registry.js +139 -0
  17. package/src/chart/routes.js +244 -0
  18. package/src/chart/start.js +98 -0
  19. package/src/chart/verify.js +166 -0
  20. package/src/cli/command-executor.js +181 -0
  21. package/src/cli/commands.js +136 -0
  22. package/src/{development.js → cli/development.js} +1 -1
  23. package/src/cli/help.js +55 -0
  24. package/src/cli/run-action.js +28 -0
  25. package/src/{update-check.js → cli/update-check.js} +8 -2
  26. package/src/{git.js → git/index.js} +4 -3
  27. package/src/hotfix/checks.js +144 -0
  28. package/src/hotfix/close.js +46 -0
  29. package/src/hotfix/deploy.js +37 -0
  30. package/src/hotfix/git.js +35 -0
  31. package/src/hotfix/index.js +5 -0
  32. package/src/hotfix/start.js +57 -0
  33. package/src/integrations/gitlab-url.js +16 -0
  34. package/src/preflight/changelog.js +130 -0
  35. package/src/preflight/chart.js +57 -0
  36. package/src/preflight/git.js +133 -0
  37. package/src/preflight/helmrelease.js +201 -0
  38. package/src/preflight/index.js +10 -0
  39. package/src/preflight/result.js +18 -0
  40. package/src/preflight/source.js +95 -0
  41. package/src/preflight/tags.js +139 -0
  42. package/src/release/close.js +39 -0
  43. package/src/release/index.js +7 -0
  44. package/src/{stable-tags.js → release/stable-tags.js} +1 -1
  45. package/src/release/start.js +51 -0
  46. package/src/release/steps.js +83 -0
  47. package/src/release/version.js +20 -0
  48. package/src/token/config.js +163 -0
  49. package/src/token/gitlab-api.js +59 -0
  50. package/src/token/index.js +6 -0
  51. package/src/token/rotate.js +157 -0
  52. package/src/changelog.js +0 -418
  53. package/src/chart.js +0 -1071
  54. package/src/command-executor.js +0 -70
  55. package/src/hotfix.js +0 -208
  56. package/src/preflight.js +0 -796
  57. package/src/release.js +0 -207
  58. package/src/token.js +0 -399
  59. /package/src/{changelog-config.js → changelog/config.js} +0 -0
  60. /package/src/{utils.js → common/utils.js} +0 -0
  61. /package/src/{version.js → common/version.js} +0 -0
package/src/chart.js DELETED
@@ -1,1071 +0,0 @@
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, getRemoteUrl, colors } = require('./utils');
7
- const { runCommand } = require('./command-executor');
8
- const { parseGitlabUrl } = require('./token');
9
- const {
10
- requireGitRepo,
11
- requireCleanWorkingTree,
12
- requireOnMainBranch,
13
- requireCurrentBranchUpToDateWithRemote,
14
- requireRemoteOrigin,
15
- requireRemoteReachable,
16
- requireSingleChart,
17
- requireTagMissing,
18
- requireChartChangelogVersion,
19
- requireHelmReleaseFiles,
20
- requireSingleValuesYaml,
21
- requireIngressPathSections,
22
- requireSourcePathDirectory,
23
- requireNextProject,
24
- requireBuildCommandSupport,
25
- SEMVER_PATTERN
26
- } = require('./preflight');
27
-
28
- const CHARTS_DIR = 'charts';
29
- const FIXED_ASSET_PATHS = [
30
- '/_next/image$',
31
- '/_next/static/media/[a-z0-9_\\.\\-]+\\.(woff|woff2|ttf|otf|eot)$',
32
- '/_next/static/chunks/[a-z0-9_\\.\\-]+\\.(css|js)$',
33
- '/_next/static/[a-zA-Z0-9_-]+/_ssgManifest.js$',
34
- '/_next/static/[a-zA-Z0-9_-]+/_buildManifest.js$',
35
- '/_next/static/[a-zA-Z0-9_-]+/_clientMiddlewareManifest.json$',
36
- '/[^?]+\\.(?:ico|svg|png|jpg|jpeg|gif|webp|txt|map)$'
37
- ];
38
-
39
- function getChartFiles() {
40
- try {
41
- if (!fs.existsSync(CHARTS_DIR)) {
42
- return [];
43
- }
44
-
45
- const entries = fs.readdirSync(CHARTS_DIR, { withFileTypes: true });
46
- const chartFiles = entries
47
- .filter((entry) => entry.isDirectory())
48
- .map((entry) => `${CHARTS_DIR}/${entry.name}/Chart.yaml`)
49
- .filter((chartFilePath) => fs.existsSync(chartFilePath));
50
-
51
- return chartFiles;
52
- } catch (error) {
53
- return [];
54
- }
55
- }
56
-
57
- function getChartName(chartFilePath) {
58
- try {
59
- if (!chartFilePath || !fs.existsSync(chartFilePath)) {
60
- return null;
61
- }
62
-
63
- const chartYaml = fs.readFileSync(chartFilePath, 'utf8');
64
- const nameMatch = chartYaml.match(/^\s*name:\s*([^\s#]+)\s*$/m);
65
- return nameMatch ? nameMatch[1] : null;
66
- } catch (error) {
67
- return null;
68
- }
69
- }
70
-
71
- function isSemver(version) {
72
- return SEMVER_PATTERN.test(version);
73
- }
74
-
75
- function normalizeList(list) {
76
- const result = [];
77
- const seen = new Set();
78
- for (const item of (Array.isArray(list) ? list : [])) {
79
- const value = String(item || '').trim();
80
- if (!value || seen.has(value)) {
81
- continue;
82
- }
83
- seen.add(value);
84
- result.push(value);
85
- }
86
- return result;
87
- }
88
-
89
- function ensureEndsWithDollar(value) {
90
- const text = String(value || '').trim();
91
- if (!text) {
92
- return '';
93
- }
94
- return text.endsWith('$') ? text : `${text}$`;
95
- }
96
-
97
- function normalizeToBeList(list) {
98
- return normalizeList(list.map(ensureEndsWithDollar));
99
- }
100
-
101
- function segmentToPattern(segment) {
102
- if (segment === 'index') {
103
- return '';
104
- }
105
- if (/^\[\[\.\.\..+\]\]$/.test(segment)) {
106
- return '.*';
107
- }
108
- if (/^\[\.\.\..+\]$/.test(segment)) {
109
- return '.+';
110
- }
111
- if (/^\[.+\]$/.test(segment)) {
112
- return '[^/]+';
113
- }
114
- return segment;
115
- }
116
-
117
- function toRouteFromFileSegments(segments) {
118
- const transformed = segments.map(segmentToPattern).filter(Boolean);
119
- const route = `/${transformed.join('/')}`.replace(/\/+/g, '/');
120
- return route === '' ? '/' : route;
121
- }
122
-
123
- function parseApiRouteFromAppFile(filePath) {
124
- const rel = filePath.replace(/\\/g, '/').replace(/^app\/api\//, '').replace(/(^|\/)route\.[^.]+$/, '');
125
- if (!rel) {
126
- return '/api';
127
- }
128
- return `/api/${toRouteFromFileSegments(rel.split('/')).replace(/^\//, '')}`.replace(/\/+$/, '');
129
- }
130
-
131
- function parseApiRouteFromPagesFile(filePath) {
132
- const rel = filePath.replace(/\\/g, '/').replace(/^pages\/api\//, '').replace(/\.[^.]+$/, '');
133
- const route = toRouteFromFileSegments(rel.split('/'));
134
- return `/api${route === '/' ? '' : route}`.replace(/\/+$/, '') || '/api';
135
- }
136
-
137
- function parsePageRouteFromPagesFile(filePath) {
138
- const rel = filePath.replace(/\\/g, '/').replace(/^pages\//, '').replace(/\.[^.]+$/, '');
139
- const chunks = rel.split('/').filter(Boolean);
140
- const first = chunks[0] || '';
141
- const excludedFiles = new Set(['_app', '_document', '_error', '404', '500']);
142
- if (first === 'api' || excludedFiles.has(first)) {
143
- return null;
144
- }
145
- return toRouteFromFileSegments(chunks);
146
- }
147
-
148
- function parsePageRouteFromAppFile(filePath) {
149
- const rel = filePath.replace(/\\/g, '/').replace(/^app\//, '').replace(/(^|\/)page\.[^.]+$/, '');
150
- if (rel.startsWith('api/')) {
151
- return null;
152
- }
153
- if (!rel) {
154
- return '/';
155
- }
156
- const chunks = rel
157
- .split('/')
158
- .filter(Boolean)
159
- .filter((part) => !/^\(.+\)$/.test(part) && !part.startsWith('@'));
160
- return toRouteFromFileSegments(chunks);
161
- }
162
-
163
- function collectFilesRecursively(dirPath, matcher) {
164
- if (!fs.existsSync(dirPath)) {
165
- return [];
166
- }
167
-
168
- const stack = [dirPath];
169
- const found = [];
170
- while (stack.length > 0) {
171
- const current = stack.pop();
172
- let entries = [];
173
- try {
174
- entries = fs.readdirSync(current, { withFileTypes: true });
175
- } catch (error) {
176
- return found;
177
- }
178
-
179
- for (const entry of entries) {
180
- const fullPath = path.join(current, entry.name);
181
- if (entry.isDirectory()) {
182
- stack.push(fullPath);
183
- continue;
184
- }
185
- if (entry.isFile() && matcher(fullPath)) {
186
- found.push(fullPath);
187
- }
188
- }
189
- }
190
- return found;
191
- }
192
-
193
- function collectRoutesFromFilesystem(sourcePath) {
194
- const api = [];
195
- const pages = [];
196
- const appDirCandidates = [path.join(sourcePath, 'app'), path.join(sourcePath, 'src', 'app')];
197
- const pagesDirCandidates = [path.join(sourcePath, 'pages'), path.join(sourcePath, 'src', 'pages')];
198
-
199
- for (const appDir of appDirCandidates) {
200
- const appApiFiles = collectFilesRecursively(
201
- path.join(appDir, 'api'),
202
- (file) => /[\\/]route\.(js|jsx|ts|tsx|mjs|cjs)$/.test(file)
203
- );
204
- for (const absFile of appApiFiles) {
205
- const rel = path.relative(path.join(appDir, 'api'), absFile).replace(/\\/g, '/');
206
- api.push(parseApiRouteFromAppFile(`app/api/${rel}`));
207
- }
208
-
209
- const appPageFiles = collectFilesRecursively(appDir, (file) => /[\\/]page\.(js|jsx|ts|tsx|mdx)$/.test(file));
210
- for (const absFile of appPageFiles) {
211
- const rel = path.relative(appDir, absFile).replace(/\\/g, '/');
212
- const route = parsePageRouteFromAppFile(`app/${rel}`);
213
- if (route) {
214
- pages.push(route);
215
- }
216
- }
217
- }
218
-
219
- for (const pagesDir of pagesDirCandidates) {
220
- const pagesApiFiles = collectFilesRecursively(path.join(pagesDir, 'api'), (file) => /\.(js|jsx|ts|tsx|mjs|cjs)$/.test(file));
221
- for (const absFile of pagesApiFiles) {
222
- const rel = path.relative(path.join(pagesDir, 'api'), absFile).replace(/\\/g, '/');
223
- api.push(parseApiRouteFromPagesFile(`pages/api/${rel}`));
224
- }
225
-
226
- const pagesFiles = collectFilesRecursively(pagesDir, (file) => /\.(js|jsx|ts|tsx|mdx)$/.test(file));
227
- for (const absFile of pagesFiles) {
228
- const rel = path.relative(pagesDir, absFile).replace(/\\/g, '/');
229
- const route = parsePageRouteFromPagesFile(`pages/${rel}`);
230
- if (route) {
231
- pages.push(route);
232
- }
233
- }
234
- }
235
-
236
- return {
237
- api: normalizeList(api),
238
- pages: normalizeList(pages)
239
- };
240
- }
241
-
242
- function readJsonIfExists(filePath) {
243
- if (!fs.existsSync(filePath)) {
244
- return null;
245
- }
246
- try {
247
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
248
- } catch (error) {
249
- return null;
250
- }
251
- }
252
-
253
- function collectRoutesFromBuildArtifacts(sourcePath) {
254
- const api = [];
255
- const pages = [];
256
-
257
- const routesManifest = readJsonIfExists(path.join(sourcePath, '.next', 'routes-manifest.json'));
258
- if (routesManifest) {
259
- const combined = []
260
- .concat(routesManifest.staticRoutes || [])
261
- .concat(routesManifest.dynamicRoutes || [])
262
- .concat(routesManifest.dataRoutes || []);
263
- for (const routeInfo of combined) {
264
- const route = routeInfo.page || routeInfo.pathname || routeInfo.route;
265
- if (!route || typeof route !== 'string') {
266
- continue;
267
- }
268
- if (route.startsWith('/api')) {
269
- api.push(route);
270
- } else {
271
- pages.push(route);
272
- }
273
- }
274
- }
275
-
276
- const appRoutesManifest = readJsonIfExists(path.join(sourcePath, '.next', 'server', 'app-path-routes-manifest.json'))
277
- || readJsonIfExists(path.join(sourcePath, '.next', 'server', 'app-paths-manifest.json'))
278
- || readJsonIfExists(path.join(sourcePath, '.next', 'app-path-routes-manifest.json'));
279
- if (appRoutesManifest && typeof appRoutesManifest === 'object') {
280
- Object.entries(appRoutesManifest).forEach(([key, routeValue]) => {
281
- const route = typeof routeValue === 'string' ? routeValue : key;
282
- if (typeof route !== 'string') {
283
- return;
284
- }
285
- if (route.startsWith('/_')) {
286
- return;
287
- }
288
- if (route.startsWith('/api')) {
289
- api.push(route);
290
- } else {
291
- pages.push(route);
292
- }
293
- });
294
- }
295
-
296
- const pagesManifest = readJsonIfExists(path.join(sourcePath, '.next', 'server', 'pages-manifest.json'));
297
- if (pagesManifest && typeof pagesManifest === 'object') {
298
- Object.keys(pagesManifest).forEach((route) => {
299
- if (route === '/_app' || route === '/_document' || route === '/_error' || route === '/404' || route === '/500') {
300
- return;
301
- }
302
- if (route.startsWith('/api')) {
303
- api.push(route);
304
- } else {
305
- pages.push(route);
306
- }
307
- });
308
- }
309
-
310
- return {
311
- api: normalizeList(api),
312
- pages: normalizeList(pages)
313
- };
314
- }
315
-
316
- function runBuildInSourcePath(sourcePath, packageManager) {
317
- const manager = packageManager || 'npm';
318
- const hasNodeModules = fs.existsSync(path.join(sourcePath, 'node_modules'));
319
- const hasPackageLock = fs.existsSync(path.join(sourcePath, 'package-lock.json'));
320
- const hasYarnLock = fs.existsSync(path.join(sourcePath, 'yarn.lock'));
321
- const hasBunLock = fs.existsSync(path.join(sourcePath, 'bun.lockb'));
322
- const installCommand = manager === 'yarn'
323
- ? (hasYarnLock ? 'yarn install --frozen-lockfile' : 'yarn install')
324
- : manager === 'bun'
325
- ? 'bun install'
326
- : (hasPackageLock ? 'npm ci' : 'npm install');
327
- const buildCommand = manager === 'yarn' ? 'yarn build' : manager === 'bun' ? 'bun run build' : 'npm run build';
328
-
329
- try {
330
- if (!hasNodeModules) {
331
- execSync(installCommand, { cwd: sourcePath, stdio: 'pipe', timeout: 600000 });
332
- } else if (!hasPackageLock && !hasYarnLock && !hasBunLock) {
333
- execSync(installCommand, { cwd: sourcePath, stdio: 'pipe', timeout: 600000 });
334
- }
335
-
336
- execSync(buildCommand, { cwd: sourcePath, stdio: 'pipe', timeout: 900000 });
337
- return true;
338
- } catch (error) {
339
- return false;
340
- }
341
- }
342
-
343
- function buildGitLikeDiff(asIsList, toBeList, title) {
344
- const asIs = normalizeList(asIsList);
345
- const toBe = normalizeList(toBeList);
346
- const asIsSet = new Set(asIs);
347
- const toBeSet = new Set(toBe);
348
- const removed = asIs.filter((item) => !toBeSet.has(item));
349
- const added = toBe.filter((item) => !asIsSet.has(item));
350
- const unchanged = asIs.filter((item) => toBeSet.has(item));
351
-
352
- const lines = [
353
- `--- AS_IS/${title}`,
354
- `+++ TO_BE/${title}`,
355
- `@@ ${title} @@`
356
- ];
357
- removed.forEach((item) => lines.push(`-${item}`));
358
- added.forEach((item) => lines.push(`+${item}`));
359
- if (removed.length === 0 && added.length === 0) {
360
- lines.push(' (no changes)');
361
- }
362
-
363
- return {
364
- removed,
365
- added,
366
- unchanged,
367
- text: lines.join('\n')
368
- };
369
- }
370
-
371
- function printSection(title, asIsList, toBeList) {
372
- const asIs = normalizeList(asIsList);
373
- const toBe = normalizeList(toBeList);
374
- const diff = buildGitLikeDiff(asIs, toBe, title);
375
-
376
- console.log(`\nСравнение путей ${title} (КАК ЕСТЬ -> КАК ДОЛЖНО БЫТЬ):`);
377
- console.log(`--- AS_IS/${title}`);
378
- console.log(`+++ TO_BE/${title}`);
379
- console.log(`@@ ${title} @@`);
380
- if (diff.removed.length === 0 && diff.added.length === 0) {
381
- console.log(' (без изменений)');
382
- } else {
383
- diff.removed.forEach((item) => console.log(`${colors.yellow}-${item}${colors.reset}`));
384
- diff.added.forEach((item) => console.log(`${colors.red}+${item}${colors.reset}`));
385
- }
386
- console.log(`${title} сводка: добавлено=${diff.added.length}, удалено=${diff.removed.length}, без изменений=${diff.unchanged.length}`);
387
-
388
- return diff;
389
- }
390
-
391
- function chartVerify(sourcePath) {
392
- return runCommand({
393
- name: 'chart verify',
394
- checks: [
395
- { name: 'git-repo', run: requireGitRepo },
396
- { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
397
- { name: 'single-values-yaml', run: requireSingleValuesYaml },
398
- { name: 'values-ingress-sections', run: (ctx) => requireIngressPathSections(ctx.valuesYamlPath) },
399
- { name: 'source-path-directory', run: () => requireSourcePathDirectory(sourcePath) },
400
- { name: 'next-project', run: (ctx) => requireNextProject(ctx.sourcePath) },
401
- { name: 'build-command-support', run: (ctx) => requireBuildCommandSupport(ctx.sourcePath) }
402
- ],
403
- steps: [
404
- {
405
- name: 'collect-routes',
406
- run: (ctx) => {
407
- const fromBuild = collectRoutesFromBuildArtifacts(ctx.sourcePath);
408
- let api = normalizeList(fromBuild.api);
409
- let pages = normalizeList(fromBuild.pages);
410
-
411
- if (api.length === 0 || pages.length === 0) {
412
- const didBuild = runBuildInSourcePath(ctx.sourcePath, ctx.sourcePackageManager);
413
- if (didBuild) {
414
- logSuccess('🛠️', 'Сборка успешно завершена в %s.', ctx.sourcePath);
415
- const rebuilt = collectRoutesFromBuildArtifacts(ctx.sourcePath);
416
- api = normalizeList(api.concat(rebuilt.api));
417
- pages = normalizeList(pages.concat(rebuilt.pages));
418
- } else {
419
- logError('⚠️', 'Сборка в %s не удалась. Переходим к маршрутам из файловой системы.', ctx.sourcePath);
420
- }
421
- }
422
-
423
- const fromFs = collectRoutesFromFilesystem(ctx.sourcePath);
424
- api = normalizeList(api.concat(fromFs.api));
425
- pages = normalizeList(pages.concat(fromFs.pages));
426
-
427
- ctx.toBeIngressPaths = {
428
- api: normalizeToBeList(api),
429
- pages: normalizeToBeList(pages),
430
- assets: normalizeToBeList(FIXED_ASSET_PATHS)
431
- };
432
- return true;
433
- }
434
- },
435
- {
436
- name: 'compare-and-report',
437
- run: (ctx) => {
438
- const asIs = ctx.valuesIngressPaths || { api: [], pages: [], assets: [] };
439
- const toBe = ctx.toBeIngressPaths || { api: [], pages: [], assets: [] };
440
-
441
- const apiDiff = printSection('api', asIs.api, toBe.api);
442
- const pagesDiff = printSection('pages', asIs.pages, toBe.pages);
443
- const assetsDiff = printSection('assets', asIs.assets, toBe.assets);
444
-
445
- const hasChanges = (
446
- apiDiff.added.length + apiDiff.removed.length +
447
- pagesDiff.added.length + pagesDiff.removed.length +
448
- assetsDiff.added.length + assetsDiff.removed.length
449
- ) > 0;
450
-
451
- if (hasChanges) {
452
- logError('❌', '[chart verify] Обнаружены различия.');
453
- return false;
454
- }
455
-
456
- logSuccess('✅', '[chart verify] КАК ЕСТЬ совпадает с КАК ДОЛЖНО БЫТЬ.');
457
- return true;
458
- }
459
- }
460
- ]
461
- });
462
- }
463
-
464
- function chartCreateTag(version, options = {}) {
465
- const force = Boolean(options.force);
466
- const wait = Boolean(options.wait);
467
-
468
- return runCommand({
469
- name: 'chart create',
470
- checks: [
471
- { name: 'git-repo', run: requireGitRepo },
472
- { name: 'clean-working-tree', run: requireCleanWorkingTree },
473
- { name: 'on-main-branch', run: requireOnMainBranch },
474
- { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
475
- {
476
- name: 'valid-semver',
477
- run: () => {
478
- if (!isSemver(version)) {
479
- return { ok: false, reason: `Версия "${version}" не соответствует semver.` };
480
- }
481
- return { ok: true, data: { version } };
482
- }
483
- },
484
- { name: 'single-chart', run: requireSingleChart },
485
- {
486
- name: 'chart-changelog-version',
487
- run: (ctx) => requireChartChangelogVersion(path.dirname(ctx.chartFilePath), ctx.version)
488
- },
489
- {
490
- name: 'version-not-downgrade',
491
- run: (ctx) => {
492
- const latestVersion = getLatestRemoteChartVersion(ctx.chartName);
493
- if (!latestVersion) {
494
- return { ok: true };
495
- }
496
- if (compareSemver(ctx.version, latestVersion) > 0) {
497
- return { ok: true, data: { latestChartVersion: latestVersion } };
498
- }
499
- if (force) {
500
- logError('⚠️', 'Версия %s не больше последней опубликованной %s. Продолжаю из-за --force.', ctx.version, latestVersion);
501
- return { ok: true, data: { latestChartVersion: latestVersion } };
502
- }
503
- return {
504
- ok: false,
505
- reason: `Версия "${ctx.version}" не больше последней опубликованной "${latestVersion}". Для сознательного отката используйте --force.`
506
- };
507
- }
508
- },
509
- {
510
- name: 'tag-missing',
511
- run: (ctx) => requireTagMissing(`chart-${ctx.chartName}-${ctx.version}`)
512
- }
513
- ],
514
- steps: [
515
- {
516
- name: 'create-tag',
517
- run: (ctx) => {
518
- const tagName = `chart-${ctx.chartName}-${ctx.version}`;
519
- if (!execCommand(`git tag "${tagName}"`)) {
520
- logError('❌', 'Не удалось создать тег %s.', tagName);
521
- return false;
522
- }
523
- logSuccess('🔖', 'Создан тег %s.', tagName);
524
- return true;
525
- }
526
- },
527
- {
528
- name: 'push-tag',
529
- run: (ctx) => {
530
- const tagName = `chart-${ctx.chartName}-${ctx.version}`;
531
- if (!execCommand(`git push origin "${tagName}"`)) {
532
- logError('❌', 'Не удалось отправить тег %s в origin.', tagName);
533
- return false;
534
- }
535
- logSuccess('🚀', 'Тег %s отправлен в origin.', tagName);
536
- return true;
537
- }
538
- },
539
- ...(wait
540
- ? [{
541
- name: 'wait-for-registry',
542
- run: (ctx) => waitForChartInRegistry(ctx.chartName, ctx.version)
543
- }]
544
- : [])
545
- ]
546
- });
547
- }
548
-
549
- function compareSemver(a, b) {
550
- const parse = (value) => {
551
- const [rawMain, rawMeta] = String(value || '').split('+', 2);
552
- const [rawVersion, rawPre] = rawMain.split('-', 2);
553
- const versionParts = rawVersion.split('.').map((part) => Number(part));
554
- const preParts = rawPre ? rawPre.split('.') : [];
555
- return { versionParts, preParts, hasPre: Boolean(rawPre), meta: rawMeta || '' };
556
- };
557
- const pa = parse(a);
558
- const pb = parse(b);
559
-
560
- for (let i = 0; i < 3; i += 1) {
561
- const diff = (pa.versionParts[i] || 0) - (pb.versionParts[i] || 0);
562
- if (diff !== 0) return diff;
563
- }
564
-
565
- if (pa.hasPre && !pb.hasPre) return -1;
566
- if (!pa.hasPre && pb.hasPre) return 1;
567
- if (!pa.hasPre && !pb.hasPre) return 0;
568
-
569
- const len = Math.max(pa.preParts.length, pb.preParts.length);
570
- for (let i = 0; i < len; i += 1) {
571
- const aa = pa.preParts[i];
572
- const bb = pb.preParts[i];
573
- if (aa === undefined) return -1;
574
- if (bb === undefined) return 1;
575
- const aNum = /^\d+$/.test(aa);
576
- const bNum = /^\d+$/.test(bb);
577
- if (aNum && bNum) {
578
- const diff = Number(aa) - Number(bb);
579
- if (diff !== 0) return diff;
580
- continue;
581
- }
582
- if (aNum && !bNum) return -1;
583
- if (!aNum && bNum) return 1;
584
- if (aa < bb) return -1;
585
- if (aa > bb) return 1;
586
- }
587
-
588
- return 0;
589
- }
590
-
591
- function getLatestRemoteChartVersion(chartName) {
592
- const safeChartName = String(chartName || '').trim();
593
- if (!safeChartName) return null;
594
-
595
- const prefix = `chart-${safeChartName}-`;
596
- let output = '';
597
- try {
598
- output = execSync(`git ls-remote --tags origin "refs/tags/${prefix}*"`, { stdio: 'pipe', encoding: 'utf8' });
599
- } catch (error) {
600
- return null;
601
- }
602
-
603
- const versions = String(output || '')
604
- .split('\n')
605
- .map((line) => line.trim())
606
- .filter(Boolean)
607
- .map((line) => line.split(/\s+/)[1] || '')
608
- .map((ref) => ref.replace('refs/tags/', ''))
609
- .filter((tag) => tag.startsWith(prefix))
610
- .map((tag) => tag.slice(prefix.length))
611
- .filter((version) => isSemver(version));
612
-
613
- if (versions.length === 0) {
614
- return null;
615
- }
616
-
617
- versions.sort(compareSemver);
618
- return versions[versions.length - 1];
619
- }
620
-
621
- const HELM_REGISTRY_CHANNEL = 'stable';
622
- const REGISTRY_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
623
- const REGISTRY_WAIT_INTERVAL_MS = 15 * 1000;
624
-
625
- function sleep(ms) {
626
- return new Promise((resolve) => setTimeout(resolve, ms));
627
- }
628
-
629
- function getGitlabProject() {
630
- const baseUrl = getRemoteUrl();
631
- if (!baseUrl) {
632
- return null;
633
- }
634
- try {
635
- return parseGitlabUrl(baseUrl);
636
- } catch (error) {
637
- return null;
638
- }
639
- }
640
-
641
- function helmIndexHasChartVersion(indexYamlContent, chartName, version) {
642
- const escapedChartName = String(chartName || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
643
- const chartHeadingPattern = new RegExp(`^${escapedChartName}:\\s*(#.*)?$`);
644
- const lines = String(indexYamlContent || '').split('\n');
645
- let inEntries = false;
646
- let inChart = false;
647
- let entriesIndent = 0;
648
- let chartIndent = 0;
649
-
650
- for (const rawLine of lines) {
651
- const line = rawLine.replace(/\r$/, '');
652
- const trimmed = line.trim();
653
- if (!trimmed || trimmed.startsWith('#')) {
654
- continue;
655
- }
656
-
657
- const indent = line.match(/^\s*/)[0].length;
658
-
659
- if (/^entries:\s*(#.*)?$/.test(trimmed) && indent === 0) {
660
- inEntries = true;
661
- inChart = false;
662
- entriesIndent = indent;
663
- continue;
664
- }
665
-
666
- if (inEntries && indent <= entriesIndent) {
667
- inEntries = false;
668
- inChart = false;
669
- }
670
-
671
- if (!inEntries) {
672
- continue;
673
- }
674
-
675
- if (chartHeadingPattern.test(trimmed)) {
676
- inChart = true;
677
- chartIndent = indent;
678
- continue;
679
- }
680
-
681
- if (inChart && indent <= chartIndent) {
682
- inChart = false;
683
- }
684
-
685
- if (!inChart) {
686
- continue;
687
- }
688
-
689
- const versionMatch = trimmed.match(/^-?\s*version:\s*["']?([^"'\s#]+)["']?\s*(#.*)?$/);
690
- if (versionMatch && versionMatch[1] === String(version)) {
691
- return true;
692
- }
693
- }
694
-
695
- return false;
696
- }
697
-
698
- async function fetchChartVersionFromRegistry(chartName, version) {
699
- const project = getGitlabProject();
700
- if (!project) {
701
- return { ok: false, reason: 'Не удалось определить GitLab-проект из remote "origin".' };
702
- }
703
-
704
- const accessToken = process.env.GITLAB_PRIVATE_TOKEN;
705
- if (!accessToken) {
706
- return {
707
- ok: false,
708
- reason: 'Не задана переменная окружения GITLAB_PRIVATE_TOKEN. Она нужна для проверки публикации чарта в Helm-registry.'
709
- };
710
- }
711
-
712
- const indexUrl = `${project.origin}/api/v4/projects/${project.encodedPath}/packages/helm/${HELM_REGISTRY_CHANNEL}/index.yaml`;
713
- let response;
714
- try {
715
- response = await fetch(indexUrl, { headers: { 'PRIVATE-TOKEN': accessToken } });
716
- } catch (error) {
717
- return { ok: false, reason: `Не удалось запросить Helm-registry (${indexUrl}): ${error.message}.` };
718
- }
719
-
720
- if (!response.ok) {
721
- return { ok: false, reason: `Helm-registry вернул HTTP ${response.status} (${indexUrl}).` };
722
- }
723
-
724
- const indexYaml = await response.text();
725
- return {
726
- ok: true,
727
- found: helmIndexHasChartVersion(indexYaml, chartName, version),
728
- indexUrl
729
- };
730
- }
731
-
732
- async function waitForChartInRegistry(chartName, version, options = {}) {
733
- const timeoutMs = options.timeoutMs === undefined ? REGISTRY_WAIT_TIMEOUT_MS : options.timeoutMs;
734
- const intervalMs = options.intervalMs === undefined ? REGISTRY_WAIT_INTERVAL_MS : options.intervalMs;
735
- const sleepFn = options.sleep || sleep;
736
- const deadline = Date.now() + timeoutMs;
737
-
738
- while (true) {
739
- const result = await fetchChartVersionFromRegistry(chartName, version);
740
- if (!result.ok) {
741
- logError('❌', '%s', result.reason);
742
- return false;
743
- }
744
- if (result.found) {
745
- logSuccess('📦', 'Версия %s чарта %s опубликована в Helm-registry.', version, chartName);
746
- return true;
747
- }
748
- if (Date.now() >= deadline) {
749
- logError('❌', 'Версия %s чарта %s не появилась в Helm-registry за отведенное время.', version, chartName);
750
- return false;
751
- }
752
- logSuccess('⏳', 'Версия %s чарта %s еще не опубликована, продолжаю ожидание...', version, chartName);
753
- await sleepFn(intervalMs);
754
- }
755
- }
756
-
757
- function getInstanceName(filePath) {
758
- const normalized = String(filePath || '').replace(/\\/g, '/');
759
- const match = normalized.match(/(?:^|\/)instances\/([^/]+)\/helmrelease\.yaml$/i);
760
- return match ? match[1] : null;
761
- }
762
-
763
- function parseInstancesOption(raw) {
764
- return normalizeList(String(raw || '').split(','));
765
- }
766
-
767
- function updateHelmReleaseVersion(filePath, nextVersion) {
768
- const content = fs.readFileSync(filePath, 'utf8');
769
- const lines = content.split('\n');
770
- const keyStack = [];
771
- const oldVersions = [];
772
- let changed = false;
773
-
774
- const updatedLines = lines.map((line) => {
775
- const keyMatch = line.match(/^(\s*)([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
776
- if (!keyMatch || line.trim().startsWith('#')) {
777
- return line;
778
- }
779
-
780
- const indent = keyMatch[1].length;
781
- const key = keyMatch[2];
782
-
783
- while (keyStack.length > 0 && keyStack[keyStack.length - 1].indent >= indent) {
784
- keyStack.pop();
785
- }
786
-
787
- const parentPath = keyStack.map((item) => item.key).join('.');
788
- keyStack.push({ key, indent });
789
- if (!(parentPath === 'spec.chart.spec' && key === 'version')) {
790
- return line;
791
- }
792
-
793
- const valuePart = keyMatch[3];
794
- const valueMatch = valuePart.match(/^(\s*)(['"]?)([^'"#\s]+)\2(\s*(?:#.*)?)$/);
795
- if (!valueMatch) {
796
- return line;
797
- }
798
-
799
- const oldVersion = valueMatch[3];
800
- oldVersions.push(oldVersion);
801
- if (oldVersion === nextVersion) {
802
- return line;
803
- }
804
-
805
- changed = true;
806
- const spacing = valueMatch[1] || ' ';
807
- return `${keyMatch[1]}version:${spacing}${valueMatch[2]}${nextVersion}${valueMatch[2]}${valueMatch[4]}`;
808
- });
809
-
810
- if (changed) {
811
- fs.writeFileSync(filePath, updatedLines.join('\n'));
812
- }
813
-
814
- const uniqueOld = Array.from(new Set(oldVersions));
815
- return {
816
- filePath,
817
- oldVersion: uniqueOld.length > 0 ? uniqueOld.join(', ') : 'unknown',
818
- newVersion: nextVersion,
819
- changed
820
- };
821
- }
822
-
823
- function printDeployChanges(changes) {
824
- changes.forEach((item) => {
825
- const icon = item.changed ? '●' : '○';
826
- const color = item.changed ? colors.green : '\x1b[90m';
827
- console.log(
828
- `${color}${icon} ${item.filePath}: ${item.oldVersion} -> ${item.newVersion}${colors.reset}`
829
- );
830
- });
831
- }
832
-
833
- function waitForEnter(promptText) {
834
- return new Promise((resolve) => {
835
- const rl = readline.createInterface({
836
- input: process.stdin,
837
- output: process.stdout
838
- });
839
- rl.question(promptText, () => {
840
- rl.close();
841
- resolve(true);
842
- });
843
- });
844
- }
845
-
846
- function splitGitNameOnly(output) {
847
- if (output === null || output === undefined) {
848
- return null;
849
- }
850
- return String(output)
851
- .split('\n')
852
- .map((line) => line.trim().replace(/\\/g, '/'))
853
- .filter(Boolean);
854
- }
855
-
856
- function chartDeploy(options = {}) {
857
- const instancesOption = options.instances;
858
-
859
- return runCommand({
860
- name: 'chart deploy',
861
- checks: [
862
- { name: 'git-repo', run: requireGitRepo },
863
- { name: 'clean-working-tree', run: requireCleanWorkingTree },
864
- { name: 'on-main-branch', run: requireOnMainBranch },
865
- { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
866
- { name: 'remote-origin', run: requireRemoteOrigin },
867
- { name: 'remote-reachable', run: requireRemoteReachable },
868
- { name: 'single-chart', run: requireSingleChart },
869
- { name: 'helmrelease-files', run: requireHelmReleaseFiles },
870
- {
871
- name: 'filter-instances',
872
- run: (ctx) => {
873
- const requested = parseInstancesOption(instancesOption);
874
- if (requested.length === 0) {
875
- return { ok: true };
876
- }
877
-
878
- const available = new Map();
879
- for (const filePath of ctx.helmReleaseFiles || []) {
880
- const instanceName = getInstanceName(filePath);
881
- if (instanceName) {
882
- available.set(instanceName, filePath);
883
- }
884
- }
885
-
886
- if (available.size === 0) {
887
- return {
888
- ok: false,
889
- reason: 'В репозитории нет файлов instances/<name>/helmrelease.yaml, флаг --instances неприменим.'
890
- };
891
- }
892
-
893
- const unknown = requested.filter((name) => !available.has(name));
894
- if (unknown.length > 0) {
895
- return {
896
- ok: false,
897
- reason: `Неизвестные инстансы: ${unknown.join(', ')}. Доступные: ${Array.from(available.keys()).join(', ')}.`
898
- };
899
- }
900
-
901
- return { ok: true, data: { helmReleaseFiles: requested.map((name) => available.get(name)) } };
902
- }
903
- }
904
- ],
905
- steps: [
906
- {
907
- name: 'resolve-latest-chart-version',
908
- run: (ctx) => {
909
- const latestVersion = getLatestRemoteChartVersion(ctx.chartName);
910
- if (!latestVersion) {
911
- logError('❌', 'Не удалось найти удаленный тег чарта для %s.', ctx.chartName);
912
- return false;
913
- }
914
- ctx.latestChartVersion = latestVersion;
915
- logSuccess('🏷️', 'Последняя удаленная версия чарта для %s: %s.', ctx.chartName, latestVersion);
916
- return true;
917
- }
918
- },
919
- {
920
- name: 'verify-chart-in-registry',
921
- run: async (ctx) => {
922
- const result = await fetchChartVersionFromRegistry(ctx.chartName, ctx.latestChartVersion);
923
- if (!result.ok) {
924
- logError('❌', '%s', result.reason);
925
- return false;
926
- }
927
- if (!result.found) {
928
- logError(
929
- '❌',
930
- 'Версия %s чарта %s отсутствует в Helm-registry. Дождитесь пайплайна "upload charts" и повторите.',
931
- ctx.latestChartVersion,
932
- ctx.chartName
933
- );
934
- return false;
935
- }
936
- logSuccess('📦', 'Версия %s чарта %s найдена в Helm-registry.', ctx.latestChartVersion, ctx.chartName);
937
- return true;
938
- }
939
- },
940
- {
941
- name: 'update-helmrelease-files',
942
- run: (ctx) => {
943
- const changes = (ctx.helmReleaseFiles || []).map((filePath) => updateHelmReleaseVersion(filePath, ctx.latestChartVersion));
944
- ctx.deployChanges = changes;
945
- ctx.updatedFiles = changes.filter((item) => item.changed).map((item) => item.filePath);
946
- ctx.updatedInstances = ctx.updatedFiles.map((filePath) => getInstanceName(filePath) || filePath);
947
-
948
- printDeployChanges(changes);
949
- if (ctx.updatedFiles.length === 0) {
950
- ctx.noUpdates = true;
951
- logSuccess('✅', 'Все файлы helmrelease.yaml уже используют последнюю версию чарта.');
952
- return true;
953
- }
954
-
955
- logSuccess('📋', 'Будут обновлены инстансы: %s (версия чарта %s).', ctx.updatedInstances.join(', '), ctx.latestChartVersion);
956
- return true;
957
- }
958
- },
959
- {
960
- name: 'confirm-publish',
961
- run: async (ctx) => {
962
- if (ctx.noUpdates) {
963
- return true;
964
- }
965
- await waitForEnter('Готов ли ты к публикации? Нажми Enter для продолжения (Ctrl+C для отмены): ');
966
- return true;
967
- }
968
- },
969
- {
970
- name: 'commit-and-push',
971
- run: (ctx) => {
972
- if (ctx.noUpdates) {
973
- return true;
974
- }
975
- const files = ctx.updatedFiles || [];
976
- const normalizedFiles = files.map((filePath) => String(filePath || '').replace(/\\/g, '/'));
977
- if (files.length === 0) {
978
- logSuccess('✅', 'Файлы не были изменены.');
979
- return true;
980
- }
981
-
982
- const stagedBeforeAdd = splitGitNameOnly(execSilent('git diff --cached --name-only'));
983
- const unstagedBeforeAdd = splitGitNameOnly(execSilent('git diff --name-only'));
984
- if (stagedBeforeAdd === null || unstagedBeforeAdd === null) {
985
- logError('❌', 'Не удалось проверить измененные файлы перед коммитом.');
986
- return false;
987
- }
988
- if (stagedBeforeAdd.length > 0) {
989
- logError('❌', 'Обнаружены неожиданные изменения в индексе: %s', stagedBeforeAdd.join(', '));
990
- return false;
991
- }
992
-
993
- const expectedSet = new Set(normalizedFiles);
994
- const unexpectedChanges = unstagedBeforeAdd.filter((filePath) => !expectedSet.has(filePath));
995
- if (unexpectedChanges.length > 0) {
996
- logError('❌', 'Обнаружены неожиданные изменения файлов: %s', unexpectedChanges.join(', '));
997
- return false;
998
- }
999
-
1000
- const missingExpectedChanges = normalizedFiles.filter((filePath) => !unstagedBeforeAdd.includes(filePath));
1001
- if (missingExpectedChanges.length > 0) {
1002
- logError('❌', 'В diff отсутствуют ожидаемые обновленные файлы: %s', missingExpectedChanges.join(', '));
1003
- return false;
1004
- }
1005
-
1006
- const quoted = normalizedFiles.map((filePath) => `"${filePath}"`).join(' ');
1007
- if (!execCommand(`git add ${quoted}`)) {
1008
- logError('❌', 'Не удалось добавить обновленные файлы helmrelease.');
1009
- return false;
1010
- }
1011
-
1012
- const stagedAfterAdd = splitGitNameOnly(execSilent('git diff --cached --name-only'));
1013
- if (stagedAfterAdd === null) {
1014
- logError('❌', 'Не удалось проверить staged-файлы после git add.');
1015
- return false;
1016
- }
1017
- const unexpectedStaged = stagedAfterAdd.filter((filePath) => !expectedSet.has(filePath));
1018
- const missingStaged = normalizedFiles.filter((filePath) => !stagedAfterAdd.includes(filePath));
1019
- if (unexpectedStaged.length > 0 || missingStaged.length > 0) {
1020
- if (unexpectedStaged.length > 0) {
1021
- logError('❌', 'Обнаружены неожиданные staged-файлы: %s', unexpectedStaged.join(', '));
1022
- }
1023
- if (missingStaged.length > 0) {
1024
- logError('❌', 'Ожидаемые файлы не добавлены в индекс: %s', missingStaged.join(', '));
1025
- }
1026
- return false;
1027
- }
1028
-
1029
- const instanceNames = (ctx.updatedInstances && ctx.updatedInstances.length > 0)
1030
- ? ctx.updatedInstances
1031
- : normalizedFiles.map((filePath) => getInstanceName(filePath) || filePath);
1032
- const versionPart = ctx.latestChartVersion ? ` ${ctx.latestChartVersion}` : '';
1033
- const commitMessage = `🚀 Деплой сервиса${versionPart} (${instanceNames.join(', ')}).`;
1034
- if (!execCommand(`git commit -m "${commitMessage}"`)) {
1035
- logError('❌', 'Не удалось создать коммит деплоя.');
1036
- return false;
1037
- }
1038
- if (!execCommand('git push')) {
1039
- logError('❌', 'Не удалось отправить коммит деплоя.');
1040
- return false;
1041
- }
1042
-
1043
- logSuccess('🚀', 'Коммит деплоя отправлен.');
1044
- return true;
1045
- }
1046
- }
1047
- ]
1048
- });
1049
- }
1050
-
1051
- module.exports = {
1052
- chartCreateTag,
1053
- chartDeploy,
1054
- chartVerify,
1055
- normalizeList,
1056
- normalizeToBeList,
1057
- collectRoutesFromFilesystem,
1058
- collectRoutesFromBuildArtifacts,
1059
- buildGitLikeDiff,
1060
- getChartName,
1061
- getChartFiles,
1062
- isSemver,
1063
- compareSemver,
1064
- getLatestRemoteChartVersion,
1065
- helmIndexHasChartVersion,
1066
- fetchChartVersionFromRegistry,
1067
- waitForChartInRegistry,
1068
- getInstanceName,
1069
- parseInstancesOption,
1070
- updateHelmReleaseVersion
1071
- };