spectrum-cli 3.2.0 → 5.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 +29 -1
  2. package/README.md +61 -30
  3. package/index.js +6 -174
  4. package/package.json +4 -4
  5. package/src/changelog/commands.js +44 -0
  6. package/src/{changelog-config.js → changelog/config.js} +1 -1
  7. package/src/changelog/format.js +51 -0
  8. package/src/changelog/fragments.js +223 -0
  9. package/src/{hotfix-changelog.js → changelog/hotfix.js} +2 -2
  10. package/src/changelog/index.js +7 -0
  11. package/src/{open-release.js → changelog/open-release.js} +6 -6
  12. package/src/changelog/release-block.js +80 -0
  13. package/src/chart/deploy.js +216 -0
  14. package/src/chart/helmrelease.js +90 -0
  15. package/src/chart/index.js +30 -0
  16. package/src/chart/metadata.js +114 -0
  17. package/src/chart/registry.js +139 -0
  18. package/src/chart/routes.js +244 -0
  19. package/src/chart/start.js +98 -0
  20. package/src/chart/verify.js +166 -0
  21. package/src/cli/command-executor.js +181 -0
  22. package/src/cli/commands.js +136 -0
  23. package/src/{development.js → cli/development.js} +1 -1
  24. package/src/cli/help.js +55 -0
  25. package/src/cli/run-action.js +28 -0
  26. package/src/{update-check.js → cli/update-check.js} +8 -2
  27. package/src/{git.js → git/index.js} +4 -3
  28. package/src/hotfix/checks.js +144 -0
  29. package/src/hotfix/close.js +46 -0
  30. package/src/hotfix/deploy.js +37 -0
  31. package/src/hotfix/git.js +35 -0
  32. package/src/hotfix/index.js +5 -0
  33. package/src/hotfix/start.js +57 -0
  34. package/src/integrations/gitlab-url.js +16 -0
  35. package/src/preflight/changelog.js +130 -0
  36. package/src/preflight/chart.js +57 -0
  37. package/src/preflight/git.js +133 -0
  38. package/src/preflight/helmrelease.js +201 -0
  39. package/src/preflight/index.js +10 -0
  40. package/src/preflight/result.js +18 -0
  41. package/src/preflight/source.js +95 -0
  42. package/src/preflight/tags.js +139 -0
  43. package/src/release/close.js +39 -0
  44. package/src/release/index.js +7 -0
  45. package/src/{stable-tags.js → release/stable-tags.js} +1 -1
  46. package/src/release/start.js +51 -0
  47. package/src/release/steps.js +83 -0
  48. package/src/release/version.js +20 -0
  49. package/src/token/config.js +163 -0
  50. package/src/token/gitlab-api.js +59 -0
  51. package/src/token/index.js +6 -0
  52. package/src/token/rotate.js +157 -0
  53. package/src/changelog.js +0 -418
  54. package/src/chart.js +0 -1071
  55. package/src/command-executor.js +0 -70
  56. package/src/hotfix.js +0 -208
  57. package/src/preflight.js +0 -796
  58. package/src/release.js +0 -207
  59. package/src/token.js +0 -399
  60. /package/src/{utils.js → common/utils.js} +0 -0
  61. /package/src/{version.js → common/version.js} +0 -0
