spectrum-cli 2.1.0 → 2.1.2

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.2] - 2026-08-17
43
+
44
+ ### 🛠 Changed
45
+
46
+ - Команда `spectrum token rotate` запрашивает два токена: owner PAT для работы с CI variables и `GITLAB_PRIVATE_TOKEN` для записи в переменные.
47
+
48
+ ## 🚀 [2.1.1] - 2026-08-17
49
+
50
+ ### 🛠 Changed
51
+
52
+ - Команда `spectrum token rotate` больше не создает PAT. Нужно передать готовый `GITLAB_PRIVATE_TOKEN`, команда только проливает его в CI/CD variables.
53
+
42
54
  ## 🚀 [2.1.0] - 2026-08-17
43
55
 
44
56
  ### 🆕 Added
package/README.md CHANGED
@@ -55,7 +55,7 @@ spectrum chart verify ~/repo # Сравнить ingress paths AS IS vs TO BE д
55
55
  spectrum changelog append "Сообщение" # Добавить запись в CHANGELOG.md
56
56
 
57
57
  # 🔑 GitLab токены
58
- spectrum token rotate # Ротировать GITLAB_PRIVATE_TOKEN и обновить CI variables
58
+ spectrum token rotate # Пролить GITLAB_PRIVATE_TOKEN в CI variables
59
59
  ```
60
60
 
61
61
  ### Справка по командам:
@@ -125,7 +125,7 @@ spectrum-cli/
125
125
  | `spectrum chart create` | Создать и запушить chart тег |
126
126
  | `spectrum chart deploy` | Обновить chart версию в helmrelease |
127
127
  | `spectrum chart verify` | Проверить ingress paths chart |
128
- | `spectrum token rotate` | Ротировать GITLAB_PRIVATE_TOKEN |
128
+ | `spectrum token rotate` | Пролить GITLAB_PRIVATE_TOKEN в CI |
129
129
 
130
130
  ### 🛡️ Preflight-проверки по командам
131
131
 
@@ -139,7 +139,7 @@ spectrum-cli/
139
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`).
140
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`).
141
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-admin-pat` (admin PAT только в памяти), `resolve-user` (`GET /user`), `check-access` (все группы и проекты из конфига доступны).
142
+ - `spectrum token rotate`: `load-config` (есть валидный `~/.config/spectrum-cli/config.yaml`), `ask-tokens` (owner PAT и `GITLAB_PRIVATE_TOKEN` только в памяти), `check-access` (все группы и проекты доступны owner PAT).
143
143
 
144
144
  ## 🔄 Workflow релиза
145
145
 
@@ -196,36 +196,31 @@ spectrum-cli/
196
196
 
197
197
  ### `spectrum token rotate`
198
198
 
199
- Ротация PAT `GITLAB_PRIVATE_TOKEN` на текущем пользователе admin PAT и пролив значения в CI/CD variables групп и проектов.
199
+ Пролив уже созданного PAT `GITLAB_PRIVATE_TOKEN` в CI/CD variables групп и проектов. Команда токен не выпускает.
200
200
 
201
201
  **Конфиг:** `~/.config/spectrum-cli/config.yaml`
202
202
 
203
203
  Если файла нет, команда создаст его:
204
204
 
205
205
  ```yaml
206
- bot: group349_bot2
207
- token_ttl_months: 6
208
- groups:
209
- - https://gitlab.spectrumdata.tech/spectrum-frontend
210
- projects:
211
- - https://gitlab.spectrumdata.tech/bso/agent-site
206
+ bot: bot
207
+ token_ttl_months: 12
208
+ groups: []
209
+ projects: []
212
210
  ```
213
211
 
214
212
  - `bot` и `token_ttl_months` читаются только из конфига.
