spectrum-cli 3.1.0 → 3.1.1

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,14 @@ 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.1.1] - 2026-09-24
9
+
10
+ ### 🪲 Fixed
11
+
12
+ - Хотфиксы создают раздел с 🩹 и поддерживают запуск подготовки из main/master.
13
+ - Дата раздела changelog необязательна; опубликованная история в прежнем формате сохраняется.
14
+ - Исправлены проверка корня репозитория и сравнение CRLF/LF в release/hotfix на Windows.
15
+
8
16
  ## 🚀 [3.1.0] - 2026-09-24
9
17
 
10
18
  ### 🆕 Added
package/README.md CHANGED
@@ -42,7 +42,7 @@ spectrum release deploy # Создать стабильный те
42
42
  spectrum release close # Свести stable main/master в dev
43
43
 
44
44
  # Срочный patch без переноса незавершённого dev в production
45
- spectrum hotfix start # Только локальная подготовка CHANGELOG на hotfix/*
45
+ spectrum hotfix start # Локальная подготовка 🩹 CHANGELOG на hotfix/* или main/master
46
46
  spectrum hotfix deploy # После merge MR: stable-тег на актуальном main/master
47
47
  spectrum hotfix close # После stable pipeline: main/master → dev
48
48
 
@@ -182,24 +182,28 @@ spectrum-cli/
182
182
 
183
183
  Все hotfix-команды сначала проверяют ветку: разрешены только `hotfix/*`, `main`
184
184
  или `master`. `dev`, `develop`, `feature/*` и detached HEAD отклоняются до fetch.
185
- Проверки этапов строже: `start` требует task-backed hotfix-ветку, `deploy` и
186
- `close` — актуальный production commit на `main/master` после merge MR.
185
+ Для `start` подходят `hotfix/<TASK>[-slug]` и production-ветка `main/master`,
186
+ содержащая актуальный origin. Локальные исправления и commits допустимы.
187
+ `deploy` и `close` требуют чистый production commit, совпадающий с origin.
187
188
 
188
189
  ### Изолированный цикл хотфикса
189
190
 
190
191
  Создайте `hotfix/<TASK>` или `hotfix/<TASK>-<slug>` от актуального `origin/main`
191
- или `origin/master`. Включайте только срочное исправление и его patch fragments
192
+ или `origin/master`, либо работайте прямо в актуальной production-ветке
193
+ `main/master`. Включайте только срочное исправление и его patch fragments
192
194
  (`fixed`, `security`, `support` либо другой совместимый patch-тип).
193
195
  `added` и `breaking` требуют обычного релиза. Все команды запускаются из корня
194
196
  репозитория; для `start` нужен Prettier, как для обычной сборки changelog.
195
197
 
196
198
  1. **`spectrum hotfix start`** обновляет сведения об origin и выбирает опубликованный
197
199
  stable-тег, достижимый из production. Цель — этот stable плюс один patch.
198
- Команда формирует верхний датированный раздел `CHANGELOG.md` и удаляет только
200
+ Команда формирует верхний раздел `## 🩹 [X.Y.Z] - YYYY-MM-DD` и удаляет только
199
201
  собранные fragments. Она не делает commit, staging, push, merge или tag,
200
202
  не меняет файлы версий и сохраняет посторонние локальные изменения.
201
203
  2. Повторный `start` дополняет тот же неопубликованный раздел, сохраняя дату и
202
- существующие записи без дублей. Без новых fragments подготовленный раздел
204
+ существующие записи без дублей. Дата необязательна: раздел без даты останется
205
+ без даты. Старые заголовки с `🚀`, `🩹`, без значка и с датами через точки
206
+ читаются без переписывания опубликованной истории. Без новых fragments подготовленный раздел
203
207
  остаётся без изменений. Например, от `v6.35.0` первый и повторные запуски
204
208
  готовят `6.35.1`; только после публикации `hotfix/6.35.1` следующий хотфикс получит
205
209
  `6.35.2`. Унаследованные от production fragments не собираются и не меняются.
@@ -208,7 +212,8 @@ spectrum-cli/
208
212
  использованных fragments.** Merge выполняется отдельно после ревью. Если
209
213
  другой хотфикс уже в production, сначала перенесите его изменения в свою
210
214
  ветку и повторите подготовку: пока stable-тега нет, раздел и версия общие.
211
- 4. После merge MR и проверки RC выполните **`spectrum hotfix deploy`** на чистом
215
+ 4. После доставки подготовленного исправления в production через MR или
216
+ разрешённый проектом push и проверки RC выполните **`spectrum hotfix deploy`** на чистом
212
217
  `main/master`, точно совпадающем с origin. Команда проверяет следующий patch,
213
218
  неизменность опубликованной истории и отсутствие несобранных hotfix fragments,
214
219
  создаёт `hotfix/X.Y.Z` на проверенном SHA и отправляет **только этот тег**.
package/index.js CHANGED
@@ -50,7 +50,7 @@ releaseCmd
50
50
  const hotfixCmd = program.command("hotfix").description("Изолированный цикл срочного patch-релиза");
51
51
 
52
52
  hotfixCmd.command("start")
53
- .description("Подготовить CHANGELOG на hotfix/* для MR, без commit, push и merge")
53
+ .description("Подготовить 🩹 CHANGELOG на hotfix/* или main/master, без commit, push и merge")
54
54
  .action(() => runAction(hotfix.hotfixStart));
55
55
  hotfixCmd.command("deploy")