@@ -0,0 +1,223 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const readline = require('readline');
4
+ const { logSuccess, logError, execSilent, getCurrentBranch, colors } = require('../common/utils');
5
+ const { CHANGELOG_DIR, FRAGMENT_TYPES, SECTION_TO_TYPE } = require('./config');
6
+ function createReadlineInterface() {
7
+ return readline.createInterface({
8
+ input: process.stdin,
9
+ output: process.stdout
10
+ });
11
+ }
12
+
13
+ function askQuestion(question) {
14
+ return new Promise((resolve) => {
15
+ const rl = createReadlineInterface();
16
+ rl.question(question, (answer) => {
17
+ rl.close();
18
+ resolve(answer.trim());
19
+ });
20
+ });
21
+ }
22
+
23
+ async function extractTaskFromBranch() {
24
+ const currentBranch = getCurrentBranch();
25
+ if (!currentBranch) {
26
+ logError('❌', 'Не удалось получить имя текущей ветки.');
27
+ return null;
28
+ }
29
+
30
+ const taskMatch = currentBranch.match(/^[a-z][a-z0-9-]*\/([A-Z]+-[0-9]+)(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/);
31
+ if (!taskMatch) {
32
+ logError(
33
+ '❌',
34
+ 'Имя ветки "%s" должно соответствовать <type>/<YOUTRACK-ID> или <type>/<YOUTRACK-ID>-<slug>.',
35
+ currentBranch
36
+ );
37
+ return null;
38
+ }
39
+
40
+ return taskMatch[1];
41
+ }
42
+
43
+ async function getGitUser() {
44
+ let name = execSilent('git config user.name');
45
+ let email = execSilent('git config user.email');
46
+
47
+ if (!name || !email) {
48
+ console.log(`⚠️ ${colors.yellow}Имя и email пользователя git не настроены.${colors.reset}`);
49
+ console.log('💡 Их можно настроить так:');
50
+ console.log(' git config user.name "Ваше Имя"');
51
+ console.log(' git config user.email "your.email@domain.com"');
52
+ console.log('');
53
+
54
+ if (!name) {
55
+ name = await askQuestion('👤 Введите ваше имя: ');
56
+ if (!name) {
57
+ logError('❌', 'Имя обязательно.');
58
+ return null;
59
+ }
60
+ }
61
+
62
+ if (!email) {
63
+ email = await askQuestion('📧 Введите ваш email: ');
64
+ if (!email || !email.includes('@')) {
65
+ logError('❌', 'Требуется корректный email.');
66
+ return null;
67
+ }
68
+ }
69
+ }
70
+
71
+ return `[${name}](${email})`;
72
+ }
73
+
74
+ function formatMessage(message) {
75
+ const trimmedMessage = String(message || '').trim();
76
+ if (!trimmedMessage) return '';
77
+ return /[.!?]$/.test(trimmedMessage) ? trimmedMessage : `${trimmedMessage}.`;
78
+ }
79
+
80
+ function detectSectionFromBranch() {
81
+ const currentBranch = getCurrentBranch();
82
+ if (!currentBranch) return [];
83
+
84
+ const branchType = currentBranch.split('/', 1)[0].toLowerCase();
85
+ const sections = {
86
+ support: [FRAGMENT_TYPES.support.section, FRAGMENT_TYPES.security.section],
87
+ bugfix: [FRAGMENT_TYPES.fixed.section],
88
+ feature: [
89
+ FRAGMENT_TYPES.breaking.section,
90
+ FRAGMENT_TYPES.added.section,
91
+ FRAGMENT_TYPES.changed.section,
92
+ FRAGMENT_TYPES.deprecated.section,
93
+ FRAGMENT_TYPES.removed.section
94
+ ]
95
+ };
96
+
97
+ if (branchType === 'support') {
98
+ return sections.support;
99
+ }
100
+ if (branchType === 'bugfix' || branchType === 'fix' || branchType === 'hotfix') {
101
+ return sections.bugfix;
102
+ }
103
+ if (branchType === 'feature' || branchType === 'feat') {
104
+ return sections.feature;
105
+ }
106
+
107
+ return [];
108
+ }
109
+
110
+ async function selectSection(availableSections) {
111
+ const sections = availableSections.length > 0
112
+ ? availableSections
113
+ : Object.values(FRAGMENT_TYPES).map((config) => config.section);
114
+
115
+ if (sections.length === 1) {
116
+ return sections[0];
117
+ }
118
+
119
+ console.log('\n📋 Выберите раздел:');
120
+ sections.forEach((section, index) => {
121
+ console.log(` ${index + 1}. ${section}`);
122
+ });
123
+
124
+ const choice = await askQuestion('\n🔢 Введите номер раздела: ');
125
+ const choiceNum = parseInt(choice, 10);
126
+
127
+ if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > sections.length) {
128
+ logError('❌', 'Неверный выбор.');
129
+ return null;
130
+ }
131
+
132
+ return sections[choiceNum - 1];
133
+ }
134
+
135
+ function sanitizeFragmentSlug(branchName, task) {
136
+ const branchWithoutTask = String(branchName || '').replace(new RegExp(task, 'i'), '-');
137
+ const slug = branchWithoutTask
138
+ .toLowerCase()
139
+ .replace(/[^a-z0-9]+/g, '-')
140
+ .replace(/^-+|-+$/g, '')
141
+ .slice(0, 80);
142
+ return slug || 'change';
143
+ }
144
+
145
+ function createFragmentPath(task, type, branchName = getCurrentBranch()) {
146
+ const slug = sanitizeFragmentSlug(branchName, task);
147
+ return path.posix.join(CHANGELOG_DIR, `${task}-${slug}.${type}.md`);
148
+ }
149
+
150
+ function displayFragment(fragmentPath, entry) {
151
+ console.log('\n📝 Changelog fragment создан:');
152
+ console.log(` ${colors.green}${fragmentPath}${colors.reset}`);
153
+ console.log(` ${colors.green}${entry}${colors.reset}`);
154
+ console.log('');
155
+ }
156
+
157
+ async function prepareChangelogEntry(message) {
158
+ try {
159
+ const formattedMessage = formatMessage(message);
160
+ if (!formattedMessage) {
161
+ return { ok: false, reason: 'Сообщение changelog не может быть пустым.' };
162
+ }
163
+
164
+ const task = await extractTaskFromBranch();
165
+ if (!task) return { ok: false, reason: 'Не удалось определить ID задачи.' };
166
+
167
+ const user = await getGitUser();
168
+ if (!user) return { ok: false, reason: 'Не удалось определить пользователя git.' };
169
+
170
+ const selectedSection = await selectSection(detectSectionFromBranch());
171
+ if (!selectedSection) return { ok: false, reason: 'Не удалось выбрать раздел.' };
172
+
173
+ const type = SECTION_TO_TYPE[selectedSection];
174
+ if (!type) return { ok: false, reason: `Неизвестный раздел changelog: "${selectedSection}".` };
175
+
176
+ return {
177
+ ok: true,
178
+ data: {
179
+ fragmentState: {
180
+ entry: `- ${task} ${formattedMessage} ${user}`,
181
+ fragmentPath: createFragmentPath(task, type),
182
+ selectedSection,
183
+ type
184
+ }
185
+ }
186
+ };
187
+ } catch (error) {
188
+ return { ok: false, reason: `Ошибка при подготовке changelog fragment: ${error.message}` };
189
+ }
190
+ }
191
+
192
+ function appendPreparedChangelogEntry(context) {
193
+ try {
194
+ const fragmentState = context.fragmentState;
195
+ if (!fragmentState) return false;
196
+
197
+ fs.mkdirSync(CHANGELOG_DIR, { recursive: true });
198
+ let entries = [];
199
+ if (fs.existsSync(fragmentState.fragmentPath)) {
200
+ entries = fs.readFileSync(fragmentState.fragmentPath, 'utf8')
201
+ .split('\n')
202
+ .map((line) => line.trim())
203
+ .filter(Boolean);
204
+ if (entries.some((line) => !line.startsWith('- '))) {
205
+ logError('❌', 'Существующий fragment имеет неверный формат: %s', fragmentState.fragmentPath);
206
+ return false;
207
+ }
208
+ }
209
+
210
+ if (!entries.includes(fragmentState.entry)) {
211
+ entries.push(fragmentState.entry);
212
+ }
213
+ fs.writeFileSync(fragmentState.fragmentPath, `${entries.join('\n')}\n`);
214
+ displayFragment(fragmentState.fragmentPath, fragmentState.entry);
215
+ logSuccess('✅', 'Запись добавлена в fragment раздела %s', fragmentState.selectedSection);
216
+ return true;
217
+ } catch (error) {
218
+ logError('❌', 'Ошибка при записи changelog fragment: %s', error.message);
219
+ return false;
220
+ }
221
+ }
222
+
223
+ module.exports = { extractTaskFromBranch, getGitUser, formatMessage, detectSectionFromBranch, selectSection, sanitizeFragmentSlug, createFragmentPath, prepareChangelogEntry, appendPreparedChangelogEntry };
@@ -1,5 +1,5 @@
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
5
  function parseChangelog(text, { validate = true } = {}) {
@@ -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
+ };