spectrum-cli 2.0.0 → 2.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
@@ -39,6 +39,18 @@ _Список правок для обеспечения безопасност
39
39
 
40
40
  _Список правок для обеспечения технической поддержки._
41
41
 
42
+ ## 🚀 [2.1.1] - 2026-08-17
43
+
44
+ ### 🛠 Changed
45
+
46
+ - Команда `spectrum token rotate` больше не создает PAT. Нужно передать готовый `GITLAB_PRIVATE_TOKEN`, команда только проливает его в CI/CD variables.
47
+
48
+ ## 🚀 [2.1.0] - 2026-08-17
49
+
50
+ ### 🆕 Added
51
+
52
+ - Добавлена команда `spectrum token rotate` для ротации `GITLAB_PRIVATE_TOKEN` и пролива в CI/CD variables групп и проектов.
53
+
42
54
  ## 🚀 [1.2.2] - 2026-03-10
43
55
 
44
56
  ### 🛠 Changed
package/README.md CHANGED
@@ -53,6 +53,9 @@ spectrum chart verify ~/repo # Сравнить ingress paths AS IS vs TO BE д
53
53
 
54
54
  # 📝 Управление changelog
55
55
  spectrum changelog append "Сообщение" # Добавить запись в CHANGELOG.md
56
+
57
+ # 🔑 GitLab токены
58
+ spectrum token rotate # Пролить GITLAB_PRIVATE_TOKEN в CI variables
56
59
  ```
57
60
 
58
61
  ### Справка по командам:
@@ -69,6 +72,9 @@ spectrum changelog --help
69
72
 
70
73
  # Справка по chart
71
74
  spectrum chart --help
75
+
76
+ # Справка по токенам
77
+ spectrum token --help
72
78
  ```
73
79
 
74
80
  ## ✨ Особенности
@@ -96,6 +102,7 @@ spectrum-cli/
96
102
  │ ├── version.js # 📦 Управление версиями SemVer
97
103
  │ ├── changelog.js # 📋 Работа с CHANGELOG.md
98
104
  │ ├── chart.js # 📈 Создание и push chart тегов
105
+ │ ├── token.js # 🔑 Ротация GitLab PAT и CI variables
99
106
  │ ├── development.js # ⚡ Dev команды (внутренние)
100
107
  │ └── release.js # 🚀 Release процесс
101
108
  ├── package.json # 📦 Конфигурация проекта
@@ -118,6 +125,7 @@ spectrum-cli/
118
125
  | `spectrum chart create` | Создать и запушить chart тег |
119
126
  | `spectrum chart deploy` | Обновить chart версию в helmrelease |
120
127
  | `spectrum chart verify` | Проверить ingress paths chart |
128
+ | `spectrum token rotate` | Пролить GITLAB_PRIVATE_TOKEN в CI |
121
129
 
122
130
  ### 🛡️ Preflight-проверки по командам
123
131
 
@@ -131,6 +139,7 @@ spectrum-cli/
131
139
  - `spectrum chart create <version>`: `git-repo`, `clean-working-tree`, `on-main-branch`, `valid-semver` (переданный `<version>` — semver), `single-chart` (ровно один `charts/<chart-name>/Chart.yaml`), `tag-missing` (тега `chart-<name>-<version>` нет локально и на `origin`).
132
140
  - `spectrum chart deploy`: `git-repo`, `clean-working-tree`, `on-main-branch` (текущая ветка `main`), `remote-origin` (настроен `origin`), `remote-reachable` (доступен `origin`), `single-chart`, `helmrelease-files` (найдены `helmrelease.yaml`).
133
141
  - `spectrum chart verify <source_path>`: `git-repo`, `single-values-yaml` (ровно один `charts/**/values.yaml`), `values-ingress-sections` (есть `ingress.paths.api/pages/assets`), `source-path-directory`, `next-project`, `build-command-support`.
142
+ - `spectrum token rotate`: `load-config` (есть валидный `~/.config/spectrum-cli/config.yaml`), `ask-private-token` (`GITLAB_PRIVATE_TOKEN` только в памяти), `check-access` (все группы и проекты из конфига доступны).
134
143
 
135
144
  ## 🔄 Workflow релиза
136
145
 
@@ -185,6 +194,31 @@ spectrum-cli/
185
194
  4. Печатает diff только по изменениям: лишние пути (`-`) и недостающие (`+`) с цветовой подсветкой
186
195
  5. Завершает команду с ошибкой, если есть расхождения
187
196
 