215
- - Admin PAT запрашивается скрытым вводом и живет только в памяти на время команды.
216
- - Если PAT `GITLAB_PRIVATE_TOKEN` уже есть, он удаляется и создается заново. Если нет — создается новый.
217
- - Срок жизни PAT: сегодня + `token_ttl_months`.
218
- - Права PAT: `api`, `read_repository`, `write_repository`, `read_registry`.
213
+ - Скрытым вводом запрашиваются два токена. Оба живут только в памяти.
214
+ - Owner PAT: ходит в API, проверяет доступ, читает/удаляет/создает CI variables. Для групп нужен Owner, для проектов — Maintainer+.
215
+ - `GITLAB_PRIVATE_TOKEN`: только значение, которое пишется в CI variable.
219
216
  - Для каждой группы и проекта CI variable `GITLAB_PRIVATE_TOKEN` удаляется (если есть) и создается заново как masked and hidden.
220
- - Description переменной: `PAT от бота <bot>, владелец Колосов. Истекает YYYY-MM-DD.`
221
- - В конце новый PAT печатается один раз. Повторно получить его нельзя.
217
+ - Description переменной: `PAT от бота <bot>, владелец Колосов. Истекает YYYY-MM-DD.` Дата = сегодня + `token_ttl_months`.
222
218
 
223
219
  1. Загружает или создает конфиг
224
- 2. Запрашивает admin PAT
225
- 3. Проверяет доступ ко всем группам и проектам
226
- 4. Удаляет старый PAT `GITLAB_PRIVATE_TOKEN` и создает новый
220
+ 2. Запрашивает owner PAT
221
+ 3. Запрашивает `GITLAB_PRIVATE_TOKEN`
222
+ 4. Проверяет доступ ко всем группам и проектам owner PAT
227
223
  5. Обновляет CI variables во всех целях
228
- 6. Печатает новый PAT и предупреждение
229
224
 
230
225
  ## 📝 Работа с Changelog
231
226
 
package/index.js CHANGED
@@ -107,7 +107,7 @@ const tokenCmd = program
107
107
 
108
108
  tokenCmd
109
109
  .command("rotate")
110
- .description("Ротировать GITLAB_PRIVATE_TOKEN и обновить CI variables")
110
+ .description("Пролить GITLAB_PRIVATE_TOKEN в CI variables через owner PAT")
111
111
  .action(() => runAction(token.tokenRotate));
112
112
 
113
113
  // Переопределяем help, чтобы показать кастомный формат с алиасами
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectrum-cli",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
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 CHANGED
@@ -3,11 +3,10 @@ const fs = require('fs');
3
3
  const os = require('os');
4
4
  const path = require('path');
5
5
  const readline = require('readline');
6
- const { logSuccess, logError, colors } = require('./utils');
6
+ const { logSuccess, logError } = require('./utils');
7
7
  const { runCommand } = require('./command-executor');
8
8
 
9
9
  const TOKEN_NAME = 'GITLAB_PRIVATE_TOKEN';
10
- const TOKEN_SCOPES = ['api', 'read_repository', 'write_repository', 'read_registry'];
11
10
 
