spectrum-cli 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 🚀 [3.2.0] - 2026-10-01
9
+
10
+ ### 🆕 Added
11
+
12
+ - Добавлена команда `spectrum changelog write`: выравнивает заголовки релизов и хотфиксов и форматирует `CHANGELOG.md` через Prettier.
13
+ - Добавлена команда `spectrum upgrade` для глобального обновления CLI через npm.
14
+ - Перед выполнением команд CLI проверяет наличие новой версии в npm с таймаутом 200 мс и выводит версии и команду обновления; ошибка проверки не прерывает команду.
15
+
16
+ ### 🪲 Fixed
17
+
18
+ - `spectrum hotfix start` собирает все текущие фрагменты из `.changelog/` независимо от истории Git.
19
+
20
+ ## 🚀 [3.1.1] - 2026-09-24
21
+
22
+ ### 🪲 Fixed
23
+
24
+ - Хотфиксы создают раздел с 🩹 и поддерживают запуск подготовки из main/master.
25
+ - Дата раздела changelog необязательна; опубликованная история в прежнем формате сохраняется.
26
+ - Исправлены проверка корня репозитория и сравнение CRLF/LF в release/hotfix на Windows.
27
+
8
28
  ## 🚀 [3.1.0] - 2026-09-24
9
29
 
10
30
  ### 🆕 Added
package/README.md CHANGED
@@ -14,6 +14,12 @@ npm install -g spectrum-cli
14
14
  spectrum --help
15
15
  ```
16
16
 
17
+ Перед выполнением каждой команды CLI проверяет опубликованную версию `spectrum-cli`
18
+ в npm Registry. Если доступна новая версия, показывает текущую и новую версии и
19
+ команду `spectrum upgrade`. Она запускает `npm install -g spectrum-cli`.
20
+ Проверка ограничена 200 мс; недоступность Registry не влияет на выполнение
21
+ команды и не выводит ошибку.
22
+
17
23
  ### 🔧 Из исходников
18
24
 
19
25
  ```bash
@@ -42,7 +48,7 @@ spectrum release deploy # Создать стабильный те
42
48
  spectrum release close # Свести stable main/master в dev
43
49
 
44
50
  # Срочный patch без переноса незавершённого dev в production
45
- spectrum hotfix start # Только локальная подготовка CHANGELOG на hotfix/*
51
+ spectrum hotfix start # Локальная подготовка 🩹 CHANGELOG на hotfix/* или main/master
46
52
  spectrum hotfix deploy # После merge MR: stable-тег на актуальном main/master
47
53
  spectrum hotfix close # После stable pipeline: main/master → dev
48
54
 
@@ -57,6 +63,7 @@ spectrum chart verify ~/repo # Сравнить ingress paths AS IS vs TO BE д
57
63
  # 📝 Управление changelog
58
64
  spectrum changelog append "Сообщение" # Создать fragment в .changelog/
59
65
  spectrum changelog check # Проверить CHANGELOG.md и все fragments
66
+ spectrum changelog write # Выравнять заголовки CHANGELOG.md и запустить Prettier
60
67
 
61
68
  # 🔑 GitLab токены
62
69
  spectrum token rotate # Пролить GITLAB_PRIVATE_TOKEN в CI variables
@@ -182,35 +189,46 @@ spectrum-cli/
182
189
 
183
190
  Все hotfix-команды сначала проверяют ветку: разрешены только `hotfix/*`, `main`
184
191
  или `master`. `dev`, `develop`, `feature/*` и detached HEAD отклоняются до fetch.
185
- Проверки этапов строже: `start` требует task-backed hotfix-ветку, `deploy` и
186
- `close` — актуальный production commit на `main/master` после merge MR.
192
+ Для `start` подходят `hotfix/<TASK>[-slug]` и production-ветка `main/master`,
193
+ содержащая актуальный origin. Локальные исправления и commits допустимы.
194
+ `deploy` и `close` требуют чистый production commit, совпадающий с origin.
187
195
 
188
196
  ### Изолированный цикл хотфикса
189
197
 
190
198
  Создайте `hotfix/<TASK>` или `hotfix/<TASK>-<slug>` от актуального `origin/main`
