spectrum-cli 2.0.0 → 2.1.0

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.0] - 2026-08-17
43
+
44
+ ### 🆕 Added
45
+
46
+ - Добавлена команда `spectrum token rotate` для ротации `GITLAB_PRIVATE_TOKEN` и пролива в CI/CD variables групп и проектов.
47
+
42
48
  ## 🚀 [1.2.2] - 2026-03-10
43
49
 
44
50
  ### 🛠 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 |
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-admin-pat` (admin PAT только в памяти), `resolve-user` (`GET /user`), `check-access` (все группы и проекты из конфига доступны).
134
143
 
135
144
  ## 🔄 Workflow релиза
136
145
 
@@ -185,6 +194,39 @@ spectrum-cli/
185
194
  4. Печатает diff только по изменениям: лишние пути (`-`) и недостающие (`+`) с цветовой подсветкой
186
195
  5. Завершает команду с ошибкой, если есть расхождения
187
196
 
197
+ ### `spectrum token rotate`
198
+
199
+ Ротация PAT `GITLAB_PRIVATE_TOKEN` на текущем пользователе admin PAT и пролив значения в CI/CD variables групп и проектов.
200
+
201
+ **Конфиг:** `~/.config/spectrum-cli/config.yaml`
202
+
203
+ Если файла нет, команда создаст его:
204
+
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
212
+ ```
213
+
214
+ - `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`.
219
+ - Для каждой группы и проекта CI variable `GITLAB_PRIVATE_TOKEN` удаляется (если есть) и создается заново как masked and hidden.
220
+ - Description переменной: `PAT от бота <bot>, владелец Колосов. Истекает YYYY-MM-DD.`
221
+ - В конце новый PAT печатается один раз. Повторно получить его нельзя.
222
+
223
+ 1. Загружает или создает конфиг
224
+ 2. Запрашивает admin PAT
225
+ 3. Проверяет доступ ко всем группам и проектам
226
+ 4. Удаляет старый PAT `GITLAB_PRIVATE_TOKEN` и создает новый
227
+ 5. Обновляет CI variables во всех целях
228
+ 6. Печатает новый PAT и предупреждение
229
+
188
230
  ## 📝 Работа с Changelog
189
231
 
