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 +12 -0
- package/README.md +15 -20
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/token.js +41 -90
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 #
|
|
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-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
|
-
|
|
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
|
-
-
|
|
217
|
-
-
|
|
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. Запрашивает
|
|
225
|
-
3.
|
|
226
|
-
4.
|
|
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("
|
|
110
|
+
.description("Пролить GITLAB_PRIVATE_TOKEN в CI variables через owner PAT")
|
|
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 promptHidden(message) {
|
|
128
127
|
return new Promise((resolve) => {
|
|
129
128
|
const stdin = process.stdin;
|
|
130
129
|
const stdout = process.stdout;
|
|
131
|
-
stdout.write(
|
|
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,
|
|
168
|
+
async function gitlabRequest(origin, accessToken, method, apiPath, body) {
|
|
170
169
|
const url = `${origin}/api/v4${apiPath}`;
|
|
171
|
-
const headers = { 'PRIVATE-TOKEN':
|
|
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,
|
|
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,
|
|
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
|
|
276
|
-
logSuccess('🔑', 'Запрашиваю
|
|
277
|
-
const
|
|
278
|
-
if (!
|
|
279
|
-
return { ok: false, reason: '
|
|
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('🔑', '
|
|
282
|
-
return { ok: true, data: { adminPat } };
|
|
283
|
-
}
|
|
281
|
+
logSuccess('🔑', 'Owner PAT принят в память.');
|
|
284
282
|
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
342
|
+
const created = await gitlabRequest(ctx.origin, ctx.ownerToken, 'POST', `/${endpoint}/${target.encodedPath}/variables`, {
|
|
379
343
|
key: TOKEN_NAME,
|
|
380
|
-
value: ctx.
|
|
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-
|
|
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: '
|
|
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
|
-
|
|
393
|
+
promptHidden,
|
|
440
394
|
loadOrCreateConfig,
|
|
441
|
-
|
|
442
|
-
resolveCurrentUser,
|
|
395
|
+
askTokens,
|
|
443
396
|
checkTargetsAccess,
|
|
444
|
-
rotatePat,
|
|
445
397
|
updateCiVariables,
|
|
446
|
-
printNewToken,
|
|
447
398
|
tokenRotate
|
|
448
399
|
};
|