197
+ ### `spectrum token rotate`
198
+
199
+ Пролив уже созданного PAT `GITLAB_PRIVATE_TOKEN` в CI/CD variables групп и проектов. Команда токен не выпускает.
200
+
201
+ **Конфиг:** `~/.config/spectrum-cli/config.yaml`
202
+
203
+ Если файла нет, команда создаст его:
204
+
205
+ ```yaml
206
+ bot: bot
207
+ token_ttl_months: 12
208
+ groups: []
209
+ projects: []
210
+ ```
211
+
212
+ - `bot` и `token_ttl_months` читаются только из конфига.
213
+ - `GITLAB_PRIVATE_TOKEN` запрашивается скрытым вводом и живет только в памяти на время команды. Им же ходим в API.
214
+ - Для каждой группы и проекта CI variable `GITLAB_PRIVATE_TOKEN` удаляется (если есть) и создается заново как masked and hidden.
215
+ - Description переменной: `PAT от бота <bot>, владелец Колосов. Истекает YYYY-MM-DD.` Дата = сегодня + `token_ttl_months`.
216
+
217
+ 1. Загружает или создает конфиг
218
+ 2. Запрашивает `GITLAB_PRIVATE_TOKEN`
219
+ 3. Проверяет доступ ко всем группам и проектам
220
+ 4. Обновляет CI variables во всех целях
221
+
188
222
  ## 📝 Работа с Changelog
189
223
 
190
224
  ### `spectrum changelog append "Сообщение"`
package/index.js CHANGED
@@ -7,6 +7,7 @@ const version = require("./src/version");
7
7
  const release = require("./src/release");
8
8
  const changelog = require("./src/changelog");
9
9
  const chart = require("./src/chart");
10
+ const token = require("./src/token");
10
11
  const { version: pkgVersion } = require("./package.json");
11
12
 