190
232
  ### `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.0",
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,448 @@
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, colors } = require('./utils');
7
+ const { runCommand } = require('./command-executor');
8
+
9
+ const TOKEN_NAME = 'GITLAB_PRIVATE_TOKEN';
10
+ const TOKEN_SCOPES = ['api', 'read_repository', 'write_repository', 'read_registry'];
11
+
12
+ function getConfigPath() {
13
+ return path.join(os.homedir(), '.config', 'spectrum-cli', 'config.yaml');
14
+ }
15
+
16
+ function defaultConfigContent() {
17
+ return [
18
+ 'bot: bot',
19
+ 'token_ttl_months: 12',
20
+ 'groups: []',
21
+ 'projects: []',
22
+ ''
23
+ ].join('\n');
24
+ }
25
+
26
+ function stripQuotes(value) {
27
+ const text = String(value || '').trim();
28
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
29
+ return text.slice(1, -1);
30
+ }
31
+ return text;
32
+ }
33
+
34
+ function parseConfig(content) {
35
+ const result = { bot: null, tokenTtlMonths: null, groups: [], projects: [] };
36
+ let section = null;
37
+
38
+ for (const rawLine of String(content || '').split('\n')) {
39
+ const line = rawLine.replace(/\r$/, '');
40
+ const trimmed = line.trim();
41
+ if (!trimmed || trimmed.startsWith('#')) {
42
+ continue;
43
+ }
44
+
45
+ const isTopLevel = !line.startsWith(' ') && !line.startsWith('\t');
46
+ const botMatch = trimmed.match(/^bot:\s*(.+)$/);
47
+ if (isTopLevel && botMatch) {
48
+ result.bot = stripQuotes(botMatch[1]);
49
+ section = null;
50
+ continue;
51
+ }
52
+
53
+ const ttlMatch = trimmed.match(/^token_ttl_months:\s*(.+)$/);
54
+ if (isTopLevel && ttlMatch) {
55
+ result.tokenTtlMonths = Number(stripQuotes(ttlMatch[1]));
56
+ section = null;
57
+ continue;
58
+ }
59
+
60
+ if (isTopLevel && /^groups:\s*$/.test(trimmed)) {
61
+ section = 'groups';
62
+ continue;
63
+ }
64
+
65
+ if (isTopLevel && /^projects:\s*$/.test(trimmed)) {
66
+ section = 'projects';
67
+ continue;
68
+ }
69
+
70
+ const itemMatch = line.match(/^\s*-\s+(.+)$/);
71
+ if (itemMatch && (section === 'groups' || section === 'projects')) {
72
+ result[section].push(stripQuotes(itemMatch[1]));
73
+ }
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ function parseGitlabUrl(raw) {
80
+ const url = new URL(String(raw || '').trim());
81
+ const pathname = url.pathname.replace(/^\//, '').replace(/\.git$/, '').replace(/\/$/, '');
82
+ if (!url.origin || !pathname) {
83
+ throw new Error('invalid gitlab url');
84
+ }
85
+ return {
86
+ origin: url.origin,
87
+ path: pathname,
88
+ encodedPath: encodeURIComponent(pathname),
89
+ url: String(raw).trim()
90
+ };
91
+ }
92
+
93
+ function addMonths(date, months) {
94
+ const result = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + Number(months), date.getUTCDate()));
95
+ return result.toISOString().slice(0, 10);
96
+ }
97
+
98
+ function buildDescription(bot, expiresAt) {
99
+ return `PAT от бота ${bot}, владелец Колосов. Истекает ${expiresAt}.`;
100
+ }
101
+
102
+ function validateConfig(config) {
103
+ if (!config.bot) {
104
+ return { ok: false, reason: 'В конфиге отсутствует поле bot.' };
105
+ }
106
+ if (!Number.isInteger(config.tokenTtlMonths) || config.tokenTtlMonths <= 0) {
107
+ return { ok: false, reason: 'В конфиге поле token_ttl_months должно быть положительным целым числом.' };
108
+ }
109
+ if (config.groups.length === 0 && config.projects.length === 0) {
110
+ return { ok: false, reason: 'В конфиге пустые списки groups и projects.' };
111
+ }
112
+ return { ok: true };
113
+ }
114
+
115
+ function parseTargets(urls, kind) {
116
+ const targets = [];
117
+ for (const raw of urls) {
118
+ try {
119
+ targets.push({ kind, ...parseGitlabUrl(raw) });
120
+ } catch (error) {
121
+ return { ok: false, reason: `Некорректный URL ${kind}: ${raw}` };
122
+ }
123
+ }
124
+ return { ok: true, targets };
125
+ }
126
+
127
+ async function promptAdminPat() {
128
+ return new Promise((resolve) => {
129
+ const stdin = process.stdin;
130
+ const stdout = process.stdout;
131
+ stdout.write('🔑 Введите admin PAT: ');
132
+
133
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
134
+ const rl = readline.createInterface({ input: stdin, output: stdout });
135
+ rl.question('', (answer) => {
136
+ rl.close();
137
+ resolve(String(answer || '').trim());
138
+ });
139
+ return;
140
+ }
141
+
142
+ stdin.setRawMode(true);
143
+ stdin.resume();
144
+ stdin.setEncoding('utf8');
145
+ let value = '';
146
+ const onData = (char) => {
147
+ if (char === '\n' || char === '\r' || char === '\u0004') {
148
+ stdin.setRawMode(false);
149
+ stdin.pause();
150
+ stdin.removeListener('data', onData);
151
+ stdout.write('\n');
152
+ resolve(value.trim());
153
+ return;
154
+ }
155
+ if (char === '\u0003') {
156
+ stdin.setRawMode(false);
157
+ process.exit(1);
158
+ }
159
+ if (char === '\u007f' || char === '\b') {
160
+ value = value.slice(0, -1);
161
+ return;
162
+ }
163
+ value += char;
164
+ };
165
+ stdin.on('data', onData);
166
+ });
167
+ }
168
+
169
+ async function gitlabRequest(origin, adminPat, method, apiPath, body) {
170
+ const url = `${origin}/api/v4${apiPath}`;
171
+ const headers = { 'PRIVATE-TOKEN': adminPat };
172
+ const options = { method, headers };
173
+ if (body !== undefined) {
174
+ headers['Content-Type'] = 'application/json';
175
+ options.body = JSON.stringify(body);
176
+ }
177
+
178
+ const response = await fetch(url, options);
179
+ const text = await response.text();
180
+ let data = null;
181
+ if (text) {
182
+ try {
183
+ data = JSON.parse(text);
184
+ } catch (error) {
185
+ data = null;
186
+ }
187
+ }
188
+
189
+ return {
190
+ ok: response.ok,
191
+ status: response.status,
192
+ data,
193
+ headers: response.headers
194
+ };
195
+ }
196
+
197
+ async function gitlabGetAll(origin, adminPat, apiPath) {
198
+ const items = [];
199
+ let page = 1;
200
+
201
+ while (true) {
202
+ const separator = apiPath.includes('?') ? '&' : '?';
203
+ const result = await gitlabRequest(origin, adminPat, 'GET', `${apiPath}${separator}per_page=100&page=${page}`);
204
+ if (!result.ok) {
205
+ return result;
206
+ }
207
+
208
+ const batch = Array.isArray(result.data) ? result.data : [];
209
+ items.push(...batch);
210
+
211
+ const nextPage = result.headers && typeof result.headers.get === 'function'
212
+ ? result.headers.get('x-next-page')
213
+ : '';
214
+ if (!nextPage) {
215
+ break;
216
+ }
217
+ page = Number(nextPage);
218
+ if (!page) {
219
+ break;
220
+ }
221
+ }
222
+
223
+ return { ok: true, status: 200, data: items };
224
+ }
225
+
226
+ function loadOrCreateConfig() {
227
+ const configPath = getConfigPath();
228
+ const configDir = path.dirname(configPath);
229
+
230
+ if (!fs.existsSync(configPath)) {
231
+ logSuccess('📄', 'Конфиг не найден, создаю %s', configPath);
232
+ fs.mkdirSync(configDir, { recursive: true });
233
+ fs.writeFileSync(configPath, defaultConfigContent(), 'utf8');
234
+ logSuccess('📄', 'Конфиг создан. Проверьте groups и projects перед следующей ротацией.');
235
+ } else {
236
+ logSuccess('📄', 'Читаю конфиг %s', configPath);
237
+ }
238
+
239
+ let content = '';
240
+ try {
241
+ content = fs.readFileSync(configPath, 'utf8');
242
+ } catch (error) {
243
+ return { ok: false, reason: `Не удалось прочитать конфиг ${configPath}.` };
244
+ }
245
+
246
+ const parsed = parseConfig(content);
247
+ const valid = validateConfig(parsed);
248
+ if (!valid.ok) {
249
+ return valid;
250
+ }
251
+
252
+ const groups = parseTargets(parsed.groups, 'группы');
253
+ if (!groups.ok) {
254
+ return groups;
255
+ }
256
+ const projects = parseTargets(parsed.projects, 'проекта');
257
+ if (!projects.ok) {
258
+ return projects;
259
+ }
260
+
261
+ const targets = groups.targets.concat(projects.targets);
262
+ logSuccess('📄', 'Конфиг загружен: bot=%s, ttl=%s мес., групп=%s, проектов=%s', parsed.bot, String(parsed.tokenTtlMonths), String(parsed.groups.length), String(parsed.projects.length));
263
+
264
+ return {
265
+ ok: true,
266
+ data: {
267
+ configPath,
268
+ bot: parsed.bot,
269
+ tokenTtlMonths: parsed.tokenTtlMonths,
270
+ targets
271
+ }
272
+ };
273
+ }
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 не указан.' };
280
+ }
281
+ logSuccess('🔑', 'Admin PAT принят в память.');
282
+ return { ok: true, data: { adminPat } };
283
+ }
284
+
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 } };
294
+ }
295
+
296
+ async function checkTargetsAccess(ctx) {
297
+ const total = ctx.targets.length;
298
+ for (let index = 0; index < total; index += 1) {
299
+ const target = ctx.targets[index];
300
+ const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
301
+ 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}`);
303
+ if (!result.ok) {
304
+ return { ok: false, reason: `${target.kind === 'группы' ? 'Группа' : 'Проект'} недоступен: ${target.url} (HTTP ${result.status}).` };
305
+ }
306
+ logSuccess('✅', '[%s/%s] %s доступна: %s', String(index + 1), String(total), target.kind === 'группы' ? 'Группа' : 'Проект', target.path);
307
+ }
308
+ return { ok: true };
309
+ }
310
+
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
+ async function updateTargetVariable(ctx, target, index, total) {
354
+ const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
355
+ const label = target.kind === 'группы' ? 'группе' : 'проекте';
356
+ logSuccess('📦', '[%s/%s] Обновляю CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
357
+
358
+ const listed = await gitlabGetAll(ctx.origin, ctx.adminPat, `/${endpoint}/${target.encodedPath}/variables`);
359
+ if (!listed.ok) {
360
+ logError('❌', 'Не удалось получить CI variables для %s (HTTP %s).', target.path, String(listed.status));
361
+ return false;
362
+ }
363
+
364
+ const exists = (listed.data || []).some((variable) => variable.key === TOKEN_NAME);
365
+ if (exists) {
366
+ 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}`);
368
+ if (!deleted.ok && deleted.status !== 204) {
369
+ logError('❌', 'Не удалось удалить CI variable в %s (HTTP %s).', target.path, String(deleted.status));
370
+ return false;
371
+ }
372
+ logSuccess('🗑', '[%s/%s] Старая CI variable удалена в %s %s', String(index + 1), String(total), label, target.path);
373
+ } else {
374
+ logSuccess('🔎', '[%s/%s] CI variable %s в %s %s отсутствует, будет создана.', String(index + 1), String(total), TOKEN_NAME, label, target.path);
375
+ }
376
+
377
+ 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`, {
379
+ key: TOKEN_NAME,
380
+ value: ctx.newToken,
381
+ masked_and_hidden: true,
382
+ description: ctx.variableDescription
383
+ });
384
+ if (!created.ok) {
385
+ logError('❌', 'Не удалось создать CI variable в %s (HTTP %s).', target.path, String(created.status));
386
+ return false;
387
+ }
388
+
389
+ logSuccess('📝', '[%s/%s] CI variable обновлена в %s %s', String(index + 1), String(total), label, target.path);
390
+ return true;
391
+ }
392
+
393
+ async function updateCiVariables(ctx) {
394
+ const total = ctx.targets.length;
395
+ logSuccess('📦', 'Начинаю обновление CI variables: целей %s', String(total));
396
+ for (let index = 0; index < total; index += 1) {
397
+ const ok = await updateTargetVariable(ctx, ctx.targets[index], index, total);
398
+ if (!ok) {
399
+ return false;
400
+ }
401
+ }
402
+ logSuccess('📦', 'Все CI variables обновлены (%s).', String(total));
403
+ return true;
404
+ }
405
+
406
+ function printNewToken(ctx) {
407
+ console.log(` ${colors.yellow}⚠️ Новый ${TOKEN_NAME} больше нельзя будет получить. Сохраните его сейчас:${colors.reset}`);
408
+ console.log(ctx.newToken);
409
+ return true;
410
+ }
411
+
412
+ function tokenRotate() {
413
+ return runCommand({
414
+ name: 'token rotate',
415
+ checks: [
416
+ { name: 'load-config', run: loadOrCreateConfig },
417
+ { name: 'ask-admin-pat', run: askAdminPat },
418
+ { name: 'resolve-user', run: resolveCurrentUser },
419
+ { name: 'check-access', run: checkTargetsAccess }
420
+ ],
421
+ steps: [
422
+ { name: 'rotate-pat', run: rotatePat },
423
+ { name: 'update-ci-variables', run: updateCiVariables },
424
+ { name: 'print-token', run: printNewToken }
425
+ ]
426
+ });
427
+ }
428
+
429
+ module.exports = {
430
+ TOKEN_NAME,
431
+ TOKEN_SCOPES,
432
+ getConfigPath,
433
+ defaultConfigContent,
434
+ parseConfig,
435
+ parseGitlabUrl,
436
+ addMonths,
437
+ buildDescription,
438
+ validateConfig,
439
+ promptAdminPat,
440
+ loadOrCreateConfig,
441
+ askAdminPat,
442
+ resolveCurrentUser,
443
+ checkTargetsAccess,
444
+ rotatePat,
445
+ updateCiVariables,
446
+ printNewToken,
447
+ tokenRotate
448
+ };