spectrum-cli 3.1.1 → 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,18 @@ 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
+
8
20
  ## 🚀 [3.1.1] - 2026-09-24
9
21
 
10
22
  ### 🪲 Fixed
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
@@ -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
@@ -197,6 +204,12 @@ spectrum-cli/
197
204
 
198
205
  1. **`spectrum hotfix start`** обновляет сведения об origin и выбирает опубликованный
199
206
  stable-тег, достижимый из production. Цель — этот stable плюс один patch.
207
+ Как и `release start`, команда читает все файлы текущей `.changelog/` независимо
208
+ от того, когда они попали в Git. Проверяет их общими правилами changelog и
209
+ допускает только patch-типы; файлы `added` и `breaking` требуют обычного релиза.
210
+ Формат CHANGELOG.md проверяется только в текущей ветке. История может отличаться
211
+ от stable-тега: её исправления не блокируют хотфикс. Снимок production используется
212
+ для сохранения записей уже подготовленного хотфикса, без строгой проверки старых заголовков.
200
213
  Команда формирует верхний раздел `## 🩹 [X.Y.Z] - YYYY-MM-DD` и удаляет только
201
214
  собранные fragments. Она не делает commit, staging, push, merge или tag,
202
215
  не меняет файлы версий и сохраняет посторонние локальные изменения.
@@ -206,7 +219,7 @@ spectrum-cli/
206
219
  читаются без переписывания опубликованной истории. Без новых fragments подготовленный раздел
207
220
  остаётся без изменений. Например, от `v6.35.0` первый и повторные запуски
208
221
  готовят `6.35.1`; только после публикации `hotfix/6.35.1` следующий хотфикс получит
209
- `6.35.2`. Унаследованные от production fragments не собираются и не меняются.
222
+ `6.35.2`.
210
223
  3. Проверьте diff, закоммитьте и отправьте исправление по правилам проекта.
211
224
  **В MR из hotfix в production уже должны быть верхний patch-раздел и удаления
212
225
  использованных fragments.** Merge выполняется отдельно после ревью. Если
@@ -215,7 +228,7 @@ spectrum-cli/
215
228
  4. После доставки подготовленного исправления в production через MR или
216
229
  разрешённый проектом push и проверки RC выполните **`spectrum hotfix deploy`** на чистом
217
230
  `main/master`, точно совпадающем с origin. Команда проверяет следующий patch,
218
- неизменность опубликованной истории и отсутствие несобранных hotfix fragments,
231
+ отсутствие несобранных hotfix fragments,
219
232
  создаёт `hotfix/X.Y.Z` на проверенном SHA и отправляет **только этот тег**.
220
233
  Повтор после ошибки push допускает существующий локальный тег лишь на том же
221
234
  SHA; опубликованный тег не перемещается. Ветки не сливаются.
@@ -367,9 +380,18 @@ Fragment должен содержать только непустые Markdown-
367
380
  > Совет: Приведите файл к нужному формату командой:
368
381
  >
369
382
  > ```bash
370
- > npx --yes prettier --write CHANGELOG.md
383
+ > spectrum changelog write
371
384
  > ```
372
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
+
373
395
  ## 🎯 Философия
374
396
 
375
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")
@@ -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.1",
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;
@@ -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,20 +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
7
  const releases = headings.map((heading, index) => {
8
8
  const match = heading[0].match(/^##[ \t]+(?:(🚀|🩹)[ \t]*)?\[(\d+\.\d+\.\d+)\](?:[ \t]+-[ \t]+(\d{4}-\d{2}-\d{2}|\d{4}\.\d{2}\.\d{2}|\d{2}\.\d{2}\.\d{4}))?[ \t]*\r?$/);
9
9
  if (!match) {
10
+ // Production snapshots supply pending notes, not files to validate. Skip
11
+ // historical non-release sections (including their bodies).
12
+ if (!validate) return null;
10
13
  throw new Error(`Некорректный заголовок CHANGELOG.md: ${heading[0]}. Ожидается ## [X.Y.Z], дата необязательна.`);
11
14
  }
12
- // Historical duplicates are preserved; callers validate the pending version and immutable history.
15
+ // Historical duplicates are preserved; callers validate the pending version.
13
16
  return {
14
17
  version: match[2], date: match[3] || null, marker: match[1] || '',
15
18
  text: text.slice(heading.index, headings[index + 1]?.index ?? text.length),
16
19
  body: text.slice(heading.index + heading[0].length, headings[index + 1]?.index ?? text.length),
17
20
  };
18
- });
21
+ }).filter(Boolean);
19
22
  return { intro: text.slice(0, headings[0]?.index ?? text.length), releases };
