spectrum-cli 3.1.1 → 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 (60) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +81 -29
  3. package/index.js +6 -158
  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.js → changelog/fragments.js} +3 -136
  8. package/src/{hotfix-changelog.js → changelog/hotfix.js} +13 -18
  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/cli/update-check.js +51 -0
  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/chart.js +0 -1071
  53. package/src/command-executor.js +0 -70
  54. package/src/hotfix.js +0 -223
  55. package/src/preflight.js +0 -796
  56. package/src/release.js +0 -207
  57. package/src/token.js +0 -399
  58. /package/src/{changelog-config.js → changelog/config.js} +0 -0
  59. /package/src/{utils.js → common/utils.js} +0 -0
  60. /package/src/{version.js → common/version.js} +0 -0
@@ -1,22 +1,8 @@
1
- #!/usr/bin/env node
2
1
  const fs = require('fs');
3
2
  const path = require('path');
4
3
  const readline = require('readline');
5
- const { logSuccess, logError, execSilent, getCurrentBranch, colors } = require('./utils');
6
- const { runCommand } = require('./command-executor');
7
- const {
8
- requireGitRepo,
9
- requireFileExists,
10
- requireChangelogFormatted,
11
- requireChangelogFragments
12
- } = require('./preflight');
13
- const {
14
- CHANGELOG_FILE,
15
- CHANGELOG_DIR,
16
- FRAGMENT_TYPES,
17
- SECTION_TO_TYPE
18
- } = require('./changelog-config');
19
-
4
+ const { logSuccess, logError, execSilent, getCurrentBranch, colors } = require('../common/utils');
5
+ const { CHANGELOG_DIR, FRAGMENT_TYPES, SECTION_TO_TYPE } = require('./config');
20
6
  function createReadlineInterface() {
21
7
  return readline.createInterface({
22
8
  input: process.stdin,
@@ -234,123 +220,4 @@ function appendPreparedChangelogEntry(context) {
234
220
  }
235
221
  }
236
222
 
237
- function changelogAppend(message) {
238
- return runCommand({
239
- name: 'changelog append',
240
- checks: [
241
- { name: 'git-repo', run: requireGitRepo },
242
- { name: 'changelog-exists', run: () => requireFileExists(CHANGELOG_FILE) },
243
- { name: 'prepare-fragment', run: () => prepareChangelogEntry(message) }
244
- ],
245
- steps: [
246
- { name: 'write-fragment', run: appendPreparedChangelogEntry }
247
- ]
248
- });
249
- }
250
-
251
- function changelogCheck() {
252
- return runCommand({
253
- name: 'changelog check',
254
- checks: [
255
- { name: 'git-repo', run: requireGitRepo },
256
- { name: 'changelog-exists', run: () => requireFileExists(CHANGELOG_FILE) },
257
- { name: 'changelog-prettier-check', run: requireChangelogFormatted },
258
- { name: 'changelog-fragments', run: requireChangelogFragments }
259
- ]
260
- });
261
- }
262
-
263
- function stripLegacyUnreleasedBlock(changelog) {
264
- const unreleasedMatch = /^## \[Unreleased\]\s*$/m.exec(changelog);
265
- if (!unreleasedMatch) return changelog;
266
-
267
- const afterUnreleased = unreleasedMatch.index + unreleasedMatch[0].length;
268
- const nextHeadingMatch = /^## /m.exec(changelog.slice(afterUnreleased));
269
- const end = nextHeadingMatch ? afterUnreleased + nextHeadingMatch.index : changelog.length;
270
- return `${changelog.slice(0, unreleasedMatch.index).trimEnd()}\n\n${changelog.slice(end).trimStart()}`;
271
- }
272
-
273
- function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10), marker = '🚀') {
274
- const fragmentsByType = new Map();
275
- for (const fragment of fragments) {
276
- const entries = fragmentsByType.get(fragment.type) || [];
277
- entries.push(...fragment.entries);
278
- fragmentsByType.set(fragment.type, entries);
279
- }
280
-
281
- const lines = [`## ${marker ? `${marker} ` : ''}[${version}]${date ? ` - ${date}` : ''}`];
282
- for (const [type, config] of Object.entries(FRAGMENT_TYPES)) {
283
- const entries = fragmentsByType.get(type);
284
- if (!entries || entries.length === 0) continue;
285
- lines.push('', config.section, '', ...entries);
286
- }
287
- return `${lines.join('\n')}\n`;
288
- }
289
-
290
- function insertReleaseBlock(changelog, releaseBlock, version) {
291
- const normalized = stripLegacyUnreleasedBlock(changelog).trimEnd();
292
- const escapedVersion = String(version).replace(/\./g, '\\.');
293
- const versionPattern = new RegExp(`^## 🚀 \\[${escapedVersion}\\](?:\\s|$)`, 'm');
294
- if (versionPattern.test(normalized)) {
295
- throw new Error(`Версия ${version} уже присутствует в ${CHANGELOG_FILE}.`);
296
- }
297
-
298
- const firstReleaseMatch = /^## /m.exec(normalized);
299
- if (!firstReleaseMatch) {
300
- return `${normalized}\n\n${releaseBlock}`;
301
- }
302
-
303
- return `${normalized.slice(0, firstReleaseMatch.index).trimEnd()}\n\n${releaseBlock}\n${normalized.slice(firstReleaseMatch.index).trimStart()}\n`;
304
- }
305
-
306
- function changelogBuildRelease(context, date) {
307
- try {
308
- const version = context.newVersion;
309
- const fragments = context.changelogFragments;
310
- if (!version || !Array.isArray(fragments) || fragments.length === 0) return false;
311
-
312
- const changelog = fs.readFileSync(CHANGELOG_FILE, 'utf8');
313
- const releaseBlock = renderReleaseBlock(version, fragments, date);
314
- const updatedChangelog = insertReleaseBlock(changelog, releaseBlock, version);
315
- fs.writeFileSync(CHANGELOG_FILE, updatedChangelog);
316
- logSuccess('📋', '%s собран из %s changelog fragments.', `CHANGELOG ${version}`, fragments.length);
317
- return true;
318
- } catch (error) {
319
- logError('❌', 'Не удалось собрать релизный changelog: %s', error.message);
320
- return false;
321
- }
322
- }
323
-
324
- function changelogRemoveFragments(context) {
325
- try {
326
- const fragments = context.changelogFragments;
327
- if (!Array.isArray(fragments) || fragments.length === 0) return false;
328
- for (const fragment of fragments) {
329
- fs.unlinkSync(fragment.filePath);
330
- }
331
- logSuccess('🧹', 'Использованные changelog fragments удалены: %s.', fragments.length);
332
- return true;
333
- } catch (error) {
334
- logError('❌', 'Не удалось удалить changelog fragments: %s', error.message);
335
- return false;
336
- }
337
- }
338
-
339
- module.exports = {
340
- changelogAppend,
341
- changelogCheck,
342
- extractTaskFromBranch,
343
- getGitUser,
344
- formatMessage,
345
- detectSectionFromBranch,
346
- selectSection,
347
- sanitizeFragmentSlug,
348
- createFragmentPath,
349
- prepareChangelogEntry,
350
- appendPreparedChangelogEntry,
351
- stripLegacyUnreleasedBlock,
352
- renderReleaseBlock,
353
- insertReleaseBlock,
354
- changelogBuildRelease,
355
- changelogRemoveFragments
356
- };
223
+ module.exports = { extractTaskFromBranch, getGitUser, formatMessage, detectSectionFromBranch, selectSection, sanitizeFragmentSlug, createFragmentPath, prepareChangelogEntry, appendPreparedChangelogEntry };
@@ -1,21 +1,24 @@
1
- const { FRAGMENT_TYPES, SECTION_TO_TYPE } = require('./changelog-config');
2
- const { renderReleaseBlock } = require('./changelog');
1
+ const { FRAGMENT_TYPES, SECTION_TO_TYPE } = require('./config');
2
+ const { renderReleaseBlock } = require('./index');
3
3
 
