spectrum-cli 1.2.2 → 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 +10 -0
- package/README.md +42 -0
- package/index.js +10 -0
- package/package.json +1 -1
- package/src/UNRELEASED.md +4 -0
- package/src/changelog.js +2 -1
- package/src/release.js +81 -5
- package/src/token.js +448 -0
- package/src/version.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### 💥 Breaking change
|
|
11
|
+
|
|
12
|
+
_Список обратно несовместимых изменений._
|
|
13
|
+
|
|
10
14
|
### 🆕 Added
|
|
11
15
|
|
|
12
16
|
_Список новой функциональности._
|
|
@@ -35,6 +39,12 @@ _Список правок для обеспечения безопасност
|
|
|
35
39
|
|
|
36
40
|
_Список правок для обеспечения технической поддержки._
|
|
37
41
|
|
|
42
|
+
## 🚀 [2.1.0] - 2026-08-17
|
|
43
|
+
|
|
44
|
+
### 🆕 Added
|
|
45
|
+
|
|
46
|
+
- Добавлена команда `spectrum token rotate` для ротации `GITLAB_PRIVATE_TOKEN` и пролива в CI/CD variables групп и проектов.
|
|
47
|
+
|
|
38
48
|
## 🚀 [1.2.2] - 2026-03-10
|
|
39
49
|
|
|
40
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
package/src/UNRELEASED.md
CHANGED
package/src/changelog.js
CHANGED
|
@@ -195,7 +195,7 @@ function detectSectionFromBranch() {
|
|
|
195
195
|
const sections = {
|
|
196
196
|
support: ['### 📦 Support', '### 🔐 Security'],
|
|
197
197
|
bugfix: ['### 🪲 Fixed'],
|
|
198
|
-
feature: ['### 🆕 Added', '### 🛠 Changed', '### 📜 Deprecated', '### 🗑 Removed']
|
|
198
|
+
feature: ['### 💥 Breaking change', '### 🆕 Added', '### 🛠 Changed', '### 📜 Deprecated', '### 🗑 Removed']
|
|
199
199
|
};
|
|
200
200
|
|
|
201
201
|
if (branchLower.includes('support')) {
|
|
@@ -212,6 +212,7 @@ function detectSectionFromBranch() {
|
|
|
212
212
|
async function selectSection(availableSections) {
|
|
213
213
|
if (availableSections.length === 0) {
|
|
214
214
|
availableSections = [
|
|
215
|
+
'### 💥 Breaking change',
|
|
215
216
|
'### 🆕 Added',
|
|
216
217
|
'### 🛠 Changed',
|
|
217
218
|
'### 📜 Deprecated',
|
package/src/release.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
2
3
|
const { goToDevBranch, goToMainBranch, updateCurrentBranch } = require('./git');
|
|
3
|
-
const { changelogChangeHeader, changelogRemoveEmptyChapters, changelogAddUnreleasedBlock
|
|
4
|
+
const { changelogChangeHeader, changelogRemoveEmptyChapters, changelogAddUnreleasedBlock } = require('./changelog');
|
|
4
5
|
const path = require('path');
|
|
5
|
-
const { getVersion, logSuccess, logError, execCommand, getCurrentBranch, getMainBranch, getMergeRequestUrl } = require('./utils');
|
|
6
|
+
const { getVersion, logSuccess, logError, execCommand, getCurrentBranch, getMainBranch, getMergeRequestUrl, getPackageManager } = require('./utils');
|
|
6
7
|
const { runCommand } = require('./command-executor');
|
|
8
|
+
const { upVersion, updateVersionFile } = require('./version');
|
|
7
9
|
const {
|
|
8
10
|
requireGitRepo,
|
|
9
11
|
requireCleanWorkingTree,
|
|
@@ -58,6 +60,68 @@ function releaseCheckChangelogLint() {
|
|
|
58
60
|
return true;
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
function detectBumpType() {
|
|
64
|
+
const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
|
|
65
|
+
const lines = changelog.split('\n');
|
|
66
|
+
|
|
67
|
+
let inUnreleased = false;
|
|
68
|
+
let currentSection = null;
|
|
69
|
+
const sectionsWithEntries = new Set();
|
|
70
|
+
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (line.startsWith('## [Unreleased]')) {
|
|
73
|
+
inUnreleased = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (inUnreleased && line.startsWith('## ')) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (inUnreleased && line.startsWith('### ')) {
|
|
80
|
+
currentSection = line.trim();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (inUnreleased && currentSection && line.startsWith('- ')) {
|
|
84
|
+
sectionsWithEntries.add(currentSection);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (sectionsWithEntries.has('### 💥 Breaking change')) {
|
|
89
|
+
return 'major';
|
|
90
|
+
}
|
|
91
|
+
if (sectionsWithEntries.has('### 🆕 Added')) {
|
|
92
|
+
return 'minor';
|
|
93
|
+
}
|
|
94
|
+
return 'patch';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function runInstall() {
|
|
98
|
+
const pm = getPackageManager() || 'npm';
|
|
99
|
+
if (!execCommand(`${pm} install`)) {
|
|
100
|
+
logError('❌', 'Не удалось выполнить %s install.', pm);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
logSuccess('📦', 'Lock-файл обновлен (%s install).', pm);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function releaseCommit() {
|
|
108
|
+
const lockFiles = { npm: 'package-lock.json', yarn: 'yarn.lock', bun: 'bun.lockb' };
|
|
109
|
+
const pm = getPackageManager();
|
|
110
|
+
const filesToAdd = ['CHANGELOG.md', 'package.json'];
|
|
111
|
+
if (pm && lockFiles[pm]) {
|
|
112
|
+
filesToAdd.push(lockFiles[pm]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const addSuccess = execCommand(`git add ${filesToAdd.join(' ')}`);
|
|
116
|
+
const commitSuccess = execCommand('git commit --message "📝 Обновить changelog и версию." --no-verify');
|
|
117
|
+
|
|
118
|
+
if (addSuccess && commitSuccess) {
|
|
119
|
+
logSuccess('📝', 'Коммит с обновленным changelog и версией создан.');
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
61
125
|
function releaseClose() {
|
|
62
126
|
return runCommand({
|
|
63
127
|
name: 'release close',
|
|
@@ -112,15 +176,26 @@ function releaseStart() {
|
|
|
112
176
|
{ name: 'changelog-exists', run: () => requireFileExists('CHANGELOG.md') },
|
|
113
177
|
{ name: 'unreleased-template-exists', run: () => requireFileExists(path.join(__dirname, 'UNRELEASED.md')) },
|
|
114
178
|
{ name: 'changelog-prettier-check', run: requireChangelogFormatted },
|
|
115
|
-
{
|
|
179
|
+
{
|
|
180
|
+
name: 'detect-bump-type',
|
|
181
|
+
run: (ctx) => {
|
|
182
|
+
const bumpType = detectBumpType();
|
|
183
|
+
const newVersion = upVersion(ctx.version, bumpType);
|
|
184
|
+
const branchCheck = requireReleaseBranchMissing(`release/${newVersion}`);
|
|
185
|
+
if (!branchCheck.ok) return branchCheck;
|
|
186
|
+
return { ok: true, data: { bumpType, newVersion } };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
116
189
|
],
|
|
117
190
|
steps: [
|
|
191
|
+
{ name: 'bump-version', run: (ctx) => updateVersionFile(ctx.bumpType) },
|
|
192
|
+
{ name: 'run-install', run: runInstall },
|
|
118
193
|
{ name: 'change-header', run: changelogChangeHeader },
|
|
119
194
|
{ name: 'remove-empty-chapters', run: changelogRemoveEmptyChapters },
|
|
120
195
|
{ name: 'add-unreleased-block', run: changelogAddUnreleasedBlock },
|
|
121
196
|
{ name: 'lint-changelog', run: releaseCheckChangelogLint },
|
|
122
197
|
{ name: 'create-release-branch', run: releaseCreate },
|
|
123
|
-
{ name: 'commit-
|
|
198
|
+
{ name: 'commit-release', run: releaseCommit },
|
|
124
199
|
{ name: 'push-release', run: releasePush }
|
|
125
200
|
]
|
|
126
201
|
});
|
|
@@ -130,5 +205,6 @@ module.exports = {
|
|
|
130
205
|
releaseCreate,
|
|
131
206
|
releasePush,
|
|
132
207
|
releaseClose,
|
|
133
|
-
releaseStart
|
|
208
|
+
releaseStart,
|
|
209
|
+
detectBumpType
|
|
134
210
|
};
|
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
|
+
};
|