20
23
  }
21
24
 
@@ -47,26 +50,18 @@ function normalizeLineEndings(text) {
47
50
  return text.replace(/\r\n/g, '\n');
48
51
  }
49
52
 
50
- function history(document) {
51
- return document.releases.map((release) => normalizeLineEndings(release.text).trim()).join('\n\n');
52
- }
53
-
54
- function pendingDocument(text, stableText, target) {
55
- const document = parseChangelog(text);
56
- const stable = parseChangelog(stableText);
53
+ function pendingDocument(text, target, { validate = true } = {}) {
54
+ const document = parseChangelog(text, { validate });
57
55
  const pending = document.releases[0]?.version === target ? document.releases.shift() : null;
58
56
  if (document.releases.some((release) => release.version === target)) {
59
57
  throw new Error('Новый patch-раздел должен быть единственным и находиться в начале CHANGELOG.md.');
60
58
  }
61
- if (history(document) !== history(stable) || normalizeLineEndings(document.intro).trim() !== normalizeLineEndings(stable.intro).trim()) {
62
- throw new Error('История CHANGELOG.md отличается от stable: допустим только верхний раздел следующего patch.');
63
- }
64
59
  return { document, pending, entries: pending ? releaseEntries(pending, true) : [] };
65
60
  }
66
61
 
67
- function prepareChangelog({ text, stableText, productionText, target, fragments, date }) {
68
- const current = pendingDocument(text, stableText, target);
69
- const production = pendingDocument(productionText, stableText, target);
62
+ function prepareChangelog({ text, productionText, target, fragments, date }) {
63
+ const current = pendingDocument(text, target);
64
+ const production = pendingDocument(productionText, target, { validate: false });
70
65
  const entryKey = (entry) => `${entry.type}:${entry.entries[0]}`;
71
66
  const currentKeys = new Set(current.entries.map(entryKey));
72
67
  if (production.entries.some((entry) => !currentKeys.has(entryKey(entry)))) {
package/src/hotfix.js CHANGED
@@ -5,8 +5,8 @@ const preflight = require('./preflight');
5
5
  const { execCommand, logSuccess, logError } = require('./utils');
6
6
  const { upVersion, compareVersions } = require('./version');
7
7
  const { STABLE_TAG_PATTERNS, parseStableTag, stableTagNames } = require('./stable-tags');
8
- const { CHANGELOG_FILE, CHANGELOG_DIR, FRAGMENT_TYPES, getFragmentType } = require('./changelog-config');
9
- const { parseChangelog, pendingDocument, prepareChangelog, normalizeLineEndings } = require('./hotfix-changelog');
8
+ const { CHANGELOG_FILE } = require('./changelog-config');
9
+ const { parseChangelog, pendingDocument, prepareChangelog } = require('./hotfix-changelog');
10
10
 
11
11
  function git(...args) {
12
12
  try {
@@ -92,10 +92,9 @@ function context(mode) {
92
92
  throw new Error(`Конфликт stable-тегов версии ${stable}: разные commits.`);
93
93
  }
94
94
  }
95
- const stableText = readAt(`refs/tags/${stableTag}`);
96
95
  const productionText = readAt(mainSha);
97
96
  const target = upVersion(stable, 'patch');
98
- return { mainBranch, mainSha, head, remoteTags, stable, stableTag, stableText, productionText, target };
97
+ return { mainBranch, mainSha, head, remoteTags, stable, stableTag, productionText, target };
99
98
  }
100
99
 
101
100
  function assertTagAvailable(ctx, allowMatchingLocal = false) {
@@ -115,28 +114,14 @@ function assertOriginUnchanged(ctx) {
115
114
  }
116
115
  }
117
116
 
118
- function newFragments(baseRef) {
119
- const inherited = git('ls-tree', '-r', '-z', '--name-only', baseRef, '--', `${CHANGELOG_DIR}/`)
120
- .split('\0').filter(Boolean);
121
- for (const file of inherited) {
122
- if (!fs.existsSync(file) || normalizeLineEndings(fs.readFileSync(file, 'utf8')) !== normalizeLineEndings(readAt(baseRef, file))) {
123
- throw new Error(`Нельзя изменять унаследованный fragment ${file} в хотфиксе.`);
124
- }
125
- }
126
- if (!fs.existsSync(CHANGELOG_DIR)) return [];
127
- return fs.readdirSync(CHANGELOG_DIR).sort().filter((name) => !name.startsWith('.'))
128
- .map((name) => `${CHANGELOG_DIR}/${name}`).filter((file) => !inherited.includes(file))
129
- .map((filePath) => {
130
- const type = getFragmentType(filePath);
131
- if (!type || !fs.lstatSync(filePath).isFile()) throw new Error(`Некорректный fragment ${filePath}.`);
132
- const content = fs.readFileSync(filePath, 'utf8');
133
- const entries = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
134
- if (!entries.length || entries.some((line) => !line.startsWith('- ') || line.length > 120)) {
135
- throw new Error(`Пустой или неверно оформленный fragment ${filePath}: записи «- …», до 120 символов.`);
136
- }
137
- if (FRAGMENT_TYPES[type].bump !== 'patch') throw new Error(`Хотфикс не допускает minor/major: ${filePath}.`);
138
- return { filePath, content, type, ...FRAGMENT_TYPES[type], entries };
139
- });
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
+ });
140
125
  }
141
126
 
142
127
  function formatChangelog() {
@@ -160,7 +145,7 @@ function hotfixStart() {
160
145
  return run(() => {
161
146
  const ctx = context('start');
162
147
  assertTagAvailable(ctx);
163
- const fragments = newFragments(ctx.mainSha);
148
+ const fragments = currentFragments();
164
149
  const original = fs.readFileSync(CHANGELOG_FILE, 'utf8');
165
150
  const updated = prepareChangelog({ text: original, ...ctx, fragments });
166
151
  assertOriginUnchanged(ctx);
@@ -168,7 +153,7 @@ function hotfixStart() {
168
153
  try {
169
154
  fs.writeFileSync(CHANGELOG_FILE, updated);
170
155
  formatChangelog();
171
- // Formatting must not rewrite published history or lose merged notes.
156
+ // Validate the formatted current file and keep all merged hotfix notes.
172
157
  prepareChangelog({ text: fs.readFileSync(CHANGELOG_FILE, 'utf8'), ...ctx, fragments: [] });
173
158
  for (const fragment of fragments) fs.unlinkSync(fragment.filePath);
174
159
  } catch (error) {
@@ -184,9 +169,9 @@ function hotfixStart() {
184
169
  function hotfixDeploy() {
185
170
  return run(() => {
186
171
  const ctx = context('deploy');
187
- const prepared = pendingDocument(ctx.productionText, ctx.stableText, ctx.target);
172
+ const prepared = pendingDocument(ctx.productionText, ctx.target);
188
173
  if (!prepared.pending) throw new Error(`В CHANGELOG.md нужен верхний подготовленный раздел ${ctx.target}.`);
189
- if (newFragments(`refs/tags/${ctx.stableTag}`).length) throw new Error('В production остались несобранные hotfix fragments.');
174
+ if (currentFragments().length) throw new Error('В production остались несобранные hotfix fragments.');
190
175
  assertTagAvailable(ctx, true);
191
176
  assertOriginUnchanged(ctx);
192
177
  const tag = `hotfix/${ctx.target}`;
@@ -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 };