spectrum-cli 2.1.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,12 @@ _Список правок для обеспечения безопасност
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
+
42
48
  ## 🚀 [2.1.0] - 2026-08-17
43
49
 
44
50
  ### 🆕 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-private-token` (`GITLAB_PRIVATE_TOKEN` только в памяти), `check-access` (все группы и проекты из конфига доступны).
143
143
 
144
144
  ## 🔄 Workflow релиза
145
145
 
@@ -196,36 +196,28 @@ 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
+ - `GITLAB_PRIVATE_TOKEN` запрашивается скрытым вводом и живет только в памяти на время команды. Им же ходим в API.
219
214
  - Для каждой группы и проекта CI variable `GITLAB_PRIVATE_TOKEN` удаляется (если есть) и создается заново как masked and hidden.
220
- - Description переменной: `PAT от бота <bot>, владелец Колосов. Истекает YYYY-MM-DD.`
221
- - В конце новый PAT печатается один раз. Повторно получить его нельзя.
215
+ - Description переменной: `PAT от бота <bot>, владелец Колосов. Истекает YYYY-MM-DD.` Дата = сегодня + `token_ttl_months`.
222
216
 
223
217
  1. Загружает или создает конфиг
224
- 2. Запрашивает admin PAT
218
+ 2. Запрашивает `GITLAB_PRIVATE_TOKEN`
225
219
  3. Проверяет доступ ко всем группам и проектам
226
- 4. Удаляет старый PAT `GITLAB_PRIVATE_TOKEN` и создает новый
227
- 5. Обновляет CI variables во всех целях
228
- 6. Печатает новый PAT и предупреждение
220
+ 4. Обновляет CI variables во всех целях
229
221
 
230
222
  ## 📝 Работа с Changelog
231
223
 
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 групп и проектов")
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.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 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 promptPrivateToken() {
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('🔑 Введите GITLAB_PRIVATE_TOKEN: ');
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, privateToken, method, apiPath, body) {
170
169
  const url = `${origin}/api/v4${apiPath}`;
171
- const headers = { 'PRIVATE-TOKEN': adminPat };
170
+ const headers = { 'PRIVATE-TOKEN': privateToken };
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, privateToken, 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, privateToken, 'GET', `${apiPath}${separator}per_page=100&page=${page}`);
204
203
  if (!result.ok) {
205
204
  return result;
206
205
  }
@@ -267,30 +266,29 @@ 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 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
280
  }
281
- logSuccess('🔑', 'Admin PAT принят в память.');
282
- return { ok: true, data: { adminPat } };
283
- }
284
281
 
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}).` };
291
- }
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 } };
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
+ };
294
292
  }
295
293
 
296
294
  async function checkTargetsAccess(ctx) {
@@ -299,7 +297,7 @@ async function checkTargetsAccess(ctx) {
299
297
  const target = ctx.targets[index];
300
298
  const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
301
299
  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}`);
300
+ const result = await gitlabRequest(ctx.origin, ctx.privateToken, 'GET', `/${endpoint}/${target.encodedPath}`);
303
301
  if (!result.ok) {
304
302
  return { ok: false, reason: `${target.kind === 'группы' ? 'Группа' : 'Проект'} недоступен: ${target.url} (HTTP ${result.status}).` };
305
303
  }
@@ -308,54 +306,12 @@ async function checkTargetsAccess(ctx) {
308
306
  return { ok: true };
309
307
  }
310
308
 
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
309
  async function updateTargetVariable(ctx, target, index, total) {
354
310
  const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
355
311
  const label = target.kind === 'группы' ? 'группе' : 'проекте';
356
312
  logSuccess('📦', '[%s/%s] Обновляю CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
357
313
 
358
- const listed = await gitlabGetAll(ctx.origin, ctx.adminPat, `/${endpoint}/${target.encodedPath}/variables`);
314
+ const listed = await gitlabGetAll(ctx.origin, ctx.privateToken, `/${endpoint}/${target.encodedPath}/variables`);
359
315
  if (!listed.ok) {
360
316
  logError('❌', 'Не удалось получить CI variables для %s (HTTP %s).', target.path, String(listed.status));
361
317
  return false;
@@ -364,7 +320,7 @@ async function updateTargetVariable(ctx, target, index, total) {
364
320
  const exists = (listed.data || []).some((variable) => variable.key === TOKEN_NAME);
365
321
  if (exists) {
366
322
  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}`);
323
+ const deleted = await gitlabRequest(ctx.origin, ctx.privateToken, 'DELETE', `/${endpoint}/${target.encodedPath}/variables/${TOKEN_NAME}`);
368
324
  if (!deleted.ok && deleted.status !== 204) {
369
325
  logError('❌', 'Не удалось удалить CI variable в %s (HTTP %s).', target.path, String(deleted.status));
370
326
  return false;
@@ -375,9 +331,9 @@ async function updateTargetVariable(ctx, target, index, total) {
375
331
  }
376
332
 
377
333
  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`, {
334
+ const created = await gitlabRequest(ctx.origin, ctx.privateToken, 'POST', `/${endpoint}/${target.encodedPath}/variables`, {
379
335
  key: TOKEN_NAME,
380
- value: ctx.newToken,
336
+ value: ctx.privateToken,
381
337
  masked_and_hidden: true,
382
338
  description: ctx.variableDescription
383
339
  });
@@ -403,32 +359,22 @@ async function updateCiVariables(ctx) {
403
359
  return true;
404
360
  }
405
361
 
406
- function printNewToken(ctx) {
407
- console.log(` ${colors.yellow}⚠️ Новый ${TOKEN_NAME} больше нельзя будет получить. Сохраните его сейчас:${colors.reset}`);
408
- console.log(ctx.newToken);
409
- return true;
410
- }
411
-
412
362
  function tokenRotate() {
413
363
  return runCommand({
414
364
  name: 'token rotate',
415
365
  checks: [
416
366
  { name: 'load-config', run: loadOrCreateConfig },
417
- { name: 'ask-admin-pat', run: askAdminPat },
418
- { name: 'resolve-user', run: resolveCurrentUser },
367
+ { name: 'ask-private-token', run: askPrivateToken },
419
368
  { name: 'check-access', run: checkTargetsAccess }
420
369
  ],
421
370
  steps: [
422
- { name: 'rotate-pat', run: rotatePat },
423
- { name: 'update-ci-variables', run: updateCiVariables },
424
- { name: 'print-token', run: printNewToken }
371
+ { name: 'update-ci-variables', run: updateCiVariables }
425
372
  ]
426
373
  });
427
374
  }
428
375
 
429
376
  module.exports = {
430
377
  TOKEN_NAME,
431
- TOKEN_SCOPES,
432
378
  getConfigPath,
433
379
  defaultConfigContent,
434
380
  parseConfig,
@@ -436,13 +382,10 @@ module.exports = {
436
382
  addMonths,
437
383
  buildDescription,
438
384
  validateConfig,
439
- promptAdminPat,
385
+ promptPrivateToken,
440
386
  loadOrCreateConfig,
441
- askAdminPat,
442
- resolveCurrentUser,
387
+ askPrivateToken,
443
388
  checkTargetsAccess,
444
- rotatePat,
445
389
  updateCiVariables,
446
- printNewToken,
447
390
  tokenRotate
448
391
  };