12
11
  function getConfigPath() {
13
12
  return path.join(os.homedir(), '.config', 'spectrum-cli', 'config.yaml');
@@ -57,12 +56,12 @@ function parseConfig(content) {
57
56
  continue;
58
57
  }
59
58
 
60
- if (isTopLevel && /^groups:\s*$/.test(trimmed)) {
59
+ if (isTopLevel && /^groups:\s*(?:\[\s*\])?\s*$/.test(trimmed)) {
61
60
  section = 'groups';
62
61
  continue;
63
62
  }
64
63
 
65
- if (isTopLevel && /^projects:\s*$/.test(trimmed)) {
64
+ if (isTopLevel && /^projects:\s*(?:\[\s*\])?\s*$/.test(trimmed)) {
66
65
  section = 'projects';
67
66
  continue;
68
67
  }
@@ -124,11 +123,11 @@ function parseTargets(urls, kind) {
124
123
  return { ok: true, targets };
125
124
  }
126
125
 
127
- async function promptAdminPat() {
126
+ async function promptHidden(message) {
128
127
  return new Promise((resolve) => {
129
128
  const stdin = process.stdin;
130
129
  const stdout = process.stdout;
131
- stdout.write('🔑 Введите admin PAT: ');
130
+ stdout.write(message);
132
131
 
133
132
  if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
134
133
  const rl = readline.createInterface({ input: stdin, output: stdout });
@@ -166,9 +165,9 @@ async function promptAdminPat() {
166
165
  });
167
166
  }
168
167
 
169
- async function gitlabRequest(origin, adminPat, method, apiPath, body) {
168
+ async function gitlabRequest(origin, accessToken, method, apiPath, body) {
170
169
  const url = `${origin}/api/v4${apiPath}`;
171
- const headers = { 'PRIVATE-TOKEN': adminPat };
170
+ const headers = { 'PRIVATE-TOKEN': accessToken };
172
171
  const options = { method, headers };
173
172
  if (body !== undefined) {
174
173
  headers['Content-Type'] = 'application/json';
@@ -194,13 +193,13 @@ async function gitlabRequest(origin, adminPat, method, apiPath, body) {
194
193
  };
195
194
  }
196
195
 
197
- async function gitlabGetAll(origin, adminPat, apiPath) {
196
+ async function gitlabGetAll(origin, accessToken, apiPath) {
198
197
  const items = [];
199
198
  let page = 1;
200
199
 
201
200
  while (true) {
202
201
  const separator = apiPath.includes('?') ? '&' : '?';
203
- const result = await gitlabRequest(origin, adminPat, 'GET', `${apiPath}${separator}per_page=100&page=${page}`);
202
+ const result = await gitlabRequest(origin, accessToken, 'GET', `${apiPath}${separator}per_page=100&page=${page}`);
204
203
  if (!result.ok) {
205
204
  return result;
206
205
  }
@@ -267,30 +266,37 @@ function loadOrCreateConfig() {
267
266
  configPath,
268
267
  bot: parsed.bot,
269
268
  tokenTtlMonths: parsed.tokenTtlMonths,
269
+ origin: targets[0].origin,
270
270
  targets
271
271
  }
272
272
  };
273
273
  }
274
274
 
275
- async function askAdminPat() {
276
- logSuccess('🔑', 'Запрашиваю admin PAT. Значение останется только в памяти.');
277
- const adminPat = await module.exports.promptAdminPat();
278
- if (!adminPat) {
279
- return { ok: false, reason: 'Admin PAT не указан.' };
275
+ async function askTokens(ctx) {
276
+ logSuccess('🔑', 'Запрашиваю owner PAT для работы с CI variables. Значение останется только в памяти.');
277
+ const ownerToken = await module.exports.promptHidden('🔑 Введите owner PAT: ');
278
+ if (!ownerToken) {
279
+ return { ok: false, reason: 'Owner PAT не указан.' };
280
280
  }
281
- logSuccess('🔑', 'Admin PAT принят в память.');
282
- return { ok: true, data: { adminPat } };
283
- }
281
+ logSuccess('🔑', 'Owner PAT принят в память.');
284
282
 
285
- async function resolveCurrentUser(ctx) {
286
- const origin = ctx.targets[0].origin;
287
- logSuccess('👤', 'Проверяю admin PAT через GET /user на %s', origin);
288
- const result = await gitlabRequest(origin, ctx.adminPat, 'GET', '/user');
289
- if (!result.ok || !result.data || !result.data.id) {
290
- return { ok: false, reason: `Не удалось получить текущего пользователя (HTTP ${result.status}).` };
283
+ logSuccess('🔑', 'Запрашиваю %s для записи в CI variables. Значение останется только в памяти.', TOKEN_NAME);
284
+ const ciToken = await module.exports.promptHidden(`🔑 Введите ${TOKEN_NAME}: `);
285
+ if (!ciToken) {
286
+ return { ok: false, reason: `${TOKEN_NAME} не указан.` };
291
287
  }
292
- logSuccess('👤', 'Текущий пользователь: %s (id=%s)', result.data.username || 'unknown', String(result.data.id));
293
- return { ok: true, data: { origin, userId: result.data.id, username: result.data.username } };
288
+
289
+ const expiresAt = addMonths(new Date(), ctx.tokenTtlMonths);
290
+ logSuccess('🔑', '%s принят в память. Description будет с датой %s.', TOKEN_NAME, expiresAt);
291
+ return {
292
+ ok: true,
293
+ data: {
294
+ ownerToken,
295
+ ciToken,
296
+ expiresAt,
297
+ variableDescription: buildDescription(ctx.bot, expiresAt)
298
+ }
299
+ };
294
300
  }
295
301
 
296
302
  async function checkTargetsAccess(ctx) {
@@ -299,7 +305,7 @@ async function checkTargetsAccess(ctx) {
299
305
  const target = ctx.targets[index];
300
306
  const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
301
307
  logSuccess('🔍', '[%s/%s] Проверяю доступ к %s %s', String(index + 1), String(total), target.kind, target.url);
302
- const result = await gitlabRequest(ctx.origin, ctx.adminPat, 'GET', `/${endpoint}/${target.encodedPath}`);
308
+ const result = await gitlabRequest(ctx.origin, ctx.ownerToken, 'GET', `/${endpoint}/${target.encodedPath}`);
303
309
  if (!result.ok) {
304
310
  return { ok: false, reason: `${target.kind === 'группы' ? 'Группа' : 'Проект'} недоступен: ${target.url} (HTTP ${result.status}).` };
305
311
  }
@@ -308,54 +314,12 @@ async function checkTargetsAccess(ctx) {
308
314
  return { ok: true };
309
315
  }
310
316
 
311
- async function rotatePat(ctx) {
312
- const expiresAt = addMonths(new Date(), ctx.tokenTtlMonths);
313
- logSuccess('🔎', 'Ищу активные PAT с именем %s', TOKEN_NAME);
314
- const listed = await gitlabGetAll(ctx.origin, ctx.adminPat, `/personal_access_tokens?user_id=${ctx.userId}&search=${encodeURIComponent(TOKEN_NAME)}`);
315
- if (!listed.ok) {
316
- logError('❌', 'Не удалось получить список PAT (HTTP %s).', String(listed.status));
317
- return false;
318
- }
319
-
320
- const existing = (listed.data || []).filter((token) => token.name === TOKEN_NAME && token.active && !token.revoked);
321
- if (existing.length === 0) {
322
- logSuccess('🔎', 'Активный PAT %s не найден, будет создан новый.', TOKEN_NAME);
323
- }
324
-
325
- for (const token of existing) {
326
- logSuccess('🗑', 'Удаляю старый PAT %s (id=%s)', TOKEN_NAME, String(token.id));
327
- const revoked = await gitlabRequest(ctx.origin, ctx.adminPat, 'DELETE', `/personal_access_tokens/${token.id}`);
328
- if (!revoked.ok && revoked.status !== 204) {
329
- logError('❌', 'Не удалось удалить PAT id=%s (HTTP %s).', String(token.id), String(revoked.status));
330
- return false;
331
- }
332
- logSuccess('🗑', 'PAT id=%s удален.', String(token.id));
333
- }
334
-
335
- logSuccess('🆕', 'Создаю PAT %s, истекает %s', TOKEN_NAME, expiresAt);
336
- const created = await gitlabRequest(ctx.origin, ctx.adminPat, 'POST', `/users/${ctx.userId}/personal_access_tokens`, {
337
- name: TOKEN_NAME,
338
- scopes: TOKEN_SCOPES,
339
- expires_at: expiresAt
340
- });
341
- if (!created.ok || !created.data || !created.data.token) {
342
- logError('❌', 'Не удалось создать PAT (HTTP %s).', String(created.status));
343
- return false;
344
- }
345
-
346
- logSuccess('🆕', 'PAT %s создан (id=%s), истекает %s', TOKEN_NAME, String(created.data.id), expiresAt);
347
- ctx.newToken = created.data.token;
348
- ctx.expiresAt = expiresAt;
349
- ctx.variableDescription = buildDescription(ctx.bot, expiresAt);
350
- return true;
351
- }
352
-
353
317
  async function updateTargetVariable(ctx, target, index, total) {
354
318
  const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
355
319
  const label = target.kind === 'группы' ? 'группе' : 'проекте';
356
320
  logSuccess('📦', '[%s/%s] Обновляю CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
357
321
 
358
- const listed = await gitlabGetAll(ctx.origin, ctx.adminPat, `/${endpoint}/${target.encodedPath}/variables`);
322
+ const listed = await gitlabGetAll(ctx.origin, ctx.ownerToken, `/${endpoint}/${target.encodedPath}/variables`);
359
323
  if (!listed.ok) {
360
324
  logError('❌', 'Не удалось получить CI variables для %s (HTTP %s).', target.path, String(listed.status));
361
325
  return false;
@@ -364,7 +328,7 @@ async function updateTargetVariable(ctx, target, index, total) {
364
328
  const exists = (listed.data || []).some((variable) => variable.key === TOKEN_NAME);
365
329
  if (exists) {
366
330
  logSuccess('🗑', '[%s/%s] Удаляю старую CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
367
- const deleted = await gitlabRequest(ctx.origin, ctx.adminPat, 'DELETE', `/${endpoint}/${target.encodedPath}/variables/${TOKEN_NAME}`);
331
+ const deleted = await gitlabRequest(ctx.origin, ctx.ownerToken, 'DELETE', `/${endpoint}/${target.encodedPath}/variables/${TOKEN_NAME}`);
368
332
  if (!deleted.ok && deleted.status !== 204) {
369
333
  logError('❌', 'Не удалось удалить CI variable в %s (HTTP %s).', target.path, String(deleted.status));
370
334
  return false;
@@ -375,9 +339,9 @@ async function updateTargetVariable(ctx, target, index, total) {
375
339
  }
376
340
 
377
341
  logSuccess('📝', '[%s/%s] Создаю CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
378
- const created = await gitlabRequest(ctx.origin, ctx.adminPat, 'POST', `/${endpoint}/${target.encodedPath}/variables`, {
342
+ const created = await gitlabRequest(ctx.origin, ctx.ownerToken, 'POST', `/${endpoint}/${target.encodedPath}/variables`, {
379
343
  key: TOKEN_NAME,
380
- value: ctx.newToken,
344
+ value: ctx.ciToken,
381
345
  masked_and_hidden: true,
382
346
  description: ctx.variableDescription
383
347
  });
@@ -403,32 +367,22 @@ async function updateCiVariables(ctx) {
403
367
  return true;
404
368
  }
405
369
 
406
- function printNewToken(ctx) {
407
- console.log(` ${colors.yellow}⚠️ Новый ${TOKEN_NAME} больше нельзя будет получить. Сохраните его сейчас:${colors.reset}`);
408
- console.log(ctx.newToken);
409
- return true;
410
- }
411
-
412
370
  function tokenRotate() {
413
371
  return runCommand({
414
372
  name: 'token rotate',
415
373
  checks: [
416
374
  { name: 'load-config', run: loadOrCreateConfig },
417
- { name: 'ask-admin-pat', run: askAdminPat },
418
- { name: 'resolve-user', run: resolveCurrentUser },
375
+ { name: 'ask-tokens', run: askTokens },
419
376
  { name: 'check-access', run: checkTargetsAccess }
420
377
  ],
421
378
  steps: [
422
- { name: 'rotate-pat', run: rotatePat },
423
- { name: 'update-ci-variables', run: updateCiVariables },
424
- { name: 'print-token', run: printNewToken }
379
+ { name: 'update-ci-variables', run: updateCiVariables }
425
380
  ]
426
381
  });
427
382
  }
428
383
 
429
384
  module.exports = {
430
385
  TOKEN_NAME,
431
- TOKEN_SCOPES,
432
386
  getConfigPath,
433
387
  defaultConfigContent,
434
388
  parseConfig,
@@ -436,13 +390,10 @@ module.exports = {
436
390
  addMonths,
437
391
  buildDescription,
438
392
  validateConfig,
439
- promptAdminPat,
393
+ promptHidden,
440
394
  loadOrCreateConfig,
441
- askAdminPat,
442
- resolveCurrentUser,
395
+ askTokens,
443
396
  checkTargetsAccess,
444
- rotatePat,
445
397
  updateCiVariables,
446
- printNewToken,
447
398
  tokenRotate
448
399
  };