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