191
- или `origin/master`. Включайте только срочное исправление и его patch fragments
199
+ или `origin/master`, либо работайте прямо в актуальной production-ветке
200
+ `main/master`. Включайте только срочное исправление и его patch fragments
192
201
  (`fixed`, `security`, `support` либо другой совместимый patch-тип).
193
202
  `added` и `breaking` требуют обычного релиза. Все команды запускаются из корня
194
203
  репозитория; для `start` нужен Prettier, как для обычной сборки changelog.
195
204
 
196
205
  1. **`spectrum hotfix start`** обновляет сведения об origin и выбирает опубликованный
197
206
  stable-тег, достижимый из production. Цель — этот stable плюс один patch.
198
- Команда формирует верхний датированный раздел `CHANGELOG.md` и удаляет только
207
+ Как и `release start`, команда читает все файлы текущей `.changelog/` независимо
208
+ от того, когда они попали в Git. Проверяет их общими правилами changelog и
209
+ допускает только patch-типы; файлы `added` и `breaking` требуют обычного релиза.
210
+ Формат CHANGELOG.md проверяется только в текущей ветке. История может отличаться
211
+ от stable-тега: её исправления не блокируют хотфикс. Снимок production используется
212
+ для сохранения записей уже подготовленного хотфикса, без строгой проверки старых заголовков.
213
+ Команда формирует верхний раздел `## 🩹 [X.Y.Z] - YYYY-MM-DD` и удаляет только
199
214
  собранные fragments. Она не делает commit, staging, push, merge или tag,
200
215
  не меняет файлы версий и сохраняет посторонние локальные изменения.
201
216
  2. Повторный `start` дополняет тот же неопубликованный раздел, сохраняя дату и
202
- существующие записи без дублей. Без новых fragments подготовленный раздел
217
+ существующие записи без дублей. Дата необязательна: раздел без даты останется
218
+ без даты. Старые заголовки с `🚀`, `🩹`, без значка и с датами через точки
219
+ читаются без переписывания опубликованной истории. Без новых fragments подготовленный раздел
203
220
  остаётся без изменений. Например, от `v6.35.0` первый и повторные запуски
204
221
  готовят `6.35.1`; только после публикации `hotfix/6.35.1` следующий хотфикс получит
205
- `6.35.2`. Унаследованные от production fragments не собираются и не меняются.
222
+ `6.35.2`.
206
223
  3. Проверьте diff, закоммитьте и отправьте исправление по правилам проекта.
207
224
  **В MR из hotfix в production уже должны быть верхний patch-раздел и удаления
208
225
  использованных fragments.** Merge выполняется отдельно после ревью. Если
209
226
  другой хотфикс уже в production, сначала перенесите его изменения в свою
210
227
  ветку и повторите подготовку: пока stable-тега нет, раздел и версия общие.
211
- 4. После merge MR и проверки RC выполните **`spectrum hotfix deploy`** на чистом
228
+ 4. После доставки подготовленного исправления в production через MR или
229
+ разрешённый проектом push и проверки RC выполните **`spectrum hotfix deploy`** на чистом
212
230
  `main/master`, точно совпадающем с origin. Команда проверяет следующий patch,
213
- неизменность опубликованной истории и отсутствие несобранных hotfix fragments,
231
+ отсутствие несобранных hotfix fragments,
214
232
  создаёт `hotfix/X.Y.Z` на проверенном SHA и отправляет **только этот тег**.
215
233
  Повтор после ошибки push допускает существующий локальный тег лишь на том же
216
234
  SHA; опубликованный тег не перемещается. Ветки не сливаются.
@@ -362,9 +380,18 @@ Fragment должен содержать только непустые Markdown-
362
380
  > Совет: Приведите файл к нужному формату командой:
363
381
  >
364
382
  > ```bash
365
- > npx --yes prettier --write CHANGELOG.md
383
+ > spectrum changelog write
366
384
  > ```
367
385
 