12
13
  async function runAction(action, ...args) {
@@ -100,6 +101,15 @@ chartCmd
100
101
  .description("Задеплоить последнюю версию chart в файлы helmrelease")
101
102
  .action(() => runAction(chart.chartDeploy));
102
103
 
104
+ const tokenCmd = program
105
+ .command("token")
106
+ .description("Команды управления GitLab токенами");
107
+
108
+ tokenCmd
109
+ .command("rotate")
110
+ .description("Пролить GITLAB_PRIVATE_TOKEN в CI variables групп и проектов")
111
+ .action(() => runAction(token.tokenRotate));
112
+
103
113
  // Переопределяем help, чтобы показать кастомный формат с алиасами
104
114
  program.configureHelp({
105
115
  formatHelp: (cmd, helper) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-cli",
3
- "version": "2.0.0",
3
+ "version": "2.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/token.js ADDED
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const { logSuccess, logError } = require('./utils');
7
+ const { runCommand } = require('./command-executor');
8
+
9
+ const TOKEN_NAME = 'GITLAB_PRIVATE_TOKEN';
10
+
11
+ function getConfigPath() {
12
+ return path.join(os.homedir(), '.config', 'spectrum-cli', 'config.yaml');
13
+ }
14
+
15
+ function defaultConfigContent() {
16
+ return [
17
+ 'bot: bot',
18
+ 'token_ttl_months: 12',
19
+ 'groups: []',
20
+ 'projects: []',
21
+ ''
22
+ ].join('\n');
23
+ }
24
+
25
+ function stripQuotes(value) {
26
+ const text = String(value || '').trim();
27
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
28
+ return text.slice(1, -1);
29
+ }
30
+ return text;
31
+ }
32
+
33
+ function parseConfig(content) {
34
+ const result = { bot: null, tokenTtlMonths: null, groups: [], projects: [] };
35
+ let section = null;
36
+
37
+ for (const rawLine of String(content || '').split('\n')) {
38
+ const line = rawLine.replace(/\r$/, '');
39
+ const trimmed = line.trim();
40
+ if (!trimmed || trimmed.startsWith('#')) {
41
+ continue;
42
+ }
43
+
44
+ const isTopLevel = !line.startsWith(' ') && !line.startsWith('\t');
45
+ const botMatch = trimmed.match(/^bot:\s*(.+)$/);
46
+ if (isTopLevel && botMatch) {
47
+ result.bot = stripQuotes(botMatch[1]);
48
+ section = null;
49
+ continue;
50
+ }
51
+
52
+ const ttlMatch = trimmed.match(/^token_ttl_months:\s*(.+)$/);
53
+ if (isTopLevel && ttlMatch) {
54
+ result.tokenTtlMonths = Number(stripQuotes(ttlMatch[1]));
55
+ section = null;
56
+ continue;
57
+ }
58
+
59
+ if (isTopLevel && /^groups:\s*(?:\[\s*\])?\s*$/.test(trimmed)) {
60
+ section = 'groups';
61
+ continue;
62
+ }
63
+
64
+ if (isTopLevel && /^projects:\s*(?:\[\s*\])?\s*$/.test(trimmed)) {
65
+ section = 'projects';
66
+ continue;
67
+ }
68
+
69
+ const itemMatch = line.match(/^\s*-\s+(.+)$/);
70
+ if (itemMatch && (section === 'groups' || section === 'projects')) {
71
+ result[section].push(stripQuotes(itemMatch[1]));
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ function parseGitlabUrl(raw) {
79
+ const url = new URL(String(raw || '').trim());
80
+ const pathname = url.pathname.replace(/^\//, '').replace(/\.git$/, '').replace(/\/$/, '');
81
+ if (!url.origin || !pathname) {
82
+ throw new Error('invalid gitlab url');
83
+ }
84
+ return {
85
+ origin: url.origin,
86
+ path: pathname,
87
+ encodedPath: encodeURIComponent(pathname),
88
+ url: String(raw).trim()
89
+ };
90
+ }
91
+
92
+ function addMonths(date, months) {
93
+ const result = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + Number(months), date.getUTCDate()));
94
+ return result.toISOString().slice(0, 10);
95
+ }
96
+
97
+ function buildDescription(bot, expiresAt) {
98
+ return `PAT от бота ${bot}, владелец Колосов. Истекает ${expiresAt}.`;
99
+ }
100
+
101
+ function validateConfig(config) {
102
+ if (!config.bot) {
103
+ return { ok: false, reason: 'В конфиге отсутствует поле bot.' };
104
+ }
105
+ if (!Number.isInteger(config.tokenTtlMonths) || config.tokenTtlMonths <= 0) {
106
+ return { ok: false, reason: 'В конфиге поле token_ttl_months должно быть положительным целым числом.' };
107
+ }
108
+ if (config.groups.length === 0 && config.projects.length === 0) {
109
+ return { ok: false, reason: 'В конфиге пустые списки groups и projects.' };
110
+ }
111
+ return { ok: true };
112
+ }
113
+
114
+ function parseTargets(urls, kind) {
115
+ const targets = [];
116
+ for (const raw of urls) {
117
+ try {
118
+ targets.push({ kind, ...parseGitlabUrl(raw) });
119
+ } catch (error) {
120
+ return { ok: false, reason: `Некорректный URL ${kind}: ${raw}` };
121
+ }
122
+ }
123
+ return { ok: true, targets };
124
+ }
125
+
126
+ async function promptPrivateToken() {
127
+ return new Promise((resolve) => {
128
+ const stdin = process.stdin;
129
+ const stdout = process.stdout;
130
+ stdout.write('🔑 Введите GITLAB_PRIVATE_TOKEN: ');
131
+
132
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
133
+ const rl = readline.createInterface({ input: stdin, output: stdout });
134
+ rl.question('', (answer) => {
135
+ rl.close();
136
+ resolve(String(answer || '').trim());
137
+ });
138
+ return;
139
+ }
140
+
141
+ stdin.setRawMode(true);
142
+ stdin.resume();
143
+ stdin.setEncoding('utf8');
144
+ let value = '';
145
+ const onData = (char) => {
146
+ if (char === '\n' || char === '\r' || char === '\u0004') {
147
+ stdin.setRawMode(false);
148
+ stdin.pause();
149
+ stdin.removeListener('data', onData);
150
+ stdout.write('\n');
151
+ resolve(value.trim());
152
+ return;
153
+ }
154
+ if (char === '\u0003') {
155
+ stdin.setRawMode(false);
156
+ process.exit(1);
157
+ }
158
+ if (char === '\u007f' || char === '\b') {
159
+ value = value.slice(0, -1);
160
+ return;
161
+ }
162
+ value += char;
163
+ };
164
+ stdin.on('data', onData);
165
+ });
166
+ }
167
+
168
+ async function gitlabRequest(origin, privateToken, method, apiPath, body) {
169
+ const url = `${origin}/api/v4${apiPath}`;
170
+ const headers = { 'PRIVATE-TOKEN': privateToken };
171
+ const options = { method, headers };
172
+ if (body !== undefined) {
173
+ headers['Content-Type'] = 'application/json';
174
+ options.body = JSON.stringify(body);
175
+ }
176
+
177
+ const response = await fetch(url, options);
178
+ const text = await response.text();
179
+ let data = null;
180
+ if (text) {
181
+ try {
182
+ data = JSON.parse(text);
183
+ } catch (error) {
184
+ data = null;
185
+ }
186
+ }
187
+
188
+ return {
189
+ ok: response.ok,
190
+ status: response.status,
191
+ data,
192
+ headers: response.headers
193
+ };
194
+ }
195
+
196
+ async function gitlabGetAll(origin, privateToken, apiPath) {
197
+ const items = [];
198
+ let page = 1;
199
+
200
+ while (true) {
201
+ const separator = apiPath.includes('?') ? '&' : '?';
202
+ const result = await gitlabRequest(origin, privateToken, 'GET', `${apiPath}${separator}per_page=100&page=${page}`);
203
+ if (!result.ok) {
204
+ return result;
205
+ }
206
+
207
+ const batch = Array.isArray(result.data) ? result.data : [];
208
+ items.push(...batch);
209
+
210
+ const nextPage = result.headers && typeof result.headers.get === 'function'
211
+ ? result.headers.get('x-next-page')
212
+ : '';
213
+ if (!nextPage) {
214
+ break;
215
+ }
216
+ page = Number(nextPage);
217
+ if (!page) {
218
+ break;
219
+ }
220
+ }
221
+
222
+ return { ok: true, status: 200, data: items };
223
+ }
224
+
225
+ function loadOrCreateConfig() {
226
+ const configPath = getConfigPath();
227
+ const configDir = path.dirname(configPath);
228
+
229
+ if (!fs.existsSync(configPath)) {
230
+ logSuccess('📄', 'Конфиг не найден, создаю %s', configPath);
231
+ fs.mkdirSync(configDir, { recursive: true });
232
+ fs.writeFileSync(configPath, defaultConfigContent(), 'utf8');
233
+ logSuccess('📄', 'Конфиг создан. Проверьте groups и projects перед следующей ротацией.');
234
+ } else {
235
+ logSuccess('📄', 'Читаю конфиг %s', configPath);
236
+ }
237
+
238
+ let content = '';
239
+ try {
240
+ content = fs.readFileSync(configPath, 'utf8');
241
+ } catch (error) {
242
+ return { ok: false, reason: `Не удалось прочитать конфиг ${configPath}.` };
243
+ }
244
+
245
+ const parsed = parseConfig(content);
246
+ const valid = validateConfig(parsed);
247
+ if (!valid.ok) {
248
+ return valid;
249
+ }
250
+
251
+ const groups = parseTargets(parsed.groups, 'группы');
252
+ if (!groups.ok) {
253
+ return groups;
254
+ }
255
+ const projects = parseTargets(parsed.projects, 'проекта');
256
+ if (!projects.ok) {
257
+ return projects;
258
+ }
259
+
260
+ const targets = groups.targets.concat(projects.targets);
261
+ logSuccess('📄', 'Конфиг загружен: bot=%s, ttl=%s мес., групп=%s, проектов=%s', parsed.bot, String(parsed.tokenTtlMonths), String(parsed.groups.length), String(parsed.projects.length));
262
+
263
+ return {
264
+ ok: true,
265
+ data: {
266
+ configPath,
267
+ bot: parsed.bot,
268
+ tokenTtlMonths: parsed.tokenTtlMonths,
269
+ origin: targets[0].origin,
270
+ targets
271
+ }
272
+ };
273
+ }
274
+
275
+ async function askPrivateToken(ctx) {
276
+ logSuccess('🔑', 'Запрашиваю %s. Значение останется только в памяти.', TOKEN_NAME);
277
+ const privateToken = await module.exports.promptPrivateToken();
278
+ if (!privateToken) {
279
+ return { ok: false, reason: `${TOKEN_NAME} не указан.` };
280
+ }
281
+
282
+ const expiresAt = addMonths(new Date(), ctx.tokenTtlMonths);
283
+ logSuccess('🔑', '%s принят в память. Description будет с датой %s.', TOKEN_NAME, expiresAt);
284
+ return {
285
+ ok: true,
286
+ data: {
287
+ privateToken,
288
+ expiresAt,
289
+ variableDescription: buildDescription(ctx.bot, expiresAt)
290
+ }
291
+ };
292
+ }
293
+
294
+ async function checkTargetsAccess(ctx) {
295
+ const total = ctx.targets.length;
296
+ for (let index = 0; index < total; index += 1) {
297
+ const target = ctx.targets[index];
298
+ const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
299
+ logSuccess('🔍', '[%s/%s] Проверяю доступ к %s %s', String(index + 1), String(total), target.kind, target.url);
300
+ const result = await gitlabRequest(ctx.origin, ctx.privateToken, 'GET', `/${endpoint}/${target.encodedPath}`);
301
+ if (!result.ok) {
302
+ return { ok: false, reason: `${target.kind === 'группы' ? 'Группа' : 'Проект'} недоступен: ${target.url} (HTTP ${result.status}).` };
303
+ }
304
+ logSuccess('✅', '[%s/%s] %s доступна: %s', String(index + 1), String(total), target.kind === 'группы' ? 'Группа' : 'Проект', target.path);
305
+ }
306
+ return { ok: true };
307
+ }
308
+
309
+ async function updateTargetVariable(ctx, target, index, total) {
310
+ const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
311
+ const label = target.kind === 'группы' ? 'группе' : 'проекте';
312
+ logSuccess('📦', '[%s/%s] Обновляю CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
313
+
314
+ const listed = await gitlabGetAll(ctx.origin, ctx.privateToken, `/${endpoint}/${target.encodedPath}/variables`);
315
+ if (!listed.ok) {
316
+ logError('❌', 'Не удалось получить CI variables для %s (HTTP %s).', target.path, String(listed.status));
317
+ return false;
318
+ }
319
+
320
+ const exists = (listed.data || []).some((variable) => variable.key === TOKEN_NAME);
321
+ if (exists) {
322
+ logSuccess('🗑', '[%s/%s] Удаляю старую CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
323
+ const deleted = await gitlabRequest(ctx.origin, ctx.privateToken, 'DELETE', `/${endpoint}/${target.encodedPath}/variables/${TOKEN_NAME}`);
324
+ if (!deleted.ok && deleted.status !== 204) {
325
+ logError('❌', 'Не удалось удалить CI variable в %s (HTTP %s).', target.path, String(deleted.status));
326
+ return false;
327
+ }
328
+ logSuccess('🗑', '[%s/%s] Старая CI variable удалена в %s %s', String(index + 1), String(total), label, target.path);
329
+ } else {
330
+ logSuccess('🔎', '[%s/%s] CI variable %s в %s %s отсутствует, будет создана.', String(index + 1), String(total), TOKEN_NAME, label, target.path);
331
+ }
332
+
333
+ logSuccess('📝', '[%s/%s] Создаю CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
334
+ const created = await gitlabRequest(ctx.origin, ctx.privateToken, 'POST', `/${endpoint}/${target.encodedPath}/variables`, {
335
+ key: TOKEN_NAME,
336
+ value: ctx.privateToken,
337
+ masked_and_hidden: true,
338
+ description: ctx.variableDescription
339
+ });
340
+ if (!created.ok) {
341
+ logError('❌', 'Не удалось создать CI variable в %s (HTTP %s).', target.path, String(created.status));
342
+ return false;
343
+ }
344
+
345
+ logSuccess('📝', '[%s/%s] CI variable обновлена в %s %s', String(index + 1), String(total), label, target.path);
346
+ return true;
347
+ }
348
+
349
+ async function updateCiVariables(ctx) {
350
+ const total = ctx.targets.length;
351
+ logSuccess('📦', 'Начинаю обновление CI variables: целей %s', String(total));
352
+ for (let index = 0; index < total; index += 1) {
353
+ const ok = await updateTargetVariable(ctx, ctx.targets[index], index, total);
354
+ if (!ok) {
355
+ return false;
356
+ }
357
+ }
358
+ logSuccess('📦', 'Все CI variables обновлены (%s).', String(total));
359
+ return true;
360
+ }
361
+
362
+ function tokenRotate() {
363
+ return runCommand({
364
+ name: 'token rotate',
365
+ checks: [
366
+ { name: 'load-config', run: loadOrCreateConfig },
367
+ { name: 'ask-private-token', run: askPrivateToken },
368
+ { name: 'check-access', run: checkTargetsAccess }
369
+ ],
370
+ steps: [
371
+ { name: 'update-ci-variables', run: updateCiVariables }
372
+ ]
373
+ });
374
+ }
375
+
376
+ module.exports = {
377
+ TOKEN_NAME,
378
+ getConfigPath,
379
+ defaultConfigContent,
380
+ parseConfig,
381
+ parseGitlabUrl,
382
+ addMonths,
383
+ buildDescription,
384
+ validateConfig,
385
+ promptPrivateToken,
386
+ loadOrCreateConfig,
387
+ askPrivateToken,
388
+ checkTargetsAccess,
389
+ updateCiVariables,
390
+ tokenRotate
391
+ };