spectrum-cli 3.2.0 → 4.0.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 +23 -1
- package/README.md +56 -26
- package/index.js +6 -174
- package/package.json +4 -4
- package/src/changelog/commands.js +44 -0
- package/src/changelog/format.js +51 -0
- package/src/changelog/fragments.js +223 -0
- package/src/{hotfix-changelog.js → changelog/hotfix.js} +2 -2
- package/src/changelog/index.js +7 -0
- package/src/{open-release.js → changelog/open-release.js} +6 -6
- package/src/changelog/release-block.js +80 -0
- package/src/chart/deploy.js +216 -0
- package/src/chart/helmrelease.js +90 -0
- package/src/chart/index.js +30 -0
- package/src/chart/metadata.js +114 -0
- package/src/chart/registry.js +139 -0
- package/src/chart/routes.js +244 -0
- package/src/chart/start.js +98 -0
- package/src/chart/verify.js +166 -0
- package/src/cli/command-executor.js +181 -0
- package/src/cli/commands.js +136 -0
- package/src/{development.js → cli/development.js} +1 -1
- package/src/cli/help.js +55 -0
- package/src/cli/run-action.js +28 -0
- package/src/{update-check.js → cli/update-check.js} +8 -2
- package/src/{git.js → git/index.js} +4 -3
- package/src/hotfix/checks.js +144 -0
- package/src/hotfix/close.js +46 -0
- package/src/hotfix/deploy.js +37 -0
- package/src/hotfix/git.js +35 -0
- package/src/hotfix/index.js +5 -0
- package/src/hotfix/start.js +57 -0
- package/src/integrations/gitlab-url.js +16 -0
- package/src/preflight/changelog.js +130 -0
- package/src/preflight/chart.js +57 -0
- package/src/preflight/git.js +133 -0
- package/src/preflight/helmrelease.js +201 -0
- package/src/preflight/index.js +10 -0
- package/src/preflight/result.js +18 -0
- package/src/preflight/source.js +95 -0
- package/src/preflight/tags.js +139 -0
- package/src/release/close.js +39 -0
- package/src/release/index.js +7 -0
- package/src/{stable-tags.js → release/stable-tags.js} +1 -1
- package/src/release/start.js +51 -0
- package/src/release/steps.js +83 -0
- package/src/release/version.js +20 -0
- package/src/token/config.js +163 -0
- package/src/token/gitlab-api.js +59 -0
- package/src/token/index.js +6 -0
- package/src/token/rotate.js +157 -0
- package/src/changelog.js +0 -418
- package/src/chart.js +0 -1071
- package/src/command-executor.js +0 -70
- package/src/hotfix.js +0 -208
- package/src/preflight.js +0 -796
- package/src/release.js +0 -207
- package/src/token.js +0 -399
- /package/src/{changelog-config.js → changelog/config.js} +0 -0
- /package/src/{utils.js → common/utils.js} +0 -0
- /package/src/{version.js → common/version.js} +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { changelogBuildRelease, changelogRemoveFragments } = require('../changelog');
|
|
2
|
+
const { requireReleaseState, requireReleaseFragments, appendOpenRelease } = require('../changelog/open-release');
|
|
3
|
+
const { runCommand } = require('../cli/command-executor');
|
|
4
|
+
const { requireGitRepo, requireCleanWorkingTree, requireCurrentBranchUpToDateWithRemote, requireMainAndDevBranches, requireOnDevBranch, requireDevContainsRemoteMain, requireReleaseVersionAvailable, requireLatestStableVersion, requireFileExists, requireChangelogFormatted } = require('../preflight');
|
|
5
|
+
const { CHANGELOG_FILE } = require('../changelog/config');
|
|
6
|
+
const { detectBumpType, resolveReleaseVersion } = require('./version');
|
|
7
|
+
const { releaseFormatChangelog, releaseCheckChangelogLint, releaseCommit, releasePush } = require('./steps');
|
|
8
|
+
function releaseStart() {
|
|
9
|
+
return runCommand({
|
|
10
|
+
name: 'release start',
|
|
11
|
+
checks: [
|
|
12
|
+
{ name: 'git-repo', run: requireGitRepo },
|
|
13
|
+
{ name: 'clean-working-tree', run: requireCleanWorkingTree },
|
|
14
|
+
{ name: 'branch-up-to-date', run: requireCurrentBranchUpToDateWithRemote },
|
|
15
|
+
{ name: 'main-and-dev-branches', run: requireMainAndDevBranches },
|
|
16
|
+
{ name: 'on-dev-branch', run: requireOnDevBranch },
|
|
17
|
+
{ name: 'dev-contains-main', run: requireDevContainsRemoteMain },
|
|
18
|
+
{ name: 'stable-version', run: requireLatestStableVersion },
|
|
19
|
+
{ name: 'changelog-exists', run: () => requireFileExists(CHANGELOG_FILE) },
|
|
20
|
+
{ name: 'release-state', requires: ['mainBranch', 'stableVersion'], run: requireReleaseState },
|
|
21
|
+
{ name: 'changelog-prettier-check', run: requireChangelogFormatted },
|
|
22
|
+
{ name: 'changelog-fragments', run: requireReleaseFragments },
|
|
23
|
+
{
|
|
24
|
+
name: 'detect-bump-type',
|
|
25
|
+
requires: ['changelogFragments', 'stableVersion'],
|
|
26
|
+
run: (ctx) => {
|
|
27
|
+
const resolved = ctx.openReleaseVersion
|
|
28
|
+
? { bumpType: detectBumpType(ctx.changelogFragments), newVersion: ctx.openReleaseVersion }
|
|
29
|
+
: resolveReleaseVersion(ctx.stableVersion, ctx.changelogFragments);
|
|
30
|
+
if (!resolved) {
|
|
31
|
+
return { ok: false, reason: 'Не удалось вычислить release-версию от последнего стабильного тега.' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const versionCheck = requireReleaseVersionAvailable(resolved.newVersion);
|
|
35
|
+
if (!versionCheck.ok) return versionCheck;
|
|
36
|
+
return { ok: true, data: resolved };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
steps: [
|
|
41
|
+
{ name: 'build-release-changelog', run: (ctx) => ctx.openReleaseVersion ? appendOpenRelease(ctx) : changelogBuildRelease(ctx) },
|
|
42
|
+
{ name: 'format-changelog', run: releaseFormatChangelog },
|
|
43
|
+
{ name: 'lint-changelog', run: releaseCheckChangelogLint },
|
|
44
|
+
{ name: 'remove-changelog-fragments', run: (ctx) => !ctx.changelogFragments.length || changelogRemoveFragments(ctx) },
|
|
45
|
+
{ name: 'commit-release', run: releaseCommit },
|
|
46
|
+
{ name: 'push-dev-and-main', run: releasePush }
|
|
47
|
+
]
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { releaseStart };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const { logSuccess, logError, execCommand, execSilent, getCurrentBranch, getMainBranch } = require('../common/utils');
|
|
2
|
+
const { requireChangelogFormatted, getPrettierRunner } = require('../preflight');
|
|
3
|
+
const { CHANGELOG_FILE, CHANGELOG_DIR } = require('../changelog/config');
|
|
4
|
+
function releaseFormatChangelog() {
|
|
5
|
+
const runner = getPrettierRunner();
|
|
6
|
+
if (!runner) {
|
|
7
|
+
logError('❌', 'Prettier недоступен.');
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (!execCommand(`${runner} --write ${CHANGELOG_FILE}`)) {
|
|
11
|
+
logError('❌', 'Не удалось отформатировать %s с помощью Prettier.', CHANGELOG_FILE);
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
logSuccess('🎨', 'Файл %s отформатирован.', CHANGELOG_FILE);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function releaseCheckChangelogLint() {
|
|
19
|
+
const result = requireChangelogFormatted();
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
logError('❌', result.reason || 'Файл CHANGELOG.md не прошел линтинг.');
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function releaseCommit(context) {
|
|
28
|
+
const paths = context.changelogFragments?.length ? `${CHANGELOG_FILE} ${CHANGELOG_DIR}` : CHANGELOG_FILE;
|
|
29
|
+
if (!execCommand(`git add --all -- ${paths}`)) return false;
|
|
30
|
+
const staged = execSilent('git diff --cached --name-only');
|
|
31
|
+
if (staged === null) return false;
|
|
32
|
+
if (!staged.trim()) {
|
|
33
|
+
logSuccess('📋', 'Новых changelog-записей нет; пустой коммит не создаётся.');
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (!execCommand(`git commit --message "📝 Подготовить релиз ${context.newVersion}." --no-verify`)) return false;
|
|
37
|
+
|
|
38
|
+
logSuccess('📝', 'Коммит со схлопнутыми changelog fragments создан.');
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getReleasePushCommand(context) {
|
|
43
|
+
const devBranch = context && context.devBranch;
|
|
44
|
+
const targetBranch = (context && context.mainBranch) || getMainBranch();
|
|
45
|
+
if (!devBranch || !targetBranch) return null;
|
|
46
|
+
|
|
47
|
+
return `git push --atomic origin ${devBranch}:${devBranch} ${devBranch}:${targetBranch}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function releasePush(context) {
|
|
51
|
+
const command = getReleasePushCommand(context);
|
|
52
|
+
if (!command) {
|
|
53
|
+
logError('❌', 'Не удалось сформировать команду публикации релиза.');
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!execCommand(command)) {
|
|
58
|
+
logError(
|
|
59
|
+
'❌',
|
|
60
|
+
'Релиз не отправлен в dev и main/master. Убедитесь, что dev содержит актуальный main/master и прямой push разрешен. Повторите: %s',
|
|
61
|
+
command
|
|
62
|
+
);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const devBranch = context.devBranch;
|
|
67
|
+
const targetBranch = context.mainBranch || getMainBranch();
|
|
68
|
+
logSuccess('📤', 'Релиз атомарно отправлен в origin/%s и origin/%s.', devBranch, targetBranch);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pushDev(context) {
|
|
73
|
+
const devBranch = context.devBranch || getCurrentBranch();
|
|
74
|
+
const command = `git push origin ${devBranch}`;
|
|
75
|
+
if (!execCommand(command)) {
|
|
76
|
+
logError('❌', 'Dev-ветка не отправлена. Повторите: %s', command);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
logSuccess('📤', 'Ветка %s отправлена.', devBranch);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { releaseFormatChangelog, releaseCheckChangelogLint, releaseCommit, getReleasePushCommand, releasePush, pushDev };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const { upVersion } = require('../common/version');
|
|
2
|
+
function detectBumpType(fragments) {
|
|
3
|
+
const bumpTypes = new Set((fragments || []).map((fragment) => fragment.bump));
|
|
4
|
+
if (bumpTypes.has('major')) {
|
|
5
|
+
return 'major';
|
|
6
|
+
}
|
|
7
|
+
if (bumpTypes.has('minor')) {
|
|
8
|
+
return 'minor';
|
|
9
|
+
}
|
|
10
|
+
return 'patch';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveReleaseVersion(stableVersion, fragments) {
|
|
14
|
+
const bumpType = detectBumpType(fragments);
|
|
15
|
+
const newVersion = upVersion(stableVersion, bumpType);
|
|
16
|
+
if (!newVersion) return null;
|
|
17
|
+
return { bumpType, newVersion };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { detectBumpType, resolveReleaseVersion };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { logSuccess } = require('../common/utils');
|
|
5
|
+
const { isDryRun } = require('../cli/command-executor');
|
|
6
|
+
const { parseGitlabUrl } = require('../integrations/gitlab-url');
|
|
7
|
+
const TOKEN_NAME = 'GITLAB_PRIVATE_TOKEN';
|
|
8
|
+
|
|
9
|
+
function getConfigPath() {
|
|
10
|
+
return path.join(os.homedir(), '.config', 'spectrum-cli', 'config.yaml');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function defaultConfigContent() {
|
|
14
|
+
return [
|
|
15
|
+
'bot: bot',
|
|
16
|
+
'token_ttl_months: 12',
|
|
17
|
+
'groups: []',
|
|
18
|
+
'projects: []',
|
|
19
|
+
''
|
|
20
|
+
].join('\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stripQuotes(value) {
|
|
24
|
+
const text = String(value || '').trim();
|
|
25
|
+
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
26
|
+
return text.slice(1, -1);
|
|
27
|
+
}
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseConfig(content) {
|
|
32
|
+
const result = { bot: null, tokenTtlMonths: null, groups: [], projects: [] };
|
|
33
|
+
let section = null;
|
|
34
|
+
|
|
35
|
+
for (const rawLine of String(content || '').split('\n')) {
|
|
36
|
+
const line = rawLine.replace(/\r$/, '');
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isTopLevel = !line.startsWith(' ') && !line.startsWith('\t');
|
|
43
|
+
const botMatch = trimmed.match(/^bot:\s*(.+)$/);
|
|
44
|
+
if (isTopLevel && botMatch) {
|
|
45
|
+
result.bot = stripQuotes(botMatch[1]);
|
|
46
|
+
section = null;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ttlMatch = trimmed.match(/^token_ttl_months:\s*(.+)$/);
|
|
51
|
+
if (isTopLevel && ttlMatch) {
|
|
52
|
+
result.tokenTtlMonths = Number(stripQuotes(ttlMatch[1]));
|
|
53
|
+
section = null;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (isTopLevel && /^groups:\s*(?:\[\s*\])?\s*$/.test(trimmed)) {
|
|
58
|
+
section = 'groups';
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isTopLevel && /^projects:\s*(?:\[\s*\])?\s*$/.test(trimmed)) {
|
|
63
|
+
section = 'projects';
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const itemMatch = line.match(/^\s*-\s+(.+)$/);
|
|
68
|
+
if (itemMatch && (section === 'groups' || section === 'projects')) {
|
|
69
|
+
result[section].push(stripQuotes(itemMatch[1]));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function addMonths(date, months) {
|
|
77
|
+
const result = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + Number(months), date.getUTCDate()));
|
|
78
|
+
return result.toISOString().slice(0, 10);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildDescription(bot, expiresAt) {
|
|
82
|
+
return `PAT от бота ${bot}, владелец Колосов. Истекает ${expiresAt}.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateConfig(config) {
|
|
86
|
+
if (!config.bot) {
|
|
87
|
+
return { ok: false, reason: 'В конфиге отсутствует поле bot.' };
|
|
88
|
+
}
|
|
89
|
+
if (!Number.isInteger(config.tokenTtlMonths) || config.tokenTtlMonths <= 0) {
|
|
90
|
+
return { ok: false, reason: 'В конфиге поле token_ttl_months должно быть положительным целым числом.' };
|
|
91
|
+
}
|
|
92
|
+
if (config.groups.length === 0 && config.projects.length === 0) {
|
|
93
|
+
return { ok: false, reason: 'В конфиге пустые списки groups и projects.' };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseTargets(urls, kind) {
|
|
99
|
+
const targets = [];
|
|
100
|
+
for (const raw of urls) {
|
|
101
|
+
try {
|
|
102
|
+
targets.push({ kind, ...parseGitlabUrl(raw) });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { ok: false, reason: `Некорректный URL ${kind}: ${raw}` };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, targets };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function loadOrCreateConfig() {
|
|
111
|
+
const configPath = getConfigPath();
|
|
112
|
+
const configDir = path.dirname(configPath);
|
|
113
|
+
|
|
114
|
+
if (!fs.existsSync(configPath)) {
|
|
115
|
+
if (isDryRun()) {
|
|
116
|
+
return { ok: false, reason: `Конфиг ${configPath} не найден. В режиме --dry он не создаётся.` };
|
|
117
|
+
}
|
|
118
|
+
logSuccess('📄', 'Конфиг не найден, создаю %s', configPath);
|
|
119
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
120
|
+
fs.writeFileSync(configPath, defaultConfigContent(), 'utf8');
|
|
121
|
+
logSuccess('📄', 'Конфиг создан. Проверьте groups и projects перед следующей ротацией.');
|
|
122
|
+
} else {
|
|
123
|
+
logSuccess('📄', 'Читаю конфиг %s', configPath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let content = '';
|
|
127
|
+
try {
|
|
128
|
+
content = fs.readFileSync(configPath, 'utf8');
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return { ok: false, reason: `Не удалось прочитать конфиг ${configPath}.` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parsed = parseConfig(content);
|
|
134
|
+
const valid = validateConfig(parsed);
|
|
135
|
+
if (!valid.ok) {
|
|
136
|
+
return valid;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const groups = parseTargets(parsed.groups, 'группы');
|
|
140
|
+
if (!groups.ok) {
|
|
141
|
+
return groups;
|
|
142
|
+
}
|
|
143
|
+
const projects = parseTargets(parsed.projects, 'проекта');
|
|
144
|
+
if (!projects.ok) {
|
|
145
|
+
return projects;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const targets = groups.targets.concat(projects.targets);
|
|
149
|
+
logSuccess('📄', 'Конфиг загружен: bot=%s, ttl=%s мес., групп=%s, проектов=%s', parsed.bot, String(parsed.tokenTtlMonths), String(parsed.groups.length), String(parsed.projects.length));
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
data: {
|
|
154
|
+
configPath,
|
|
155
|
+
bot: parsed.bot,
|
|
156
|
+
tokenTtlMonths: parsed.tokenTtlMonths,
|
|
157
|
+
origin: targets[0].origin,
|
|
158
|
+
targets
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { TOKEN_NAME, getConfigPath, defaultConfigContent, parseConfig, addMonths, buildDescription, validateConfig, loadOrCreateConfig };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
|
|
2
|
+
async function gitlabRequest(origin, accessToken, method, apiPath, body) {
|
|
3
|
+
const url = `${origin}/api/v4${apiPath}`;
|
|
4
|
+
const headers = { 'PRIVATE-TOKEN': accessToken };
|
|
5
|
+
const options = { method, headers };
|
|
6
|
+
if (body !== undefined) {
|
|
7
|
+
headers['Content-Type'] = 'application/json';
|
|
8
|
+
options.body = JSON.stringify(body);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const response = await fetch(url, options);
|
|
12
|
+
const text = await response.text();
|
|
13
|
+
let data = null;
|
|
14
|
+
if (text) {
|
|
15
|
+
try {
|
|
16
|
+
data = JSON.parse(text);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
data = null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
ok: response.ok,
|
|
24
|
+
status: response.status,
|
|
25
|
+
data,
|
|
26
|
+
headers: response.headers
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function gitlabGetAll(origin, accessToken, apiPath) {
|
|
31
|
+
const items = [];
|
|
32
|
+
let page = 1;
|
|
33
|
+
|
|
34
|
+
while (true) {
|
|
35
|
+
const separator = apiPath.includes('?') ? '&' : '?';
|
|
36
|
+
const result = await gitlabRequest(origin, accessToken, 'GET', `${apiPath}${separator}per_page=100&page=${page}`);
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const batch = Array.isArray(result.data) ? result.data : [];
|
|
42
|
+
items.push(...batch);
|
|
43
|
+
|
|
44
|
+
const nextPage = result.headers && typeof result.headers.get === 'function'
|
|
45
|
+
? result.headers.get('x-next-page')
|
|
46
|
+
: '';
|
|
47
|
+
if (!nextPage) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
page = Number(nextPage);
|
|
51
|
+
if (!page) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { ok: true, status: 200, data: items };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { gitlabRequest, gitlabGetAll };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const { logSuccess, logError } = require('../common/utils');
|
|
3
|
+
const { runCommand } = require('../cli/command-executor');
|
|
4
|
+
const { TOKEN_NAME, addMonths, buildDescription, loadOrCreateConfig } = require('./config');
|
|
5
|
+
const { gitlabRequest, gitlabGetAll } = require('./gitlab-api');
|
|
6
|
+
async function promptHidden(message) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const stdin = process.stdin;
|
|
9
|
+
const stdout = process.stdout;
|
|
10
|
+
stdout.write(message);
|
|
11
|
+
|
|
12
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
13
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
14
|
+
rl.question('', (answer) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
resolve(String(answer || '').trim());
|
|
17
|
+
});
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
stdin.setRawMode(true);
|
|
22
|
+
stdin.resume();
|
|
23
|
+
stdin.setEncoding('utf8');
|
|
24
|
+
let value = '';
|
|
25
|
+
const onData = (char) => {
|
|
26
|
+
if (char === '\n' || char === '\r' || char === '\u0004') {
|
|
27
|
+
stdin.setRawMode(false);
|
|
28
|
+
stdin.pause();
|
|
29
|
+
stdin.removeListener('data', onData);
|
|
30
|
+
stdout.write('\n');
|
|
31
|
+
resolve(value.trim());
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (char === '\u0003') {
|
|
35
|
+
stdin.setRawMode(false);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
if (char === '\u007f' || char === '\b') {
|
|
39
|
+
value = value.slice(0, -1);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
value += char;
|
|
43
|
+
};
|
|
44
|
+
stdin.on('data', onData);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function askTokens(ctx) {
|
|
49
|
+
logSuccess('🔑', 'Запрашиваю owner PAT для работы с CI variables. Значение останется только в памяти.');
|
|
50
|
+
const ownerToken = await require('./index').promptHidden('🔑 Введите owner PAT: ');
|
|
51
|
+
if (!ownerToken) {
|
|
52
|
+
return { ok: false, reason: 'Owner PAT не указан.' };
|
|
53
|
+
}
|
|
54
|
+
logSuccess('🔑', 'Owner PAT принят в память.');
|
|
55
|
+
|
|
56
|
+
logSuccess('🔑', 'Запрашиваю %s для записи в CI variables. Значение останется только в памяти.', TOKEN_NAME);
|
|
57
|
+
const ciToken = await require('./index').promptHidden(`🔑 Введите ${TOKEN_NAME}: `);
|
|
58
|
+
if (!ciToken) {
|
|
59
|
+
return { ok: false, reason: `${TOKEN_NAME} не указан.` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const expiresAt = addMonths(new Date(), ctx.tokenTtlMonths);
|
|
63
|
+
logSuccess('🔑', '%s принят в память. Description будет с датой %s.', TOKEN_NAME, expiresAt);
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
data: {
|
|
67
|
+
ownerToken,
|
|
68
|
+
ciToken,
|
|
69
|
+
expiresAt,
|
|
70
|
+
variableDescription: buildDescription(ctx.bot, expiresAt)
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function checkTargetsAccess(ctx) {
|
|
76
|
+
const total = ctx.targets.length;
|
|
77
|
+
for (let index = 0; index < total; index += 1) {
|
|
78
|
+
const target = ctx.targets[index];
|
|
79
|
+
const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
|
|
80
|
+
logSuccess('🔍', '[%s/%s] Проверяю доступ к %s %s', String(index + 1), String(total), target.kind, target.url);
|
|
81
|
+
const result = await gitlabRequest(ctx.origin, ctx.ownerToken, 'GET', `/${endpoint}/${target.encodedPath}`);
|
|
82
|
+
if (!result.ok) {
|
|
83
|
+
return { ok: false, reason: `${target.kind === 'группы' ? 'Группа' : 'Проект'} недоступен: ${target.url} (HTTP ${result.status}).` };
|
|
84
|
+
}
|
|
85
|
+
logSuccess('✅', '[%s/%s] %s доступна: %s', String(index + 1), String(total), target.kind === 'группы' ? 'Группа' : 'Проект', target.path);
|
|
86
|
+
}
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function updateTargetVariable(ctx, target, index, total) {
|
|
91
|
+
const endpoint = target.kind === 'группы' ? 'groups' : 'projects';
|
|
92
|
+
const label = target.kind === 'группы' ? 'группе' : 'проекте';
|
|
93
|
+
logSuccess('📦', '[%s/%s] Обновляю CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
|
|
94
|
+
|
|
95
|
+
const listed = await gitlabGetAll(ctx.origin, ctx.ownerToken, `/${endpoint}/${target.encodedPath}/variables`);
|
|
96
|
+
if (!listed.ok) {
|
|
97
|
+
logError('❌', 'Не удалось получить CI variables для %s (HTTP %s).', target.path, String(listed.status));
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const exists = (listed.data || []).some((variable) => variable.key === TOKEN_NAME);
|
|
102
|
+
if (exists) {
|
|
103
|
+
logSuccess('🗑', '[%s/%s] Удаляю старую CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
|
|
104
|
+
const deleted = await gitlabRequest(ctx.origin, ctx.ownerToken, 'DELETE', `/${endpoint}/${target.encodedPath}/variables/${TOKEN_NAME}`);
|
|
105
|
+
if (!deleted.ok && deleted.status !== 204) {
|
|
106
|
+
logError('❌', 'Не удалось удалить CI variable в %s (HTTP %s).', target.path, String(deleted.status));
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
logSuccess('🗑', '[%s/%s] Старая CI variable удалена в %s %s', String(index + 1), String(total), label, target.path);
|
|
110
|
+
} else {
|
|
111
|
+
logSuccess('🔎', '[%s/%s] CI variable %s в %s %s отсутствует, будет создана.', String(index + 1), String(total), TOKEN_NAME, label, target.path);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
logSuccess('📝', '[%s/%s] Создаю CI variable %s в %s %s', String(index + 1), String(total), TOKEN_NAME, label, target.path);
|
|
115
|
+
const created = await gitlabRequest(ctx.origin, ctx.ownerToken, 'POST', `/${endpoint}/${target.encodedPath}/variables`, {
|
|
116
|
+
key: TOKEN_NAME,
|
|
117
|
+
value: ctx.ciToken,
|
|
118
|
+
masked_and_hidden: true,
|
|
119
|
+
description: ctx.variableDescription
|
|
120
|
+
});
|
|
121
|
+
if (!created.ok) {
|
|
122
|
+
logError('❌', 'Не удалось создать CI variable в %s (HTTP %s).', target.path, String(created.status));
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
logSuccess('📝', '[%s/%s] CI variable обновлена в %s %s', String(index + 1), String(total), label, target.path);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function updateCiVariables(ctx) {
|
|
131
|
+
const total = ctx.targets.length;
|
|
132
|
+
logSuccess('📦', 'Начинаю обновление CI variables: целей %s', String(total));
|
|
133
|
+
for (let index = 0; index < total; index += 1) {
|
|
134
|
+
const ok = await updateTargetVariable(ctx, ctx.targets[index], index, total);
|
|
135
|
+
if (!ok) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
logSuccess('📦', 'Все CI variables обновлены (%s).', String(total));
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function tokenRotate() {
|
|
144
|
+
return runCommand({
|
|
145
|
+
name: 'token rotate',
|
|
146
|
+
checks: [
|
|
147
|
+
{ name: 'load-config', run: loadOrCreateConfig },
|
|
148
|
+
{ name: 'ask-tokens', requires: ['tokenTtlMonths', 'bot'], run: askTokens },
|
|
149
|
+
{ name: 'check-access', requires: ['targets', 'origin', 'ownerToken'], run: checkTargetsAccess }
|
|
150
|
+
],
|
|
151
|
+
steps: [
|
|
152
|
+
{ name: 'update-ci-variables', run: updateCiVariables }
|
|
153
|
+
]
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { promptHidden, askTokens, checkTargetsAccess, updateCiVariables, tokenRotate };
|