386
+ ### `spectrum changelog write`
387
+
388
+ Команда приводит все релизные заголовки `CHANGELOG.md` к виду `## 🚀 [X.Y.Z]`,
389
+ `## 🩹 [X.Y.Z]` или `## [X.Y.Z]` с необязательной датой, затем запускает Prettier.
390
+ Старые даты через точки и текст записей сохраняются. Неверный заголовок, включая
391
+ `Unreleased`, останавливает команду без удаления записей. При ошибке Prettier
392
+ исходный `CHANGELOG.md` восстанавливается. Файлы `.changelog/` команда не меняет;
393
+ commit и push не выполняет.
394
+
368
395
  ## 🎯 Философия
369
396
 
370
397
  - **Простота** - минимум команд, максимум функциональности
package/index.js CHANGED
@@ -8,9 +8,15 @@ const hotfix = require("./src/hotfix");
8
8
  const changelog = require("./src/changelog");
9
9
  const chart = require("./src/chart");
10
10
  const token = require("./src/token");
11
+ const { checkForUpdates, upgrade } = require("./src/update-check");
11
12
  const { version: pkgVersion } = require("./package.json");
12
13
 
13
14
  async function runAction(action, ...args) {
15
+ try {
16
+ await checkForUpdates();
17
+ } catch {
18
+ // A failed update check must not affect the command.
19
+ }
14
20
  try {
15
21
  const result = await action(...args);
16
22
  if (!result) {
@@ -27,6 +33,11 @@ program
27
33
  .description("🚀 Spectrum CLI для процесса разработки")
28
34
  .version(pkgVersion, "-v, --version");
29
35
 
36
+ program
37
+ .command("upgrade")
38
+ .description("Обновить Spectrum CLI через npm install -g spectrum-cli")
39
+ .action(() => runAction(upgrade));
40
+
30
41
  // Команды релиза
31
42
  const releaseCmd = program
32
43
  .command("release")
@@ -50,7 +61,7 @@ releaseCmd
50
61
  const hotfixCmd = program.command("hotfix").description("Изолированный цикл срочного patch-релиза");
51
62
 
52
63
  hotfixCmd.command("start")
53
- .description("Подготовить CHANGELOG на hotfix/* для MR, без commit, push и merge")
64
+ .description("Подготовить 🩹 CHANGELOG на hotfix/* или main/master, без commit, push и merge")
54
65
  .action(() => runAction(hotfix.hotfixStart));
55
66
  hotfixCmd.command("deploy")
56
67
  .description("После merge хотфикса отправить stable-тег с main/master")
@@ -74,6 +85,11 @@ changelogCmd
74
85
  .description("Проверить CHANGELOG.md и changelog fragments")
75
86
  .action(() => runAction(changelog.changelogCheck));
76
87
 
88
+ changelogCmd
89
+ .command("write")
90
+ .description("Привести заголовки CHANGELOG.md к формату релиза и запустить Prettier")
91
+ .action(() => runAction(changelog.changelogWrite));
92
+
77
93
  // Команды chart
78
94
  const chartCmd = program
79
95
  .command("chart")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-cli",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "🚀 Modern CLI for development workflow automation - release management, versioning, and changelog generation",
5
5
  "main": "index.js",
6
6
  "bin": {
package/src/changelog.js CHANGED
@@ -2,11 +2,12 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const readline = require('readline');
5
- const { logSuccess, logError, execSilent, getCurrentBranch, colors } = require('./utils');
5
+ const { logSuccess, logError, execSilent, execCommand, getCurrentBranch, colors } = require('./utils');
6
6
  const { runCommand } = require('./command-executor');
7
7
  const {
8
8
  requireGitRepo,
9
9
  requireFileExists,
10
+ requirePrettierAvailable,
10
11
  requireChangelogFormatted,
11
12
  requireChangelogFragments
12
13
  } = require('./preflight');
@@ -260,6 +261,65 @@ function changelogCheck() {
260
261
  });
261
262
  }
262
263
 
264
+ function normalizeChangelogHeadings(text) {
265
+ const version = '(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)';
266
+ const date = '(\\d{4}-\\d{2}-\\d{2}|\\d{4}\\.\\d{2}\\.\\d{2}|\\d{2}\\.\\d{2}\\.\\d{4})';
267
+ const releaseHeading = new RegExp(`^##[ \\t]*(?:(🚀|🩹)[ \\t]*)?\\[${version}\\](?:[ \\t]+-[ \\t]+${date})?[ \\t]*\\r?$`);
268
+ return text.replace(/^##(?!#)[^\n]*$/gm, (heading) => {
269
+ const match = releaseHeading.exec(heading);
270
+ if (!match) throw new Error(`Некорректный заголовок ${CHANGELOG_FILE}: ${heading.trimEnd()}`);
271
+ const marker = match[1] ? `${match[1]} ` : '';
272
+ const releaseVersion = `${match[2]}.${match[3]}.${match[4]}`;
273
+ const releaseDate = match[5] ? ` - ${match[5]}` : '';
274
+ return `## ${marker}[${releaseVersion}]${releaseDate}${heading.endsWith('\r') ? '\r' : ''}`;
275
+ });
276
+ }
277
+
278
+ function writeCurrentChangelog(context) {
279
+ let original;
280
+ let mayHaveChanged = false;
281
+ try {
282
+ original = fs.readFileSync(CHANGELOG_FILE, 'utf8');
283
+ const normalized = normalizeChangelogHeadings(original);
284
+ if (normalized !== original) {
285
+ mayHaveChanged = true;
286
+ fs.writeFileSync(CHANGELOG_FILE, normalized);
287
+ }
288
+ mayHaveChanged = true;
289
+ if (!execCommand(`${context.prettierRunner} --write ${CHANGELOG_FILE}`) ||
290
+ !execCommand(`${context.prettierRunner} --check ${CHANGELOG_FILE}`)) {
291
+ throw new Error('Prettier не смог отформатировать CHANGELOG.md.');
292
+ }
293
+ const formatted = fs.readFileSync(CHANGELOG_FILE, 'utf8');
294
+ if (normalizeChangelogHeadings(formatted) !== formatted) {
295
+ throw new Error('После Prettier заголовки CHANGELOG.md не соответствуют формату релизов.');
296
+ }
297
+ return true;
298
+ } catch (error) {
299
+ if (mayHaveChanged && original !== undefined) {
300
+ try {
301
+ fs.writeFileSync(CHANGELOG_FILE, original);
302
+ } catch (restoreError) {
303
+ logError('❌', 'Не удалось восстановить %s: %s', CHANGELOG_FILE, restoreError.message);
304
+ }
305
+ }
306
+ logError('❌', 'Не удалось отформатировать %s: %s', CHANGELOG_FILE, error.message);
307
+ return false;
308
+ }
309
+ }
310
+
311
+ function changelogWrite() {
312
+ return runCommand({
313
+ name: 'changelog write',
314
+ checks: [
315
+ { name: 'git-repo', run: requireGitRepo },
316
+ { name: 'changelog-exists', run: () => requireFileExists(CHANGELOG_FILE) },
317
+ { name: 'prettier-available', run: requirePrettierAvailable }
318
+ ],
319
+ steps: [{ name: 'write-changelog', run: writeCurrentChangelog }]
320
+ });
321
+ }
322
+
263
323
  function stripLegacyUnreleasedBlock(changelog) {
264
324
  const unreleasedMatch = /^## \[Unreleased\]\s*$/m.exec(changelog);
265
325
  if (!unreleasedMatch) return changelog;
@@ -270,7 +330,7 @@ function stripLegacyUnreleasedBlock(changelog) {
270
330
  return `${changelog.slice(0, unreleasedMatch.index).trimEnd()}\n\n${changelog.slice(end).trimStart()}`;
271
331
  }
272
332
 
273
- function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10)) {
333
+ function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10), marker = '🚀') {
274
334
  const fragmentsByType = new Map();
275
335
  for (const fragment of fragments) {
276
336
  const entries = fragmentsByType.get(fragment.type) || [];
@@ -278,7 +338,7 @@ function renderReleaseBlock(version, fragments, date = new Date().toISOString().
278
338
  fragmentsByType.set(fragment.type, entries);
279
339
  }
280
340
 
281
- const lines = [`## 🚀 [${version}] - ${date}`];
341
+ const lines = [`## ${marker ? `${marker} ` : ''}[${version}]${date ? ` - ${date}` : ''}`];
282
342
  for (const [type, config] of Object.entries(FRAGMENT_TYPES)) {
283
343
  const entries = fragmentsByType.get(type);
284
344
  if (!entries || entries.length === 0) continue;
@@ -339,6 +399,8 @@ function changelogRemoveFragments(context) {
339
399
  module.exports = {
340
400
  changelogAppend,
341
401
  changelogCheck,
402
+ changelogWrite,
403
+ normalizeChangelogHeadings,
342
404
  extractTaskFromBranch,
343
405
  getGitUser,
344
406
  formatMessage,
@@ -2,21 +2,23 @@ const { FRAGMENT_TYPES, SECTION_TO_TYPE } = require('./changelog-config');
2
2
  const { renderReleaseBlock } = require('./changelog');
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
- const seen = new Set();
8
7
  const releases = headings.map((heading, index) => {
9
- const match = heading[0].match(/^## (?:🚀 )?\[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$/);
10
- if (!match || seen.has(match[1])) {
11
- throw new Error('CHANGELOG.md содержит некорректный или повторный раздел релиза.');
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
+ 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;
13
+ throw new Error(`Некорректный заголовок CHANGELOG.md: ${heading[0]}. Ожидается ## [X.Y.Z], дата необязательна.`);
12
14
  }
13
- seen.add(match[1]);
15
+ // Historical duplicates are preserved; callers validate the pending version.
14
16
  return {
15
- version: match[1], date: match[2],
17
+ version: match[2], date: match[3] || null, marker: match[1] || '',
16
18
  text: text.slice(heading.index, headings[index + 1]?.index ?? text.length),
17
19
  body: text.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? text.length),
18
20
  };
19
- });
21
+ }).filter(Boolean);
20
22
  return { intro: text.slice(0, headings[0]?.index ?? text.length), releases };
21
23
  }
22
24
 
@@ -44,23 +46,22 @@ function releaseEntries(release, patchOnly = false) {
44
46
  return entries;
45
47
  }
46
48
 
47
- function history(document) {
48
- return document.releases.map((release) => release.text.trim()).join('\n\n');
49
+ function normalizeLineEndings(text) {
50
+ return text.replace(/\r\n/g, '\n');
49
51
  }
50
52
 
51
- function pendingDocument(text, stableText, target) {
52
- const document = parseChangelog(text);
53
- const stable = parseChangelog(stableText);
53
+ function pendingDocument(text, target, { validate = true } = {}) {
54
+ const document = parseChangelog(text, { validate });
54
55
  const pending = document.releases[0]?.version === target ? document.releases.shift() : null;
55
- if (history(document) !== history(stable) || document.intro.trim() !== stable.intro.trim()) {
56
- throw new Error('История CHANGELOG.md отличается от stable: допустим только верхний раздел следующего patch.');
56
+ if (document.releases.some((release) => release.version === target)) {
57
+ throw new Error('Новый patch-раздел должен быть единственным и находиться в начале CHANGELOG.md.');
57
58
  }
58
59
  return { document, pending, entries: pending ? releaseEntries(pending, true) : [] };
59
60
  }
60
61
 
61
- function prepareChangelog({ text, stableText, productionText, target, fragments, date }) {
62
- const current = pendingDocument(text, stableText, target);
63
- 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 });
64
65
  const entryKey = (entry) => `${entry.type}:${entry.entries[0]}`;
65
66
  const currentKeys = new Set(current.entries.map(entryKey));
66
67
  if (production.entries.some((entry) => !currentKeys.has(entryKey(entry)))) {
@@ -79,10 +80,10 @@ function prepareChangelog({ text, stableText, productionText, target, fragments,
79
80
  currentKeys.add(entryKey(entry));
80
81
  }
81
82
  }
82
- const block = renderReleaseBlock(target, merged, current.pending?.date || date);
83
+ const block = renderReleaseBlock(target, merged, current.pending ? current.pending.date : date, '🩹');
83
84
  // Preserve the published tail byte-for-byte, including its spacing.
84
85
  const tail = current.document.releases.map((release) => release.text).join('');
85
86
  return `${current.document.intro.trimEnd()}\n\n${block.trim()}\n${tail ? `\n${tail}` : ''}`;
86
87
  }
87
88
 
88
- module.exports = { parseChangelog, releaseEntries, pendingDocument, prepareChangelog };
89
+ module.exports = { parseChangelog, releaseEntries, pendingDocument, prepareChangelog, normalizeLineEndings };
package/src/hotfix.js CHANGED
@@ -1,10 +1,11 @@
1
1
  const fs = require('fs');
2
+ const path = require('path');
2
3
  const { execFileSync } = require('child_process');
3
4
  const preflight = require('./preflight');
4
5
  const { execCommand, logSuccess, logError } = require('./utils');
5
6
  const { upVersion, compareVersions } = require('./version');
6
7
  const { STABLE_TAG_PATTERNS, parseStableTag, stableTagNames } = require('./stable-tags');
7
- const { CHANGELOG_FILE, CHANGELOG_DIR, FRAGMENT_TYPES, getFragmentType } = require('./changelog-config');
8
+ const { CHANGELOG_FILE } = require('./changelog-config');
8
9
  const { parseChangelog, pendingDocument, prepareChangelog } = require('./hotfix-changelog');
9
10
 
10
11
  function git(...args) {
@@ -41,7 +42,7 @@ function readAt(ref, file = CHANGELOG_FILE) {
41
42
 
42
43
  function context(mode) {
43
44
  const root = git('rev-parse', '--show-toplevel').trim();
44
- if (fs.realpathSync(root) !== fs.realpathSync(process.cwd())) {
45
+ if (path.relative(fs.realpathSync(root), fs.realpathSync(process.cwd())) !== '') {
45
46
  throw new Error('Запустите hotfix из корня репозитория.');
46
47
  }
47
48
  const branch = git('branch', '--show-current').trim();
@@ -51,8 +52,8 @@ function context(mode) {
51
52
  if (mode !== 'start' && !['main', 'master'].includes(branch)) {
52
53
  throw new Error('hotfix deploy/close выполняются на main/master после merge хотфикса.');
53
54
  }
54
- if (mode === 'start' && !/^hotfix\/[A-Z]+-[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(branch)) {
55
- throw new Error('hotfix start выполняется только на hotfix/<TASK>[-slug].');
55
+ if (mode === 'start' && !['main', 'master'].includes(branch) && !/^hotfix\/[A-Z]+-[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/.test(branch)) {
56
+ throw new Error('hotfix start выполняется на hotfix/<TASK>[-slug], main или master.');
56
57
  }
57
58
  if (mode !== 'start' && git('status', '--porcelain').trim()) {
58
59
  throw new Error('Для deploy/close требуется чистое рабочее дерево.');
@@ -64,6 +65,9 @@ function context(mode) {
64
65
  const mainRef = `refs/remotes/origin/${mainBranch}`;
65
66
  const mainSha = git('rev-parse', mainRef).trim();
66
67
  const head = git('rev-parse', 'HEAD').trim();
68
+ if (['main', 'master'].includes(branch) && branch !== mainBranch) {
69
+ throw new Error(`Production-ветка origin — ${mainBranch}. Переключитесь на неё или на hotfix/<TASK>[-slug].`);
70
+ }
67
71
  if (mode === 'start') {
68
72
  if (!ancestor(mainSha, head)) throw new Error('Сначала обновите hotfix из актуальной production-ветки.');
69
73
  } else if (branch !== mainBranch || head !== mainSha) {
@@ -88,10 +92,9 @@ function context(mode) {
88
92
  throw new Error(`Конфликт stable-тегов версии ${stable}: разные commits.`);
89
93
  }
90
94
  }
91
- const stableText = readAt(`refs/tags/${stableTag}`);
92
95
  const productionText = readAt(mainSha);
93
96
  const target = upVersion(stable, 'patch');
94
- return { mainBranch, mainSha, head, remoteTags, stable, stableTag, stableText, productionText, target };
97
+ return { mainBranch, mainSha, head, remoteTags, stable, stableTag, productionText, target };
95
98
  }
96
99
 
97
100
  function assertTagAvailable(ctx, allowMatchingLocal = false) {
@@ -111,28 +114,14 @@ function assertOriginUnchanged(ctx) {
111
114
  }
112
115
  }
113
116
 
114
- function newFragments(baseRef) {
115
- const inherited = git('ls-tree', '-r', '-z', '--name-only', baseRef, '--', `${CHANGELOG_DIR}/`)
116
- .split('\0').filter(Boolean);
117
- for (const file of inherited) {
118
- if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== readAt(baseRef, file)) {
119
- throw new Error(`Нельзя изменять унаследованный fragment ${file} в хотфиксе.`);
120
- }
121
- }
122
- if (!fs.existsSync(CHANGELOG_DIR)) return [];
123
- return fs.readdirSync(CHANGELOG_DIR).sort().filter((name) => !name.startsWith('.'))
124
- .map((name) => `${CHANGELOG_DIR}/${name}`).filter((file) => !inherited.includes(file))
125
- .map((filePath) => {
126
- const type = getFragmentType(filePath);
127
- if (!type || !fs.lstatSync(filePath).isFile()) throw new Error(`Некорректный fragment ${filePath}.`);
128
- const content = fs.readFileSync(filePath, 'utf8');
129
- const entries = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
130
- if (!entries.length || entries.some((line) => !line.startsWith('- ') || line.length > 120)) {
131
- throw new Error(`Пустой или неверно оформленный fragment ${filePath}: записи «- …», до 120 символов.`);
132
- }
133
- if (FRAGMENT_TYPES[type].bump !== 'patch') throw new Error(`Хотфикс не допускает minor/major: ${filePath}.`);
134
- return { filePath, content, type, ...FRAGMENT_TYPES[type], entries };
135
- });
117
+ function currentFragments() {
118
+ if (!preflight.findChangelogFragmentFiles().length) return [];
119
+ const result = preflight.requireChangelogFragments();
120
+ if (!result.ok) throw new Error(result.reason);
121
+ return result.data.changelogFragments.map((fragment) => {
122
+ if (fragment.bump !== 'patch') throw new Error(`Хотфикс не допускает minor/major: ${fragment.filePath}.`);
123
+ return { ...fragment, content: fs.readFileSync(fragment.filePath, 'utf8') };
124
+ });
136
125
  }
137
126
 
138
127
  function formatChangelog() {
@@ -156,7 +145,7 @@ function hotfixStart() {
156
145
  return run(() => {
157
146
  const ctx = context('start');
158
147
  assertTagAvailable(ctx);
159
- const fragments = newFragments(ctx.mainSha);
148
+ const fragments = currentFragments();
160
149
  const original = fs.readFileSync(CHANGELOG_FILE, 'utf8');
161
150
  const updated = prepareChangelog({ text: original, ...ctx, fragments });
162
151
  assertOriginUnchanged(ctx);
@@ -164,7 +153,7 @@ function hotfixStart() {
164
153
  try {
165
154
  fs.writeFileSync(CHANGELOG_FILE, updated);
166
155
  formatChangelog();
167
- // Formatting must not rewrite published history or lose merged notes.
156
+ // Validate the formatted current file and keep all merged hotfix notes.
168
157
  prepareChangelog({ text: fs.readFileSync(CHANGELOG_FILE, 'utf8'), ...ctx, fragments: [] });
169
158
  for (const fragment of fragments) fs.unlinkSync(fragment.filePath);
170
159
  } catch (error) {
@@ -180,9 +169,9 @@ function hotfixStart() {
180
169
  function hotfixDeploy() {
181
170
  return run(() => {
182
171
  const ctx = context('deploy');
183
- const prepared = pendingDocument(ctx.productionText, ctx.stableText, ctx.target);
172
+ const prepared = pendingDocument(ctx.productionText, ctx.target);
184
173
  if (!prepared.pending) throw new Error(`В CHANGELOG.md нужен верхний подготовленный раздел ${ctx.target}.`);
185
- if (newFragments(`refs/tags/${ctx.stableTag}`).length) throw new Error('В production остались несобранные hotfix fragments.');
174
+ if (currentFragments().length) throw new Error('В production остались несобранные hotfix fragments.');
186
175
  assertTagAvailable(ctx, true);
187
176
  assertOriginUnchanged(ctx);
188
177
  const tag = `hotfix/${ctx.target}`;
@@ -2,7 +2,7 @@ const fs = require('fs');
2
2
  const { execSilent, logError, logSuccess } = require('./utils');
3
3
  const { compareVersions } = require('./version');
4
4
  const { CHANGELOG_FILE, CHANGELOG_DIR } = require('./changelog-config');
5
- const { parseChangelog, releaseEntries } = require('./hotfix-changelog');
5
+ const { parseChangelog, releaseEntries, normalizeLineEndings } = require('./hotfix-changelog');
6
6
  const { renderReleaseBlock, stripLegacyUnreleasedBlock } = require('./changelog');
7
7
  const { requireChangelogFragments } = require('./preflight');
8
8
 
@@ -23,7 +23,7 @@ function resolveReleaseState(localText, productionText, stableVersion) {
23
23
  if (local.open && production.open && local.open.version !== production.open.version) {
24
24
  throw new Error('Открытые версии в dev и production различаются. Сначала согласуйте CHANGELOG.md.');
25
25
  }
26
- const history = ({ document }) => document.releases.map((release) => release.text.trim()).join('\n\n');
26
+ const history = ({ document }) => document.releases.map((release) => normalizeLineEndings(release.text).trim()).join('\n\n');
27
27
  if (history(local) !== history(production)) {
28
28
  throw new Error('Опубликованная история CHANGELOG.md изменена относительно production.');
29
29
  }
@@ -65,7 +65,7 @@ function appendOpenRelease(context) {
65
65
  keys.add(key);
66
66
  }
67
67
  }
68
- const block = renderReleaseBlock(open.version, merged, open.date);
68
+ const block = renderReleaseBlock(open.version, merged, open.date, open.marker);
69
69
  const tail = local.document.releases.map((release) => release.text).join('');
70
70
  const updated = `${local.document.intro.trimEnd()}\n\n${block.trim()}\n${tail ? `\n${tail}` : ''}`;
71
71
  fs.writeFileSync(CHANGELOG_FILE, updated);
@@ -0,0 +1,45 @@
1
+ const childProcess = require('child_process');
2
+ const { name, version: currentVersion } = require('../package.json');
3
+ const { compareVersions } = require('./version');
4
+
5
+ const UPDATE_CHECK_TIMEOUT_MS = 200;
6
+
7
+ async function checkForUpdates() {
8
+ const controller = new AbortController();
9
+ let timer;
10
+ try {
11
+ const timeout = new Promise((resolve) => {
12
+ timer = setTimeout(() => {
13
+ controller.abort();
14
+ resolve(null);
15
+ }, UPDATE_CHECK_TIMEOUT_MS);
16
+ });
17
+ const request = (async () => {
18
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
19
+ signal: controller.signal,
20
+ headers: { accept: 'application/json' }
21
+ });
22
+ if (!response.ok) return null;
23
+ return (await response.json()).version;
24
+ })();
25
+ const latestVersion = await Promise.race([request, timeout]);
26
+ if (compareVersions(latestVersion, currentVersion) > 0) {
27
+ console.log(`⚠️ Доступна новая версия Spectrum CLI: сейчас ${currentVersion}, доступна ${latestVersion}.\nОбновить: spectrum upgrade`);
28
+ }
29
+ } catch {
30
+ // Network and registry errors must never block a command.
31
+ } finally {
32
+ clearTimeout(timer);
33
+ }
34
+ }
35
+
36
+ function upgrade() {
37
+ const result = childProcess.spawnSync('npm', ['install', '-g', name], {
38
+ stdio: 'inherit',
39
+ shell: process.platform === 'win32'
40
+ });
41
+ if (result.error) throw result.error;
42
+ return result.status === 0;
43
+ }
44
+
45
+ module.exports = { checkForUpdates, upgrade, UPDATE_CHECK_TIMEOUT_MS };