56
56
  .description("После merge хотфикса отправить stable-тег с main/master")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-cli",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
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
@@ -270,7 +270,7 @@ function stripLegacyUnreleasedBlock(changelog) {
270
270
  return `${changelog.slice(0, unreleasedMatch.index).trimEnd()}\n\n${changelog.slice(end).trimStart()}`;
271
271
  }
272
272
 
273
- function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10)) {
273
+ function renderReleaseBlock(version, fragments, date = new Date().toISOString().slice(0, 10), marker = '🚀') {
274
274
  const fragmentsByType = new Map();
275
275
  for (const fragment of fragments) {
276
276
  const entries = fragmentsByType.get(fragment.type) || [];
@@ -278,7 +278,7 @@ function renderReleaseBlock(version, fragments, date = new Date().toISOString().
278
278
  fragmentsByType.set(fragment.type, entries);
279
279
  }
280
280
 
281
- const lines = [`## 🚀 [${version}] - ${date}`];
281
+ const lines = [`## ${marker ? `${marker} ` : ''}[${version}]${date ? ` - ${date}` : ''}`];
282
282
  for (const [type, config] of Object.entries(FRAGMENT_TYPES)) {
283
283
  const entries = fragmentsByType.get(type);
284
284
  if (!entries || entries.length === 0) continue;
@@ -4,15 +4,14 @@ const { renderReleaseBlock } = require('./changelog');
4
4
  // Parse release boundaries without rewriting any published text.
5
5
  function parseChangelog(text) {
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
+ throw new Error(`Некорректный заголовок CHANGELOG.md: ${heading[0]}. Ожидается ## [X.Y.Z], дата необязательна.`);
12
11
  }
13
- seen.add(match[1]);
12
+ // Historical duplicates are preserved; callers validate the pending version and immutable history.
14
13
  return {
15
- version: match[1], date: match[2],
14
+ version: match[2], date: match[3] || null, marker: match[1] || '',
16
15
  text: text.slice(heading.index, headings[index + 1]?.index ?? text.length),
17
16
  body: text.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? text.length),
18
17
  };
@@ -44,15 +43,22 @@ function releaseEntries(release, patchOnly = false) {
44
43
  return entries;
45
44
  }
46
45
 
46
+ function normalizeLineEndings(text) {
47
+ return text.replace(/\r\n/g, '\n');
48
+ }
49
+
47
50
  function history(document) {
48
- return document.releases.map((release) => release.text.trim()).join('\n\n');
51
+ return document.releases.map((release) => normalizeLineEndings(release.text).trim()).join('\n\n');
49
52
  }
50
53
 
51
54
  function pendingDocument(text, stableText, target) {
52
55
  const document = parseChangelog(text);
53
56
  const stable = parseChangelog(stableText);
54
57
  const pending = document.releases[0]?.version === target ? document.releases.shift() : null;
55
- if (history(document) !== history(stable) || document.intro.trim() !== stable.intro.trim()) {
58
+ if (document.releases.some((release) => release.version === target)) {
59
+ throw new Error('Новый patch-раздел должен быть единственным и находиться в начале CHANGELOG.md.');
60
+ }
61
+ if (history(document) !== history(stable) || normalizeLineEndings(document.intro).trim() !== normalizeLineEndings(stable.intro).trim()) {
56
62
  throw new Error('История CHANGELOG.md отличается от stable: допустим только верхний раздел следующего patch.');
57
63
  }
58
64
  return { document, pending, entries: pending ? releaseEntries(pending, true) : [] };
@@ -79,10 +85,10 @@ function prepareChangelog({ text, stableText, productionText, target, fragments,
79
85
  currentKeys.add(entryKey(entry));
80
86
  }
81
87
  }
82
- const block = renderReleaseBlock(target, merged, current.pending?.date || date);
88
+ const block = renderReleaseBlock(target, merged, current.pending ? current.pending.date : date, '🩹');
83
89
  // Preserve the published tail byte-for-byte, including its spacing.
84
90
  const tail = current.document.releases.map((release) => release.text).join('');
85
91
  return `${current.document.intro.trimEnd()}\n\n${block.trim()}\n${tail ? `\n${tail}` : ''}`;
86
92
  }
87
93
 
88
- module.exports = { parseChangelog, releaseEntries, pendingDocument, prepareChangelog };
94
+ module.exports = { parseChangelog, releaseEntries, pendingDocument, prepareChangelog, normalizeLineEndings };
package/src/hotfix.js CHANGED
@@ -1,11 +1,12 @@
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
8
  const { CHANGELOG_FILE, CHANGELOG_DIR, FRAGMENT_TYPES, getFragmentType } = require('./changelog-config');
8
- const { parseChangelog, pendingDocument, prepareChangelog } = require('./hotfix-changelog');
9
+ const { parseChangelog, pendingDocument, prepareChangelog, normalizeLineEndings } = require('./hotfix-changelog');
9
10
 
10
11
  function git(...args) {
11
12
  try {
@@ -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) {
@@ -115,7 +119,7 @@ function newFragments(baseRef) {
115
119
  const inherited = git('ls-tree', '-r', '-z', '--name-only', baseRef, '--', `${CHANGELOG_DIR}/`)
116
120
  .split('\0').filter(Boolean);
117
121
  for (const file of inherited) {
118
- if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== readAt(baseRef, file)) {
122
+ if (!fs.existsSync(file) || normalizeLineEndings(fs.readFileSync(file, 'utf8')) !== normalizeLineEndings(readAt(baseRef, file))) {
119
123
  throw new Error(`Нельзя изменять унаследованный fragment ${file} в хотфиксе.`);
120
124
  }
121
125
  }
@@ -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);