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 +6 -0
- package/README.md +12 -20
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/token.js +34 -91
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 #
|
|
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` |
|
|
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-
|
|
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
|
-
|
|
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:
|
|
207
|
-
token_ttl_months:
|
|
208
|
-
groups:
|
|
209
|
-
|
|
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
|
-
-
|
|
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. Запрашивает
|
|
218
|
+
2. Запрашивает `GITLAB_PRIVATE_TOKEN`
|
|
225
219
|
3. Проверяет доступ ко всем группам и проектам
|
|
226
|
-
4.
|
|
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("
|
|
110
|
+
.description("Пролить GITLAB_PRIVATE_TOKEN в CI variables групп и проектов")
|
|
111
111
|
.action(() => runAction(token.tokenRotate));
|
|
112
112
|
|
|
113
113
|
// Переопределяем help, чтобы показать кастомный формат с алиасами
|
package/package.json
CHANGED
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
|
|
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
|
|
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('🔑 Введите
|
|
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,
|
|
168
|
+
async function gitlabRequest(origin, privateToken, method, apiPath, body) {
|
|
170
169
|
const url = `${origin}/api/v4${apiPath}`;
|
|
171
|
-
const headers = { 'PRIVATE-TOKEN':
|
|
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,
|
|
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,
|
|
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
|
|
276
|
-
logSuccess('🔑', 'Запрашиваю
|
|
277
|
-
const
|
|
278
|
-
if (!
|
|
279
|
-
return { ok: false, reason:
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
334
|
+
const created = await gitlabRequest(ctx.origin, ctx.privateToken, 'POST', `/${endpoint}/${target.encodedPath}/variables`, {
|
|
379
335
|
key: TOKEN_NAME,
|
|
380
|
-
value: ctx.
|
|
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-
|
|
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: '
|
|
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
|
-
|
|
385
|
+
promptPrivateToken,
|
|
440
386
|
loadOrCreateConfig,
|
|
441
|
-
|
|
442
|
-
resolveCurrentUser,
|
|
387
|
+
askPrivateToken,
|
|
443
388
|
checkTargetsAccess,
|
|
444
|
-
rotatePat,
|
|
445
389
|
updateCiVariables,
|
|
446
|
-
printNewToken,
|
|
447
390
|
tokenRotate
|
|
448
391
|
};
|