4
4
  // Parse release boundaries without rewriting any published text.
5
- function parseChangelog(text) {
5
+ function parseChangelog(text, { validate = true } = {}) {
6
6
  const headings = [...text.matchAll(/^## .+$/gm)];
7
7
  const releases = headings.map((heading, index) => {
8
8
  const match = heading[0].match(/^##[ \t]+(?:(🚀|🩹)[ \t]*)?\[(\d+\.\d+\.\d+)\](?:[ \t]+-[ \t]+(\d{4}-\d{2}-\d{2}|\d{4}\.\d{2}\.\d{2}|\d{2}\.\d{2}\.\d{4}))?[ \t]*\r?$/);
9
9
  if (!match) {
10
+ // Production snapshots supply pending notes, not files to validate. Skip
11
+ // historical non-release sections (including their bodies).
12
+ if (!validate) return null;
10
13
  throw new Error(`Некорректный заголовок CHANGELOG.md: ${heading[0]}. Ожидается ## [X.Y.Z], дата необязательна.`);
11
14
  }
12
- // Historical duplicates are preserved; callers validate the pending version and immutable history.
15
+ // Historical duplicates are preserved; callers validate the pending version.
13
16
  return {
14
17
  version: match[2], date: match[3] || null, marker: match[1] || '',
15
18
  text: text.slice(heading.index, headings[index + 1]?.index ?? text.length),
16
19
  body: text.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? text.length),
17
20
  };
18
- });
21
+ }).filter(Boolean);
19
22
  return { intro: text.slice(0, headings[0]?.index ?? text.length), releases };
20
23
  }
21
24
 
@@ -47,26 +50,18 @@ function normalizeLineEndings(text) {
47
50
  return text.replace(/\r\n/g, '\n');
48
51
  }
49
52
 
50
- function history(document) {
51
- return document.releases.map((release) => normalizeLineEndings(release.text).trim()).join('\n\n');
52
- }
53
-
54
- function pendingDocument(text, stableText, target) {
55
- const document = parseChangelog(text);
56
- const stable = parseChangelog(stableText);
53
+ function pendingDocument(text, target, { validate = true } = {}) {
54
+ const document = parseChangelog(text, { validate });
57
55
  const pending = document.releases[0]?.version === target ? document.releases.shift() : null;
58
56
  if (document.releases.some((release) => release.version === target)) {
59
57
  throw new Error('Новый patch-раздел должен быть единственным и находиться в начале CHANGELOG.md.');
60
58
  }
61
- if (history(document) !== history(stable) || normalizeLineEndings(document.intro).trim() !== normalizeLineEndings(stable.intro).trim()) {
62
- throw new Error('История CHANGELOG.md отличается от stable: допустим только верхний раздел следующего patch.');
63
- }
64
59
  return { document, pending, entries: pending ? releaseEntries(pending, true) : [] };
65
60
  }
66
61
 
67
- function prepareChangelog({ text, stableText, productionText, target, fragments, date }) {
68
- const current = pendingDocument(text, stableText, target);
69
- const production = pendingDocument(productionText, stableText, target);
62
+ function prepareChangelog({ text, productionText, target, fragments, date }) {
63
+ const current = pendingDocument(text, target);
64
+ const production = pendingDocument(productionText, target, { validate: false });
70
65
  const entryKey = (entry) => `${entry.type}:${entry.entries[0]}`;
71
66
  const currentKeys = new Set(current.entries.map(entryKey));
72
67
  if (production.entries.some((entry) => !currentKeys.has(entryKey(entry)))) {
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ module.exports = {
3
+ ...require('./commands'),
4
+ ...require('./fragments'),
5
+ ...require('./format'),
6
+ ...require('./release-block')
7
+ };
@@ -1,10 +1,10 @@
1
1
  const fs = require('fs');
2
- const { execSilent, logError, logSuccess } = require('./utils');
3
- const { compareVersions } = require('./version');
4
- const { CHANGELOG_FILE, CHANGELOG_DIR } = require('./changelog-config');
5
- const { parseChangelog, releaseEntries, normalizeLineEndings } = require('./hotfix-changelog');
6
- const { renderReleaseBlock, stripLegacyUnreleasedBlock } = require('./changelog');
7
- const { requireChangelogFragments } = require('./preflight');
2
+ const { execSilent, logError, logSuccess } = require('../common/utils');
3
+ const { compareVersions } = require('../common/version');
4
+ const { CHANGELOG_FILE, CHANGELOG_DIR } = require('./config');
5
+ const { parseChangelog, releaseEntries, normalizeLineEndings } = require('./hotfix');
6
+ const { renderReleaseBlock, stripLegacyUnreleasedBlock } = require('./index');
7
+ const { requireChangelogFragments } = require('../preflight');
8
8
 
9
9
  function splitOpenRelease(text, stableVersion) {
10
10
  const document = parseChangelog(stripLegacyUnreleasedBlock(text));
@@ -0,0 +1,80 @@
1
+ const fs = require('fs');
2
+ const { logSuccess, logError } = require('../common/utils');
3
+ const { CHANGELOG_FILE, FRAGMENT_TYPES } = require('./config');
4
+ function stripLegacyUnreleasedBlock(changelog) {
5
+ const unreleasedMatch = /^## \[Unreleased\]\s*$/m.exec(changelog);
6
+ if (!unreleasedMatch) return changelog;
7
+
8
+ const afterUnreleased = unreleasedMatch.index + unreleasedMatch[0].length;
9
+ const nextHeadingMatch = /^## /m.exec(changelog.slice(afterUnreleased));
10
+ const end = nextHeadingMatch ? afterUnreleased + nextHeadingMatch.index : changelog.length;
11
+ return `${changelog.slice(0, unreleasedMatch.index).trimEnd()}\n\n${changelog.slice(end).trimStart()}`;
12
+ }
13
+
14
+ function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10), marker = '🚀') {
15
+ const fragmentsByType = new Map();
16
+ for (const fragment of fragments) {
17
+ const entries = fragmentsByType.get(fragment.type) || [];
18
+ entries.push(...fragment.entries);
19
+ fragmentsByType.set(fragment.type, entries);
20
+ }
21
+
22
+ const lines = [`## ${marker ? `${marker} ` : ''}[${version}]${date ? ` - ${date}` : ''}`];
23
+ for (const [type, config] of Object.entries(FRAGMENT_TYPES)) {
24
+ const entries = fragmentsByType.get(type);
25
+ if (!entries || entries.length === 0) continue;
26
+ lines.push('', config.section, '', ...entries);
27
+ }
28
+ return `${lines.join('\n')}\n`;
29
+ }
30
+
31
+ function insertReleaseBlock(changelog, releaseBlock, version) {
32
+ const normalized = stripLegacyUnreleasedBlock(changelog).trimEnd();
33
+ const escapedVersion = String(version).replace(/\./g, '\\.');
34
+ const versionPattern = new RegExp(`^## 🚀 \\[${escapedVersion}\\](?:\\s|$)`, 'm');
35
+ if (versionPattern.test(normalized)) {
36
+ throw new Error(`Версия ${version} уже присутствует в ${CHANGELOG_FILE}.`);
37
+ }
38
+
39
+ const firstReleaseMatch = /^## /m.exec(normalized);
40
+ if (!firstReleaseMatch) {
41
+ return `${normalized}\n\n${releaseBlock}`;
42
+ }
43
+
44
+ return `${normalized.slice(0, firstReleaseMatch.index).trimEnd()}\n\n${releaseBlock}\n${normalized.slice(firstReleaseMatch.index).trimStart()}\n`;
45
+ }
46
+
47
+ function changelogBuildRelease(context, date) {
48
+ try {
49
+ const version = context.newVersion;
50
+ const fragments = context.changelogFragments;
51
+ if (!version || !Array.isArray(fragments) || fragments.length === 0) return false;
52
+
53
+ const changelog = fs.readFileSync(CHANGELOG_FILE, 'utf8');
54
+ const releaseBlock = renderReleaseBlock(version, fragments, date);
55
+ const updatedChangelog = insertReleaseBlock(changelog, releaseBlock, version);
56
+ fs.writeFileSync(CHANGELOG_FILE, updatedChangelog);
57
+ logSuccess('📋', '%s собран из %s changelog fragments.', `CHANGELOG ${version}`, fragments.length);
58
+ return true;
59
+ } catch (error) {
60
+ logError('❌', 'Не удалось собрать релизный changelog: %s', error.message);
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function changelogRemoveFragments(context) {
66
+ try {
67
+ const fragments = context.changelogFragments;
68
+ if (!Array.isArray(fragments) || fragments.length === 0) return false;
69
+ for (const fragment of fragments) {
70
+ fs.unlinkSync(fragment.filePath);
71
+ }
72
+ logSuccess('🧹', 'Использованные changelog fragments удалены: %s.', fragments.length);
73
+ return true;
74
+ } catch (error) {
75
+ logError('❌', 'Не удалось удалить changelog fragments: %s', error.message);
76
+ return false;
77
+ }
78
+ }
79
+
80
+ module.exports = { stripLegacyUnreleasedBlock, renderReleaseBlock, insertReleaseBlock, changelogBuildRelease, changelogRemoveFragments };
@@ -0,0 +1,216 @@
1
+ const readline = require('readline');
2
+ const { logSuccess, logError, execCommand, execSilent } = require('../common/utils');
3
+ const { runCommand } = require('../cli/command-executor');
4
+ const { requireGitRepo, requireCleanWorkingTree, requireOnMainBranch, requireCurrentBranchUpToDateWithRemote, requireRemoteOrigin, requireRemoteReachable, requireSingleChart, requireHelmReleaseFiles } = require('../preflight');
5
+ const { getLatestRemoteChartVersion } = require('./metadata');
6
+ const { fetchChartVersionFromRegistry } = require('./registry');
7
+ const { getInstanceName, parseInstancesOption, updateHelmReleaseVersion, printDeployChanges, splitGitNameOnly } = require('./helmrelease');
8
+ function waitForEnter(promptText) {
9
+ return new Promise((resolve) => {
10
+ const rl = readline.createInterface({
11
+ input: process.stdin,
12
+ output: process.stdout
13
+ });
14
+ rl.question(promptText, () => {
15
+ rl.close();
16
+ resolve(true);
17
+ });
18
+ });
19
+ }
20
+
21
+ function chartDeploy(options = {}) {
22
+ const instancesOption = options.instances;
23
+
24
+ return runCommand({
25
+ name: 'chart deploy',
26
+ checks: [
27
+ { name: 'git-repo', run: requireGitRepo },
28
+ { name: 'clean-working-tree', run: requireCleanWorkingTree },
29
+ { name: 'on-main-branch', run: requireOnMainBranch },
30
+ { name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
31
+ { name: 'remote-origin', run: requireRemoteOrigin },
32
+ { name: 'remote-reachable', run: requireRemoteReachable },
33
+ { name: 'single-chart', run: requireSingleChart },
34
+ { name: 'helmrelease-files', run: requireHelmReleaseFiles },
35
+ {
36
+ name: 'filter-instances',
37
+ run: (ctx) => {
38
+ const requested = parseInstancesOption(instancesOption);
39
+ if (requested.length === 0) {
40
+ return { ok: true };
41
+ }
42
+
43
+ const available = new Map();
44
+ for (const filePath of ctx.helmReleaseFiles || []) {
45
+ const instanceName = getInstanceName(filePath);
46
+ if (instanceName) {
47
+ available.set(instanceName, filePath);
48
+ }
49
+ }
50
+
51
+ if (available.size === 0) {
52
+ return {
53
+ ok: false,
54
+ reason: 'В репозитории нет файлов instances/<name>/helmrelease.yaml, флаг --instances неприменим.'
55
+ };
56
+ }
57
+
58
+ const unknown = requested.filter((name) => !available.has(name));
59
+ if (unknown.length > 0) {
60
+ return {
61
+ ok: false,
62
+ reason: `Неизвестные инстансы: ${unknown.join(', ')}. Доступные: ${Array.from(available.keys()).join(', ')}.`
63
+ };
64
+ }
65
+
66
+ return { ok: true, data: { helmReleaseFiles: requested.map((name) => available.get(name)) } };
67
+ }
68
+ }
69
+ ],
70
+ steps: [
71
+ {
72
+ name: 'resolve-latest-chart-version',
73
+ run: (ctx) => {
74
+ const latestVersion = getLatestRemoteChartVersion(ctx.chartName);
75
+ if (!latestVersion) {
76
+ logError('❌', 'Не удалось найти удаленный тег чарта для %s.', ctx.chartName);
77
+ return false;
78
+ }
79
+ ctx.latestChartVersion = latestVersion;
80
+ logSuccess('🏷️', 'Последняя удаленная версия чарта для %s: %s.', ctx.chartName, latestVersion);
81
+ return true;
82
+ }
83
+ },
84
+ {
85
+ name: 'verify-chart-in-registry',
86
+ run: async (ctx) => {
87
+ const result = await fetchChartVersionFromRegistry(ctx.chartName, ctx.latestChartVersion);
88
+ if (!result.ok) {
89
+ logError('❌', '%s', result.reason);
90
+ return false;
91
+ }
92
+ if (!result.found) {
93
+ logError(
94
+ '❌',
95
+ 'Версия %s чарта %s отсутствует в Helm-registry. Дождитесь пайплайна "upload charts" и повторите.',
96
+ ctx.latestChartVersion,
97
+ ctx.chartName
98
+ );
99
+ return false;
100
+ }
101
+ logSuccess('📦', 'Версия %s чарта %s найдена в Helm-registry.', ctx.latestChartVersion, ctx.chartName);
102
+ return true;
103
+ }
104
+ },
105
+ {
106
+ name: 'update-helmrelease-files',
107
+ run: (ctx) => {
108
+ const changes = (ctx.helmReleaseFiles || []).map((filePath) => updateHelmReleaseVersion(filePath, ctx.latestChartVersion));
109
+ ctx.deployChanges = changes;
110
+ ctx.updatedFiles = changes.filter((item) => item.changed).map((item) => item.filePath);
111
+ ctx.updatedInstances = ctx.updatedFiles.map((filePath) => getInstanceName(filePath) || filePath);
112
+
113
+ printDeployChanges(changes);
114
+ if (ctx.updatedFiles.length === 0) {
115
+ ctx.noUpdates = true;
116
+ logSuccess('✅', 'Все файлы helmrelease.yaml уже используют последнюю версию чарта.');
117
+ return true;
118
+ }
119
+
120
+ logSuccess('📋', 'Будут обновлены инстансы: %s (версия чарта %s).', ctx.updatedInstances.join(', '), ctx.latestChartVersion);
121
+ return true;
122
+ }
123
+ },
124
+ {
125
+ name: 'confirm-publish',
126
+ run: async (ctx) => {
127
+ if (ctx.noUpdates) {
128
+ return true;
129
+ }
130
+ await waitForEnter('Готов ли ты к публикации? Нажми Enter для продолжения (Ctrl+C для отмены): ');
131
+ return true;
132
+ }
133
+ },
134
+ {
135
+ name: 'commit-and-push',
136
+ run: (ctx) => {
137
+ if (ctx.noUpdates) {
138
+ return true;
139
+ }
140
+ const files = ctx.updatedFiles || [];
141
+ const normalizedFiles = files.map((filePath) => String(filePath || '').replace(/\\/g, '/'));
142
+ if (files.length === 0) {
143
+ logSuccess('✅', 'Файлы не были изменены.');
144
+ return true;
145
+ }
146
+
147
+ const stagedBeforeAdd = splitGitNameOnly(execSilent('git diff --cached --name-only'));
148
+ const unstagedBeforeAdd = splitGitNameOnly(execSilent('git diff --name-only'));
149
+ if (stagedBeforeAdd === null || unstagedBeforeAdd === null) {
150
+ logError('❌', 'Не удалось проверить измененные файлы перед коммитом.');
151
+ return false;
152
+ }
153
+ if (stagedBeforeAdd.length > 0) {
154
+ logError('❌', 'Обнаружены неожиданные изменения в индексе: %s', stagedBeforeAdd.join(', '));
155
+ return false;
156
+ }
157
+
158
+ const expectedSet = new Set(normalizedFiles);
159
+ const unexpectedChanges = unstagedBeforeAdd.filter((filePath) => !expectedSet.has(filePath));
160
+ if (unexpectedChanges.length > 0) {
161
+ logError('❌', 'Обнаружены неожиданные изменения файлов: %s', unexpectedChanges.join(', '));
162
+ return false;
163
+ }
164
+
165
+ const missingExpectedChanges = normalizedFiles.filter((filePath) => !unstagedBeforeAdd.includes(filePath));
166
+ if (missingExpectedChanges.length > 0) {
167
+ logError('❌', 'В diff отсутствуют ожидаемые обновленные файлы: %s', missingExpectedChanges.join(', '));
168
+ return false;
169
+ }
170
+
171
+ const quoted = normalizedFiles.map((filePath) => `"${filePath}"`).join(' ');
172
+ if (!execCommand(`git add ${quoted}`)) {
173
+ logError('❌', 'Не удалось добавить обновленные файлы helmrelease.');
174
+ return false;
175
+ }
176
+
177
+ const stagedAfterAdd = splitGitNameOnly(execSilent('git diff --cached --name-only'));
178
+ if (stagedAfterAdd === null) {
179
+ logError('❌', 'Не удалось проверить staged-файлы после git add.');
180
+ return false;
181
+ }
182
+ const unexpectedStaged = stagedAfterAdd.filter((filePath) => !expectedSet.has(filePath));
183
+ const missingStaged = normalizedFiles.filter((filePath) => !stagedAfterAdd.includes(filePath));
184
+ if (unexpectedStaged.length > 0 || missingStaged.length > 0) {
185
+ if (unexpectedStaged.length > 0) {
186
+ logError('❌', 'Обнаружены неожиданные staged-файлы: %s', unexpectedStaged.join(', '));
187
+ }
188
+ if (missingStaged.length > 0) {
189
+ logError('❌', 'Ожидаемые файлы не добавлены в индекс: %s', missingStaged.join(', '));
190
+ }
191
+ return false;
192
+ }
193
+
194
+ const instanceNames = (ctx.updatedInstances && ctx.updatedInstances.length > 0)
195
+ ? ctx.updatedInstances
196
+ : normalizedFiles.map((filePath) => getInstanceName(filePath) || filePath);
197
+ const versionPart = ctx.latestChartVersion ? ` ${ctx.latestChartVersion}` : '';
198
+ const commitMessage = `🚀 Деплой сервиса${versionPart} (${instanceNames.join(', ')}).`;
199
+ if (!execCommand(`git commit -m "${commitMessage}"`)) {
200
+ logError('❌', 'Не удалось создать коммит деплоя.');
201
+ return false;
202
+ }
203
+ if (!execCommand('git push')) {
204
+ logError('❌', 'Не удалось отправить коммит деплоя.');
205
+ return false;
206
+ }
207
+
208
+ logSuccess('🚀', 'Коммит деплоя отправлен.');
209
+ return true;
210
+ }
211
+ }
212
+ ]
213
+ });
214
+ }
215
+
216
+ module.exports = { chartDeploy };
@@ -0,0 +1,90 @@
1
+ const fs = require('fs');
2
+ const { colors } = require('../common/utils');
3
+ const { normalizeList } = require('./routes');
4
+ function getInstanceName(filePath) {
5
+ const normalized = String(filePath || '').replace(/\\/g, '/');
6
+ const match = normalized.match(/(?:^|\/)instances\/([^/]+)\/helmrelease\.yaml$/i);
7
+ return match ? match[1] : null;
8
+ }
9
+
10
+ function parseInstancesOption(raw) {
11
+ return normalizeList(String(raw || '').split(','));
12
+ }
13
+
14
+ function updateHelmReleaseVersion(filePath, nextVersion) {
15
+ const content = fs.readFileSync(filePath, 'utf8');
16
+ const lines = content.split('\n');
17
+ const keyStack = [];
18
+ const oldVersions = [];
19
+ let changed = false;
20
+
21
+ const updatedLines = lines.map((line) => {
22
+ const keyMatch = line.match(/^(\s*)([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
23
+ if (!keyMatch || line.trim().startsWith('#')) {
24
+ return line;
25
+ }
26
+
27
+ const indent = keyMatch[1].length;
28
+ const key = keyMatch[2];
29
+
30
+ while (keyStack.length > 0 && keyStack[keyStack.length - 1].indent >= indent) {
31
+ keyStack.pop();
32
+ }
33
+
34
+ const parentPath = keyStack.map((item) => item.key).join('.');
35
+ keyStack.push({ key, indent });
36
+ if (!(parentPath === 'spec.chart.spec' && key === 'version')) {
37
+ return line;
38
+ }
39
+
40
+ const valuePart = keyMatch[3];
41
+ const valueMatch = valuePart.match(/^(\s*)(['"]?)([^'"#\s]+)\2(\s*(?:#.*)?)$/);
42
+ if (!valueMatch) {
43
+ return line;
44
+ }
45
+
46
+ const oldVersion = valueMatch[3];
47
+ oldVersions.push(oldVersion);
48
+ if (oldVersion === nextVersion) {
49
+ return line;
50
+ }
51
+
52
+ changed = true;
53
+ const spacing = valueMatch[1] || ' ';
54
+ return `${keyMatch[1]}version:${spacing}${valueMatch[2]}${nextVersion}${valueMatch[2]}${valueMatch[4]}`;
55
+ });
56
+
57
+ if (changed) {
58
+ fs.writeFileSync(filePath, updatedLines.join('\n'));
59
+ }
60
+
61
+ const uniqueOld = Array.from(new Set(oldVersions));
62
+ return {
63
+ filePath,
64
+ oldVersion: uniqueOld.length > 0 ? uniqueOld.join(', ') : 'unknown',
65
+ newVersion: nextVersion,
66
+ changed
67
+ };
68
+ }
69
+
70
+ function printDeployChanges(changes) {
71
+ changes.forEach((item) => {
72
+ const icon = item.changed ? '●' : '○';
73
+ const color = item.changed ? colors.green : '\x1b[90m';
74
+ console.log(
75
+ `${color}${icon} ${item.filePath}: ${item.oldVersion} -> ${item.newVersion}${colors.reset}`
76
+ );
77
+ });
78
+ }
79
+
80
+ function splitGitNameOnly(output) {
81
+ if (output === null || output === undefined) {
82
+ return null;
83
+ }
84
+ return String(output)
85
+ .split('\n')
86
+ .map((line) => line.trim().replace(/\\/g, '/'))
87
+ .filter(Boolean);
88
+ }
89
+
90
+ module.exports = { getInstanceName, parseInstancesOption, updateHelmReleaseVersion, printDeployChanges, splitGitNameOnly };
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ const { chartStart } = require('./start');
3
+ const { chartDeploy } = require('./deploy');
4
+ const { chartVerify, buildGitLikeDiff } = require('./verify');
5
+ const { normalizeList, normalizeToBeList, collectRoutesFromFilesystem, collectRoutesFromBuildArtifacts } = require('./routes');
6
+ const { getChartName, getChartFiles, isSemver, compareSemver, getLatestRemoteChartVersion } = require('./metadata');
7
+ const { helmIndexHasChartVersion, fetchChartVersionFromRegistry, waitForChartInRegistry } = require('./registry');
8
+ const { getInstanceName, parseInstancesOption, updateHelmReleaseVersion } = require('./helmrelease');
9
+
10
+ module.exports = {
11
+ chartStart,
12
+ chartDeploy,
13
+ chartVerify,
14
+ normalizeList,
15
+ normalizeToBeList,
16
+ collectRoutesFromFilesystem,
17
+ collectRoutesFromBuildArtifacts,
18
+ buildGitLikeDiff,
19
+ getChartName,
20
+ getChartFiles,
21
+ isSemver,
22
+ compareSemver,
23
+ getLatestRemoteChartVersion,
24
+ helmIndexHasChartVersion,
25
+ fetchChartVersionFromRegistry,
26
+ waitForChartInRegistry,
27
+ getInstanceName,
28
+ parseInstancesOption,
29
+ updateHelmReleaseVersion
30
+ };