spectrum-cli 2.1.1 → 3.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.
package/src/changelog.js CHANGED
@@ -2,114 +2,20 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const readline = require('readline');
5
- const { logSuccess, logError, execCommand, execSilent, getVersion, getCurrentBranch, colors } = require('./utils');
5
+ const { logSuccess, logError, execSilent, getCurrentBranch, colors } = require('./utils');
6
6
  const { runCommand } = require('./command-executor');
7
- const { requireGitRepo, requireFileExists, requirePrettierAvailable, requireChangelogFormatted } = require('./preflight');
8
-
9
- function getPrettierRunner() {
10
- const npxVersion = execSilent('npx --yes prettier --version');
11
- if (npxVersion) return 'npx --yes prettier';
12
- const directVersion = execSilent('prettier --version');
13
- if (directVersion) return 'prettier';
14
- return null;
15
- }
16
-
17
- function ensurePrettierAvailable() {
18
- const runner = getPrettierRunner();
19
- if (!runner) {
20
- logError('❌', 'Prettier недоступен. Установите его или используйте npx.');
21
- return null;
22
- }
23
- return runner;
24
- }
25
-
26
- function checkChangelogWithPrettier() {
27
- const runner = ensurePrettierAvailable();
28
- if (!runner) return false;
29
- const ok = execCommand(`${runner} --check CHANGELOG.md`);
30
- if (!ok) {
31
- logError('❌', 'Файл CHANGELOG.md не прошел проверку Prettier. Исправьте форматирование перед продолжением.');
32
- return false;
33
- }
34
- return true;
35
- }
36
-
37
- function changelogChangeHeader() {
38
- try {
39
- if (!checkChangelogWithPrettier()) {
40
- return false;
41
- }
42
- const currentVersion = getVersion();
43
- if (!currentVersion) {
44
- return false;
45
- }
46
-
47
- const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
48
- const updatedChangelog = changelog.replace(
49
- /## \[Unreleased\]/g,
50
- `## 🚀 [${currentVersion}]`
51
- );
52
-
53
- fs.writeFileSync('CHANGELOG.md', updatedChangelog);
54
- logSuccess('🔄', 'Заголовок обновлен до 🚀 [%s].', currentVersion);
55
- return true;
56
- } catch (error) {
57
- return false;
58
- }
59
- }
60
-
61
- function changelogRemoveEmptyChapters() {
62
- try {
63
- if (!checkChangelogWithPrettier()) {
64
- return false;
65
- }
66
- const data = fs.readFileSync('CHANGELOG.md', 'utf8');
67
- const updatedData = data.replaceAll(/###.*\n\n_.*_\n\n/gm, '');
68
- fs.writeFileSync('CHANGELOG.md', updatedData);
69
-
70
- logSuccess('🧹', 'Пустые разделы удалены.');
71
- return true;
72
- } catch (error) {
73
- return false;
74
- }
75
- }
76
-
77
- function changelogAddUnreleasedBlock() {
78
- try {
79
- if (!checkChangelogWithPrettier()) {
80
- return false;
81
- }
82
- const currentVersion = getVersion();
83
- if (!currentVersion) {
84
- return false;
85
- }
86
-
87
- const unreleased = fs.readFileSync(path.join(__dirname, 'UNRELEASED.md'), 'utf8') + '\n';
88
-
89
- const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
90
- const updatedChangelog = changelog.replace(
91
- new RegExp(`## 🚀 \\[${currentVersion}\\]`),
92
- `${unreleased}## 🚀 [${currentVersion}]`
93
- );
94
-
95
- fs.writeFileSync('CHANGELOG.md', updatedChangelog);
96
- logSuccess('📋', 'Блок unreleased добавлен.');
97
- return true;
98
- } catch (error) {
99
- return false;
100
- }
101
- }
102
-
103
- function changelogCommit() {
104
- const addSuccess = execCommand('git add CHANGELOG.md');
105
- const commitSuccess = execCommand('git commit --message "📝 Обновить changelog." --no-verify');
106
-
107
- if (addSuccess && commitSuccess) {
108
- logSuccess('📝', 'Коммит с обновленным changelog создан.');
109
- return true;
110
- }
111
- return false;
112
- }
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');
113
19
 
114
20
  function createReadlineInterface() {
115
21
  return readline.createInterface({
@@ -134,32 +40,31 @@ async function extractTaskFromBranch() {
134
40
  logError('❌', 'Не удалось получить имя текущей ветки.');
135
41
  return null;
136
42
  }
137
-
138
- const taskMatch = currentBranch.match(/([a-zA-Z]+-[0-9]+)/);
43
+
44
+ const taskMatch = currentBranch.match(/^[a-z][a-z0-9-]*\/([A-Z]+-[0-9]+)(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/);
139
45
  if (!taskMatch) {
140
- console.log(`⚠️ ${colors.yellow}Имя ветки "${currentBranch}" не содержит номер задачи в формате [a-zA-Z]+-[0-9]+.${colors.reset}`);
141
- const task = await askQuestion('📝 Введите номер задачи (например, SPEC-123): ');
142
- if (!task || !task.match(/^[a-zA-Z]+-[0-9]+$/)) {
143
- logError('❌', 'Неверный формат задачи. Ожидается формат: [a-zA-Z]+-[0-9]+');
144
- return null;
145
- }
146
- return task;
46
+ logError(
47
+ '❌',
48
+ 'Имя ветки "%s" должно соответствовать <type>/<YOUTRACK-ID> или <type>/<YOUTRACK-ID>-<slug>.',
49
+ currentBranch
50
+ );
51
+ return null;
147
52
  }
148
-
53
+
149
54
  return taskMatch[1];
150
55
  }
151
56
 
152
57
  async function getGitUser() {
153
58
  let name = execSilent('git config user.name');
154
59
  let email = execSilent('git config user.email');
155
-
60
+
156
61
  if (!name || !email) {
157
62
  console.log(`⚠️ ${colors.yellow}Имя и email пользователя git не настроены.${colors.reset}`);
158
- console.log(`💡 Их можно настроить так:`);
159
- console.log(` git config user.name "Ваше Имя"`);
160
- console.log(` git config user.email "your.email@domain.com"`);
63
+ console.log('💡 Их можно настроить так:');
64
+ console.log(' git config user.name "Ваше Имя"');
65
+ console.log(' git config user.email "your.email@domain.com"');
161
66
  console.log('');
162
-
67
+
163
68
  if (!name) {
164
69
  name = await askQuestion('👤 Введите ваше имя: ');
165
70
  if (!name) {
@@ -167,7 +72,7 @@ async function getGitUser() {
167
72
  return null;
168
73
  }
169
74
  }
170
-
75
+
171
76
  if (!email) {
172
77
  email = await askQuestion('📧 Введите ваш email: ');
173
78
  if (!email || !email.includes('@')) {
@@ -176,210 +81,276 @@ async function getGitUser() {
176
81
  }
177
82
  }
178
83
  }
179
-
84
+
180
85
  return `[${name}](${email})`;
181
86
  }
182
87
 
183
88
  function formatMessage(message) {
184
- if (!message.endsWith('.')) {
185
- return message + '.';
186
- }
187
- return message;
89
+ const trimmedMessage = String(message || '').trim();
90
+ if (!trimmedMessage) return '';
91
+ return /[.!?]$/.test(trimmedMessage) ? trimmedMessage : `${trimmedMessage}.`;
188
92
  }
189
93
 
190
94
  function detectSectionFromBranch() {
191
95
  const currentBranch = getCurrentBranch();
192
96
  if (!currentBranch) return [];
193
-
194
- const branchLower = currentBranch.toLowerCase();
97
+
98
+ const branchType = currentBranch.split('/', 1)[0].toLowerCase();
195
99
  const sections = {
196
- support: ['### 📦 Support', '### 🔐 Security'],
197
- bugfix: ['### 🪲 Fixed'],
198
- feature: ['### 💥 Breaking change', '### 🆕 Added', '### 🛠 Changed', '### 📜 Deprecated', '### 🗑 Removed']
100
+ support: [FRAGMENT_TYPES.support.section, FRAGMENT_TYPES.security.section],
101
+ bugfix: [FRAGMENT_TYPES.fixed.section],
102
+ feature: [
103
+ FRAGMENT_TYPES.breaking.section,
104
+ FRAGMENT_TYPES.added.section,
105
+ FRAGMENT_TYPES.changed.section,
106
+ FRAGMENT_TYPES.deprecated.section,
107
+ FRAGMENT_TYPES.removed.section
108
+ ]
199
109
  };
200
-
201
- if (branchLower.includes('support')) {
110
+
111
+ if (branchType === 'support') {
202
112
  return sections.support;
203
- } else if (branchLower.includes('bugfix') || branchLower.includes('fix')) {
113
+ }
114
+ if (branchType === 'bugfix' || branchType === 'fix' || branchType === 'hotfix') {
204
115
  return sections.bugfix;
205
- } else if (branchLower.includes('feature') || branchLower.includes('feat')) {
116
+ }
117
+ if (branchType === 'feature' || branchType === 'feat') {
206
118
  return sections.feature;
207
119
  }
208
-
120
+
209
121
  return [];
210
122
  }
211
123
 
212
124
  async function selectSection(availableSections) {
213
- if (availableSections.length === 0) {
214
- availableSections = [
215
- '### 💥 Breaking change',
216
- '### 🆕 Added',
217
- '### 🛠 Changed',
218
- '### 📜 Deprecated',
219
- '### 🗑 Removed',
220
- '### 🪲 Fixed',
221
- '### 🔐 Security',
222
- '### 📦 Support'
223
- ];
224
- }
225
-
226
- if (availableSections.length === 1) {
227
- return availableSections[0];
125
+ const sections = availableSections.length > 0
126
+ ? availableSections
127
+ : Object.values(FRAGMENT_TYPES).map((config) => config.section);
128
+
129
+ if (sections.length === 1) {
130
+ return sections[0];
228
131
  }
229
-
230
- console.log(`\n📋 Выберите раздел:`);
231
- availableSections.forEach((section, index) => {
132
+
133
+ console.log('\n📋 Выберите раздел:');
134
+ sections.forEach((section, index) => {
232
135
  console.log(` ${index + 1}. ${section}`);
233
136
  });
234
-
137
+
235
138
  const choice = await askQuestion('\n🔢 Введите номер раздела: ');
236
- const choiceNum = parseInt(choice);
237
-
238
- if (isNaN(choiceNum) || choiceNum < 1 || choiceNum > availableSections.length) {
139
+ const choiceNum = parseInt(choice, 10);
140
+
141
+ if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > sections.length) {
239
142
  logError('❌', 'Неверный выбор.');
240
143
  return null;
241
144
  }
242
-
243
- return availableSections[choiceNum - 1];
145
+
146
+ return sections[choiceNum - 1];
244
147
  }
245
148
 
246
- function displayContext(lines, insertIndex, entry) {
247
- console.log('\n📝 Changelog обновлен:');
248
-
249
- if (insertIndex > 0) {
250
- console.log(` ${colors.reset}${lines[insertIndex - 1]}${colors.reset}`);
251
- }
252
-
253
- console.log(` ${colors.green}${entry}${colors.reset}`);
254
-
255
- if (insertIndex < lines.length - 1) {
256
- console.log(` ${colors.reset}${lines[insertIndex]}${colors.reset}`);
257
- }
258
- console.log('');
149
+ function sanitizeFragmentSlug(branchName, task) {
150
+ const branchWithoutTask = String(branchName || '').replace(new RegExp(task, 'i'), '-');
151
+ const slug = branchWithoutTask
152
+ .toLowerCase()
153
+ .replace(/[^a-z0-9]+/g, '-')
154
+ .replace(/^-+|-+$/g, '')
155
+ .slice(0, 80);
156
+ return slug || 'change';
259
157
  }
260
158
 
261
- async function changelogAppend(message) {
262
- return runCommand({
263
- name: 'changelog append',
264
- checks: [
265
- { name: 'git-repo', run: requireGitRepo },
266
- { name: 'changelog-exists', run: () => requireFileExists('CHANGELOG.md') },
267
- { name: 'prettier-available', run: requirePrettierAvailable },
268
- { name: 'changelog-prettier-check', run: requireChangelogFormatted },
269
- {
270
- name: 'prepare-entry',
271
- run: async () => prepareChangelogEntry(message)
272
- }
273
- ],
274
- steps: [
275
- {
276
- name: 'append-entry',
277
- run: (ctx) => appendPreparedChangelogEntry(ctx)
278
- }
279
- ]
280
- });
159
+ function createFragmentPath(task, type, branchName = getCurrentBranch()) {
160
+ const slug = sanitizeFragmentSlug(branchName, task);
161
+ return path.join(CHANGELOG_DIR, `${task}-${slug}.${type}.md`);
162
+ }
163
+
164
+ function displayFragment(fragmentPath, entry) {
165
+ console.log('\n📝 Changelog fragment создан:');
166
+ console.log(` ${colors.green}${fragmentPath}${colors.reset}`);
167
+ console.log(` ${colors.green}${entry}${colors.reset}`);
168
+ console.log('');
281
169
  }
282
170
 
283
171
  async function prepareChangelogEntry(message) {
284
172
  try {
173
+ const formattedMessage = formatMessage(message);
174
+ if (!formattedMessage) {
175
+ return { ok: false, reason: 'Сообщение changelog не может быть пустым.' };
176
+ }
177
+
285
178
  const task = await extractTaskFromBranch();
286
179
  if (!task) return { ok: false, reason: 'Не удалось определить ID задачи.' };
287
180
 
288
181
  const user = await getGitUser();
289
182
  if (!user) return { ok: false, reason: 'Не удалось определить пользователя git.' };
290
183
 
291
- const formattedMessage = formatMessage(message);
292
- const entry = `- ${task} ${formattedMessage} ${user}`;
293
- const availableSections = detectSectionFromBranch();
294
- const selectedSection = await selectSection(availableSections);
184
+ const selectedSection = await selectSection(detectSectionFromBranch());
295
185
  if (!selectedSection) return { ok: false, reason: 'Не удалось выбрать раздел.' };
296
186
 
297
- const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
298
- const lines = changelog.split('\n');
299
-
300
- let sectionIndex = -1;
301
- for (let i = 0; i < lines.length; i++) {
302
- if (lines[i] === selectedSection) {
303
- sectionIndex = i;
304
- break;
305
- }
306
- }
307
-
308
- if (sectionIndex === -1) {
309
- return { ok: false, reason: `Не удалось найти раздел "${selectedSection}" в CHANGELOG.md.` };
310
- }
187
+ const type = SECTION_TO_TYPE[selectedSection];
188
+ if (!type) return { ok: false, reason: `Неизвестный раздел changelog: "${selectedSection}".` };
311
189
 
312
190
  return {
313
191
  ok: true,
314
192
  data: {
315
- appendState: {
316
- entry,
193
+ fragmentState: {
194
+ entry: `- ${task} ${formattedMessage} ${user}`,
195
+ fragmentPath: createFragmentPath(task, type),
317
196
  selectedSection,
318
- lines,
319
- sectionIndex
197
+ type
320
198
  }
321
199
  }
322
200
  };
323
201
  } catch (error) {
324
- return { ok: false, reason: `Ошибка при подготовке записи changelog: ${error.message}` };
202
+ return { ok: false, reason: `Ошибка при подготовке changelog fragment: ${error.message}` };
325
203
  }
326
204
  }
327
205
 
328
206
  function appendPreparedChangelogEntry(context) {
329
207
  try {
330
- const appendState = context.appendState;
331
- if (!appendState) {
332
- return false;
208
+ const fragmentState = context.fragmentState;
209
+ if (!fragmentState) return false;
210
+
211
+ fs.mkdirSync(CHANGELOG_DIR, { recursive: true });
212
+ let entries = [];
213
+ if (fs.existsSync(fragmentState.fragmentPath)) {
214
+ entries = fs.readFileSync(fragmentState.fragmentPath, 'utf8')
215
+ .split('\n')
216
+ .map((line) => line.trim())
217
+ .filter(Boolean);
218
+ if (entries.some((line) => !line.startsWith('- '))) {
219
+ logError('❌', 'Существующий fragment имеет неверный формат: %s', fragmentState.fragmentPath);
220
+ return false;
221
+ }
333
222
  }
334
223
 
335
- const lines = [...appendState.lines];
336
- const selectedSection = appendState.selectedSection;
337
- const entry = appendState.entry;
338
- let insertIndex = appendState.sectionIndex + 2;
339
- let defaultTextLines = 0;
340
-
341
- while (insertIndex + defaultTextLines < lines.length && lines[insertIndex + defaultTextLines].startsWith('_')) {
342
- defaultTextLines++;
224
+ if (!entries.includes(fragmentState.entry)) {
225
+ entries.push(fragmentState.entry);
343
226
  }
227
+ fs.writeFileSync(fragmentState.fragmentPath, `${entries.join('\n')}\n`);
228
+ displayFragment(fragmentState.fragmentPath, fragmentState.entry);
229
+ logSuccess('✅', 'Запись добавлена в fragment раздела %s', fragmentState.selectedSection);
230
+ return true;
231
+ } catch (error) {
232
+ logError('❌', 'Ошибка при записи changelog fragment: %s', error.message);
233
+ return false;
234
+ }
235
+ }
344
236
 
345
- lines.splice(insertIndex, 0, entry);
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
+ }
346
250
 
347
- if (defaultTextLines > 0) {
348
- let hasOtherEntries = false;
349
- for (let i = insertIndex + 1; i < insertIndex + 1 + defaultTextLines; i++) {
350
- if (lines[i] && lines[i].startsWith('- ')) {
351
- hasOtherEntries = true;
352
- break;
353
- }
354
- }
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
+ }
355
262
 
356
- if (!hasOtherEntries) {
357
- lines.splice(insertIndex + 1, defaultTextLines);
358
- }
359
- }
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)) {
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 = [`## 🚀 [${version}] - ${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
+ }
360
305
 
361
- fs.writeFileSync('CHANGELOG.md', lines.join('\n'));
362
- displayContext(lines, insertIndex, entry);
363
- logSuccess('✅', 'Запись добавлена в %s', selectedSection);
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);
364
317
  return true;
365
318
  } catch (error) {
366
- logError('❌', 'Ошибка при добавлении записи changelog: %s', error.message);
319
+ logError('❌', 'Не удалось собрать релизный changelog: %s', error.message);
367
320
  return false;
368
321
  }
369
322
  }
370
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
+ }
371
338
 
372
339
  module.exports = {
373
- changelogChangeHeader,
374
- changelogRemoveEmptyChapters,
375
- changelogAddUnreleasedBlock,
376
- changelogCommit,
377
340
  changelogAppend,
341
+ changelogCheck,
378
342
  extractTaskFromBranch,
379
343
  getGitUser,
380
344
  formatMessage,
381
345
  detectSectionFromBranch,
382
346
  selectSection,
347
+ sanitizeFragmentSlug,
348
+ createFragmentPath,
383
349
  prepareChangelogEntry,
384
- appendPreparedChangelogEntry
350
+ appendPreparedChangelogEntry,
351
+ stripLegacyUnreleasedBlock,
352
+ renderReleaseBlock,
353
+ insertReleaseBlock,
354
+ changelogBuildRelease,
355
+ changelogRemoveFragments
385
356
  };