spectrum-cli 1.1.1 → 1.2.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.
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { execSilent, execCommand, getCurrentBranch, getMainBranch, getDevelopBranch, getVersion } = require('./utils');
5
+
6
+ const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
7
+
8
+ function ok(data) {
9
+ return { ok: true, data };
10
+ }
11
+
12
+ function fail(reason) {
13
+ return { ok: false, reason };
14
+ }
15
+
16
+ function toPosixPath(filePath) {
17
+ return String(filePath || '').replace(/\\/g, '/');
18
+ }
19
+
20
+ function getPrettierRunner() {
21
+ const npxVersion = execSilent('npx --yes prettier --version');
22
+ if (npxVersion) return 'npx --yes prettier';
23
+ const directVersion = execSilent('prettier --version');
24
+ if (directVersion) return 'prettier';
25
+ return null;
26
+ }
27
+
28
+ function requireGitRepo() {
29
+ if (!execCommand('git rev-parse --is-inside-work-tree')) {
30
+ return fail('Текущая директория не является git-репозиторием.');
31
+ }
32
+ return ok();
33
+ }
34
+
35
+ function requireCleanWorkingTree() {
36
+ const status = execSilent('git status --porcelain');
37
+ if (status === null) {
38
+ return fail('Не удалось получить состояние рабочего дерева git.');
39
+ }
40
+ if (status.trim() !== '') {
41
+ return fail('Рабочее дерево не чистое.');
42
+ }
43
+ return ok();
44
+ }
45
+
46
+ function requireMainAndDevBranches() {
47
+ const branches = execSilent('git branch -r');
48
+ if (!branches) {
49
+ return fail('Не удалось получить список удаленных веток.');
50
+ }
51
+
52
+ const mainBranch = getMainBranch();
53
+ const devBranch = getDevelopBranch();
54
+ const hasMain = branches.includes(`origin/${mainBranch}`);
55
+ const hasDev = branches.includes(`origin/${devBranch}`);
56
+
57
+ if (!hasMain || !hasDev) {
58
+ return fail(`Отсутствуют обязательные удаленные ветки (origin/${mainBranch}, origin/${devBranch}).`);
59
+ }
60
+
61
+ return ok({ mainBranch, devBranch });
62
+ }
63
+
64
+ function requireCurrentBranch(expectedBranch) {
65
+ const currentBranch = getCurrentBranch();
66
+ if (!currentBranch) {
67
+ return fail('Не удалось определить текущую ветку.');
68
+ }
69
+ if (currentBranch !== expectedBranch) {
70
+ return fail(`Текущая ветка "${currentBranch}", ожидалась "${expectedBranch}".`);
71
+ }
72
+ return ok({ currentBranch });
73
+ }
74
+
75
+ function requireOnMainBranch() {
76
+ const mainBranch = getMainBranch();
77
+ const currentBranch = getCurrentBranch();
78
+ if (!currentBranch) {
79
+ return fail('Не удалось определить текущую ветку.');
80
+ }
81
+ if (currentBranch !== mainBranch) {
82
+ return fail(`Текущая ветка "${currentBranch}", ожидалась "${mainBranch}".`);
83
+ }
84
+ return ok({ mainBranch, currentBranch });
85
+ }
86
+
87
+ function requireRemoteOrigin() {
88
+ const remoteUrl = execSilent('git remote get-url origin');
89
+ if (!remoteUrl) {
90
+ return fail('Удаленный репозиторий "origin" не настроен.');
91
+ }
92
+ return ok({ remoteOrigin: remoteUrl });
93
+ }
94
+
95
+ function requireRemoteReachable() {
96
+ const remoteHeads = execSilent('git ls-remote --heads origin');
97
+ if (remoteHeads === null) {
98
+ return fail('Не удалось получить доступ к удаленному репозиторию "origin".');
99
+ }
100
+ return ok();
101
+ }
102
+
103
+ function requireCurrentBranchUpToDateWithRemote() {
104
+ const currentBranch = getCurrentBranch();
105
+ if (!currentBranch) {
106
+ return fail('Не удалось определить текущую ветку.');
107
+ }
108
+
109
+ if (!execCommand('git fetch --all --prune --jobs=10')) {
110
+ return fail('Не удалось получить изменения с удаленного репозитория.');
111
+ }
112
+
113
+ const upstreamBranch = execSilent('git rev-parse --abbrev-ref --symbolic-full-name "@{u}"');
114
+ if (!upstreamBranch || !upstreamBranch.trim()) {
115
+ return fail(`Для текущей ветки "${currentBranch}" не настроена upstream-ветка.`);
116
+ }
117
+
118
+ const aheadBehind = execSilent(`git rev-list --left-right --count HEAD...${upstreamBranch.trim()}`);
119
+ if (!aheadBehind) {
120
+ return fail(`Не удалось сравнить текущую ветку "${currentBranch}" с "${upstreamBranch.trim()}".`);
121
+ }
122
+
123
+ const parts = aheadBehind.trim().split(/\s+/);
124
+ const behindBy = Number(parts[1] || 0);
125
+ if (!Number.isFinite(behindBy)) {
126
+ return fail(`Не удалось разобрать информацию ahead/behind для "${upstreamBranch.trim()}".`);
127
+ }
128
+
129
+ if (behindBy > 0) {
130
+ return fail(`В удаленной ветке есть новые коммиты (${behindBy}). Выполните "git pull" и повторите попытку.`);
131
+ }
132
+
133
+ return ok({ currentBranch, upstreamBranch: upstreamBranch.trim() });
134
+ }
135
+
136
+ function requireFileExists(filePath) {
137
+ if (!fs.existsSync(filePath)) {
138
+ return fail(`Обязательный файл "${filePath}" не существует.`);
139
+ }
140
+ return ok();
141
+ }
142
+
143
+ function requirePackageVersion() {
144
+ const version = getVersion();
145
+ if (!version) {
146
+ return fail('Не удалось прочитать версию из package.json.');
147
+ }
148
+ if (!SEMVER_PATTERN.test(version)) {
149
+ return fail(`Версия "${version}" не соответствует semver.`);
150
+ }
151
+ return ok({ version });
152
+ }
153
+
154
+ function requireTagMissing(tagName) {
155
+ const localTag = execSilent(`git tag -l "${tagName}"`);
156
+ if (localTag && localTag.trim()) {
157
+ return fail(`Тег "${tagName}" уже существует локально.`);
158
+ }
159
+
160
+ const remoteTag = execSilent(`git ls-remote --tags origin "refs/tags/${tagName}"`);
161
+ if (remoteTag && remoteTag.trim()) {
162
+ return fail(`Тег "${tagName}" уже существует на origin.`);
163
+ }
164
+
165
+ return ok();
166
+ }
167
+
168
+ function requireOnReleaseBranch() {
169
+ const currentBranch = getCurrentBranch();
170
+ if (!currentBranch) {
171
+ return fail('Не удалось определить текущую ветку.');
172
+ }
173
+ if (!currentBranch.startsWith('release/')) {
174
+ return fail(`Текущая ветка "${currentBranch}", ожидалась "release/*".`);
175
+ }
176
+ return ok({ currentBranch });
177
+ }
178
+
179
+ function requireReleaseBranchMissing(branchName) {
180
+ const localBranch = execSilent(`git branch --list "${branchName}"`);
181
+ if (localBranch && localBranch.trim()) {
182
+ return fail(`Ветка "${branchName}" уже существует локально.`);
183
+ }
184
+ const remoteBranch = execSilent(`git ls-remote --heads origin "refs/heads/${branchName}"`);
185
+ if (remoteBranch && remoteBranch.trim()) {
186
+ return fail(`Ветка "${branchName}" уже существует на origin.`);
187
+ }
188
+ return ok();
189
+ }
190
+
191
+ function requirePrettierAvailable() {
192
+ const runner = getPrettierRunner();
193
+ if (!runner) {
194
+ return fail('Prettier недоступен.');
195
+ }
196
+ return ok({ prettierRunner: runner });
197
+ }
198
+
199
+ function requireChangelogFormatted() {
200
+ const runner = getPrettierRunner();
201
+ if (!runner) {
202
+ return fail('Prettier недоступен.');
203
+ }
204
+ if (!execCommand(`${runner} --check CHANGELOG.md`)) {
205
+ return fail('Файл CHANGELOG.md не прошел проверку Prettier.');
206
+ }
207
+ return ok({ prettierRunner: runner });
208
+ }
209
+
210
+ function requireSingleChart() {
211
+ const chartsDir = 'charts';
212
+ if (!fs.existsSync(chartsDir)) {
213
+ return fail('Не удалось найти директорию charts.');
214
+ }
215
+
216
+ const entries = fs.readdirSync(chartsDir, { withFileTypes: true });
217
+ const chartFiles = entries
218
+ .filter((entry) => entry.isDirectory())
219
+ .map((entry) => `${chartsDir}/${entry.name}/Chart.yaml`)
220
+ .filter((chartFilePath) => fs.existsSync(chartFilePath));
221
+
222
+ if (chartFiles.length === 0) {
223
+ return fail('Не удалось найти Chart.yaml по пути charts/<chart-name>/Chart.yaml.');
224
+ }
225
+
226
+ if (chartFiles.length > 1) {
227
+ return fail(`Найдено несколько chart-файлов: ${chartFiles.join(', ')}.`);
228
+ }
229
+
230
+ const chartFilePath = chartFiles[0];
231
+ const chartYaml = fs.readFileSync(chartFilePath, 'utf8');
232
+ const nameMatch = chartYaml.match(/^\s*name:\s*([^\s#]+)\s*$/m);
233
+ const chartName = nameMatch ? nameMatch[1] : null;
234
+ if (!chartName) {
235
+ return fail(`Не удалось прочитать имя чарта из ${chartFilePath}.`);
236
+ }
237
+
238
+ return ok({ chartFilePath, chartName });
239
+ }
240
+
241
+ function findValuesYamlFiles(baseDir = 'charts') {
242
+ if (!fs.existsSync(baseDir)) {
243
+ return [];
244
+ }
245
+
246
+ const stack = [baseDir];
247
+ const files = [];
248
+
249
+ while (stack.length > 0) {
250
+ const current = stack.pop();
251
+ let entries = [];
252
+ try {
253
+ entries = fs.readdirSync(current, { withFileTypes: true });
254
+ } catch (error) {
255
+ return [];
256
+ }
257
+
258
+ for (const entry of entries) {
259
+ const fullPath = path.join(current, entry.name);
260
+ if (entry.isDirectory()) {
261
+ stack.push(fullPath);
262
+ continue;
263
+ }
264
+
265
+ if (entry.isFile() && entry.name === 'values.yaml') {
266
+ files.push(toPosixPath(fullPath));
267
+ }
268
+ }
269
+ }
270
+
271
+ return files.sort();
272
+ }
273
+
274
+ function findHelmReleaseFiles(baseDir = '.') {
275
+ if (!fs.existsSync(baseDir)) {
276
+ return [];
277
+ }
278
+
279
+ const skipDirs = new Set(['.git', 'node_modules']);
280
+ const stack = [baseDir];
281
+ const files = [];
282
+
283
+ while (stack.length > 0) {
284
+ const current = stack.pop();
285
+ let entries = [];
286
+ try {
287
+ entries = fs.readdirSync(current, { withFileTypes: true });
288
+ } catch (error) {
289
+ return [];
290
+ }
291
+
292
+ for (const entry of entries) {
293
+ const fullPath = path.join(current, entry.name);
294
+ if (entry.isDirectory()) {
295
+ if (!skipDirs.has(entry.name)) {
296
+ stack.push(fullPath);
297
+ }
298
+ continue;
299
+ }
300
+
301
+ if (entry.isFile() && entry.name.toLowerCase() === 'helmrelease.yaml') {
302
+ files.push(toPosixPath(fullPath));
303
+ }
304
+ }
305
+ }
306
+
307
+ return files.sort();
308
+ }
309
+
310
+ function requireHelmReleaseFiles() {
311
+ const helmReleaseFiles = findHelmReleaseFiles('.');
312
+ if (helmReleaseFiles.length === 0) {
313
+ return fail('Не удалось найти файлы helmrelease.yaml в репозитории.');
314
+ }
315
+ return ok({ helmReleaseFiles });
316
+ }
317
+
318
+ function requireSingleValuesYaml() {
319
+ const valuesFiles = findValuesYamlFiles('charts');
320
+ if (valuesFiles.length === 0) {
321
+ return fail('Не удалось найти values.yaml по маске charts/**/values.yaml.');
322
+ }
323
+ if (valuesFiles.length > 1) {
324
+ return fail(`Найдено несколько файлов values.yaml: ${valuesFiles.join(', ')}.`);
325
+ }
326
+ return ok({ valuesYamlPath: valuesFiles[0] });
327
+ }
328
+
329
+ function extractYamlList(content, sectionName) {
330
+ const lines = String(content || '').split('\n');
331
+ let inIngress = false;
332
+ let inPaths = false;
333
+ let inSection = false;
334
+ let ingressIndent = 0;
335
+ let pathsIndent = 0;
336
+ let sectionIndent = 0;
337
+ const values = [];
338
+
339
+ for (const rawLine of lines) {
340
+ const line = rawLine.replace(/\r$/, '');
341
+ const indent = line.match(/^\s*/)[0].length;
342
+ const trimmed = line.trim();
343
+
344
+ if (!trimmed || trimmed.startsWith('#')) {
345
+ continue;
346
+ }
347
+
348
+ if (/^ingress:\s*(#.*)?$/.test(trimmed)) {
349
+ inIngress = true;
350
+ inPaths = false;
351
+ inSection = false;
352
+ ingressIndent = indent;
353
+ continue;
354
+ }
355
+
356
+ if (inIngress && indent <= ingressIndent && !/^ingress:\s*(#.*)?$/.test(trimmed)) {
357
+ inIngress = false;
358
+ inPaths = false;
359
+ inSection = false;
360
+ }
361
+
362
+ if (!inIngress) {
363
+ continue;
364
+ }
365
+
366
+ if (/^paths:\s*(#.*)?$/.test(trimmed)) {
367
+ inPaths = true;
368
+ inSection = false;
369
+ pathsIndent = indent;
370
+ continue;
371
+ }
372
+
373
+ if (inPaths && indent <= pathsIndent && !/^paths:\s*(#.*)?$/.test(trimmed)) {
374
+ inPaths = false;
375
+ inSection = false;
376
+ }
377
+
378
+ if (!inPaths) {
379
+ continue;
380
+ }
381
+
382
+ const sectionRegex = new RegExp(`^${sectionName}:\\s*(#.*)?$`);
383
+ if (sectionRegex.test(trimmed)) {
384
+ inSection = true;
385
+ sectionIndent = indent;
386
+ continue;
387
+ }
388
+
389
+ if (inSection && indent <= sectionIndent && !sectionRegex.test(trimmed)) {
390
+ inSection = false;
391
+ }
392
+
393
+ if (!inSection) {
394
+ continue;
395
+ }
396
+
397
+ const itemMatch = line.match(/^\s*-\s+(.+?)(?:\s+#.*)?$/);
398
+ if (itemMatch) {
399
+ values.push(itemMatch[1].trim());
400
+ }
401
+ }
402
+
403
+ return values;
404
+ }
405
+
406
+ function requireIngressPathSections(valuesYamlPath) {
407
+ if (!valuesYamlPath || !fs.existsSync(valuesYamlPath)) {
408
+ return fail(`Обязательный файл "${valuesYamlPath}" не существует.`);
409
+ }
410
+
411
+ let content = '';
412
+ try {
413
+ content = fs.readFileSync(valuesYamlPath, 'utf8');
414
+ } catch (error) {
415
+ return fail(`Не удалось прочитать "${valuesYamlPath}".`);
416
+ }
417
+
418
+ const api = extractYamlList(content, 'api');
419
+ const pages = extractYamlList(content, 'pages');
420
+ const assets = extractYamlList(content, 'assets');
421
+
422
+ if (api.length === 0) {
423
+ return fail(`Отсутствует или пуст ingress.paths.api в "${valuesYamlPath}".`);
424
+ }
425
+ if (pages.length === 0) {
426
+ return fail(`Отсутствует или пуст ingress.paths.pages в "${valuesYamlPath}".`);
427
+ }
428
+ if (assets.length === 0) {
429
+ return fail(`Отсутствует или пуст ingress.paths.assets в "${valuesYamlPath}".`);
430
+ }
431
+
432
+ return ok({
433
+ valuesIngressPaths: { api, pages, assets }
434
+ });
435
+ }
436
+
437
+ function requireSourcePathDirectory(sourcePath) {
438
+ if (!sourcePath || typeof sourcePath !== 'string') {
439
+ return fail('Требуется путь к исходникам.');
440
+ }
441
+ const resolved = path.resolve(sourcePath);
442
+ if (!fs.existsSync(resolved)) {
443
+ return fail(`Путь к исходникам не существует: "${sourcePath}".`);
444
+ }
445
+
446
+ let stat;
447
+ try {
448
+ stat = fs.statSync(resolved);
449
+ } catch (error) {
450
+ return fail(`Не удалось получить доступ к пути исходников: "${sourcePath}".`);
451
+ }
452
+ if (!stat.isDirectory()) {
453
+ return fail(`Путь к исходникам не является директорией: "${sourcePath}".`);
454
+ }
455
+
456
+ return ok({ sourcePath: resolved });
457
+ }
458
+
459
+ function hasNextConfig(sourcePath) {
460
+ const files = ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs'];
461
+ return files.some((fileName) => fs.existsSync(path.join(sourcePath, fileName)));
462
+ }
463
+
464
+ function requireNextProject(sourcePath) {
465
+ if (!sourcePath) {
466
+ return fail('Требуется путь к исходникам.');
467
+ }
468
+
469
+ const pkgPath = path.join(sourcePath, 'package.json');
470
+ if (!fs.existsSync(pkgPath)) {
471
+ return fail(`Не удалось найти package.json в "${sourcePath}".`);
472
+ }
473
+
474
+ let pkg;
475
+ try {
476
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
477
+ } catch (error) {
478
+ return fail(`Не удалось разобрать package.json в "${sourcePath}".`);
479
+ }
480
+
481
+ const dependencies = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
482
+ const hasNextDependency = typeof dependencies.next === 'string';
483
+ const nextConfigExists = hasNextConfig(sourcePath);
484
+
485
+ if (!hasNextDependency && !nextConfigExists) {
486
+ return fail(`"${sourcePath}" не похож на проект Next.js.`);
487
+ }
488
+
489
+ return ok({
490
+ sourcePackageJsonPath: pkgPath,
491
+ sourcePackageManager: fs.existsSync(path.join(sourcePath, 'yarn.lock'))
492
+ ? 'yarn'
493
+ : fs.existsSync(path.join(sourcePath, 'bun.lockb'))
494
+ ? 'bun'
495
+ : 'npm',
496
+ sourceHasLockfile: Boolean(
497
+ fs.existsSync(path.join(sourcePath, 'package-lock.json')) ||
498
+ fs.existsSync(path.join(sourcePath, 'yarn.lock')) ||
499
+ fs.existsSync(path.join(sourcePath, 'bun.lockb'))
500
+ )
501
+ });
502
+ }
503
+
504
+ function requireBuildCommandSupport(sourcePath) {
505
+ if (!sourcePath) {
506
+ return fail('Требуется путь к исходникам.');
507
+ }
508
+
509
+ const pkgPath = path.join(sourcePath, 'package.json');
510
+ if (!fs.existsSync(pkgPath)) {
511
+ return fail(`Не удалось найти package.json в "${sourcePath}".`);
512
+ }
513
+
514
+ let pkg;
515
+ try {
516
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
517
+ } catch (error) {
518
+ return fail(`Не удалось разобрать package.json в "${sourcePath}".`);
519
+ }
520
+
521
+ if (!pkg.scripts || typeof pkg.scripts.build !== 'string') {
522
+ return fail(`В "${pkgPath}" отсутствует скрипт build.`);
523
+ }
524
+
525
+ return ok({ sourceBuildScript: pkg.scripts.build });
526
+ }
527
+
528
+ module.exports = {
529
+ SEMVER_PATTERN,
530
+ getPrettierRunner,
531
+ requireGitRepo,
532
+ requireCleanWorkingTree,
533
+ requireMainAndDevBranches,
534
+ requireCurrentBranch,
535
+ requireOnMainBranch,
536
+ requireRemoteOrigin,
537
+ requireRemoteReachable,
538
+ requireCurrentBranchUpToDateWithRemote,
539
+ requireFileExists,
540
+ requirePackageVersion,
541
+ requireTagMissing,
542
+ requireOnReleaseBranch,
543
+ requireReleaseBranchMissing,
544
+ requirePrettierAvailable,
545
+ requireChangelogFormatted,
546
+ requireSingleChart,
547
+ findValuesYamlFiles,
548
+ findHelmReleaseFiles,
549
+ extractYamlList,
550
+ requireSingleValuesYaml,
551
+ requireHelmReleaseFiles,
552
+ requireIngressPathSections,
553
+ requireSourcePathDirectory,
554
+ requireNextProject,
555
+ requireBuildCommandSupport
556
+ };