@nitra/cursor 3.19.0 → 3.20.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 +18 -0
- package/bin/n-cursor.js +12 -0
- package/package.json +1 -1
- package/rules/docker/docker.mdc +3 -3
- package/rules/docker/js/lint.mjs +1 -1
- package/rules/docker/lib/docker-hadolint.mjs +27 -55
- package/rules/ga/lint/lint.mjs +18 -54
- package/rules/k8s/lint/lint.mjs +3 -10
- package/rules/nginx-default-tpl/js/template.mjs +39 -1
- package/rules/nginx-default-tpl/nginx-default-tpl.mdc +3 -1
- package/rules/npm-module/npm-module.mdc +1 -1
- package/rules/npm-module/policy/npm_publish_yml/target.json +1 -0
- package/rules/rego/lint/lint.mjs +10 -55
- package/rules/text/lint/lint.mjs +11 -40
- package/rules/worktree/policy/vscode_settings/target.json +5 -0
- package/rules/worktree/policy/vscode_settings/template/settings.json.snippet.json +8 -0
- package/rules/worktree/policy/zed_settings/target.json +5 -0
- package/rules/worktree/policy/zed_settings/template/settings.json.snippet.json +12 -0
- package/rules/worktree/worktree.mdc +52 -0
- package/schemas/target.json +5 -0
- package/scripts/lib/assert-project-root.mjs +74 -0
- package/scripts/lib/ensure-tool.mjs +352 -0
- package/scripts/lib/run-conftest-batch.mjs +6 -28
- package/scripts/lib/run-rule.mjs +61 -5
- package/scripts/lib/template.mjs +29 -3
- package/scripts/lib/worktree-notice.mjs +52 -1
- package/skills/fix/SKILL.md +4 -4
- package/types/bin/n-cursor.d.ts +1 -1
- package/rules/npm-module/policy/npm_publish_yml/npm_publish_yml.rego +0 -87
package/rules/text/lint/lint.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI-обгортка над канонічним `lint-text` (text.mdc):
|
|
3
|
-
* (
|
|
2
|
+
* CLI-обгортка над канонічним `lint-text` (text.mdc): авто-встановлює `shellcheck` і `dotenv-linter`
|
|
3
|
+
* через `ensureTool` (brew/scoop/GitHub Release per-platform), перевіряє наявність `patch`
|
|
4
|
+
* (для авто-фіксу shellcheck); далі послідовно
|
|
4
5
|
* 1) `cspell .` — перевірка правопису з `@nitra/cspell-dict`;
|
|
5
6
|
* 2) `runShellcheckText()` — авто-фікс і фінальна перевірка `*.sh` через `shellcheck`;
|
|
6
7
|
* 3) `runDotenvLinter()` — авто-фікс і фінальна перевірка `.env*` через `dotenv-linter`;
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
*
|
|
10
11
|
* Без preflight локальний прогін може пройти cspell/markdownlint, а CI на ubuntu-latest
|
|
11
12
|
* (де shellcheck передвстановлений, але dotenv-linter — ні) падає на кроці dotenv-linter
|
|
12
|
-
* з неінформативним повідомленням.
|
|
13
|
+
* з неінформативним повідомленням. ensureTool збирає всі відсутні бінарники до першого кроку.
|
|
13
14
|
*
|
|
14
15
|
* Перший ненульовий код з ланцюжка повертається як код виходу; наступні кроки не запускаються.
|
|
15
16
|
* Експортовано як `runLintTextCli` — використовується з `bin/n-cursor.js` як підкоманда `lint-text`.
|
|
@@ -22,6 +23,7 @@ import { platform } from 'node:process'
|
|
|
22
23
|
import { runLintStep } from '../../../scripts/lib/run-lint-step.mjs'
|
|
23
24
|
import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
|
|
24
25
|
import { runStandardLint } from '../../../scripts/lib/run-standard-lint.mjs'
|
|
26
|
+
import { ensureTool } from '../../../scripts/lib/ensure-tool.mjs'
|
|
25
27
|
import { runDotenvLinter } from './run-dotenv-linter.mjs'
|
|
26
28
|
import { runShellcheckText } from './run-shellcheck.mjs'
|
|
27
29
|
import { runV8rWithGlobs } from './run-v8r.mjs'
|
|
@@ -36,22 +38,6 @@ import { runV8rWithGlobs } from './run-v8r.mjs'
|
|
|
36
38
|
* @property {string} successMsg повідомлення на pass-шлях
|
|
37
39
|
*/
|
|
38
40
|
|
|
39
|
-
/** @type {PreflightDep} */
|
|
40
|
-
const SHELLCHECK_PREFLIGHT = {
|
|
41
|
-
bin: 'shellcheck',
|
|
42
|
-
winBins: ['shellcheck.exe'],
|
|
43
|
-
explanation: [
|
|
44
|
-
'Без нього `runShellcheckText()` пропускає перевірку tracked `*.sh` — локально lint-text',
|
|
45
|
-
'може бути зеленим, а CI (shellcheck + patch) падає на тих самих скриптах.'
|
|
46
|
-
].join('\n '),
|
|
47
|
-
install: [
|
|
48
|
-
'macOS: brew install shellcheck',
|
|
49
|
-
'Debian/Ubuntu: sudo apt-get install -y shellcheck',
|
|
50
|
-
'Arch: sudo pacman -S shellcheck'
|
|
51
|
-
],
|
|
52
|
-
successMsg: '✅ shellcheck знайдено в PATH — lint-text перевірить *.sh'
|
|
53
|
-
}
|
|
54
|
-
|
|
55
41
|
/** @type {PreflightDep} */
|
|
56
42
|
const PATCH_PREFLIGHT = {
|
|
57
43
|
bin: 'patch',
|
|
@@ -62,22 +48,6 @@ const PATCH_PREFLIGHT = {
|
|
|
62
48
|
successMsg: '✅ patch знайдено в PATH — shellcheck auto-fix працюватиме'
|
|
63
49
|
}
|
|
64
50
|
|
|
65
|
-
/** @type {PreflightDep} */
|
|
66
|
-
const DOTENV_LINTER_PREFLIGHT = {
|
|
67
|
-
bin: 'dotenv-linter',
|
|
68
|
-
winBins: ['dotenv-linter.exe'],
|
|
69
|
-
explanation: [
|
|
70
|
-
'Без нього не виконається крок `.env*` у lint-text — локально cspell/markdownlint',
|
|
71
|
-
'пройдуть, а CI без Install dotenv-linter впаде неінформативно.'
|
|
72
|
-
].join('\n '),
|
|
73
|
-
install: [
|
|
74
|
-
'macOS: brew install dotenv-linter',
|
|
75
|
-
'Linux: curl -sSfL https://git.io/JLbXn | sh -s -- -b /usr/local/bin',
|
|
76
|
-
'cargo: cargo install dotenv-linter'
|
|
77
|
-
],
|
|
78
|
-
successMsg: '✅ dotenv-linter знайдено в PATH — lint-text перевірить .env*'
|
|
79
|
-
}
|
|
80
|
-
|
|
81
51
|
/**
|
|
82
52
|
* Шукає шлях до бінарника `dep.bin` у `PATH`; на Windows додатково перебирає `dep.winBins`.
|
|
83
53
|
* @param {PreflightDep} dep опис залежності з canon-списку preflight-перевірок
|
|
@@ -128,11 +98,12 @@ function preflight(dep) {
|
|
|
128
98
|
* @returns {number} 0 — все OK, інакше — код першого кроку, що впав
|
|
129
99
|
*/
|
|
130
100
|
function runLintTextSteps() {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
101
|
+
// Auto-install: throws on failure → propagates as exit 1 from runStandardLint
|
|
102
|
+
ensureTool('shellcheck')
|
|
103
|
+
ensureTool('dotenv-linter')
|
|
104
|
+
|
|
105
|
+
// patch is hint-only (system tool)
|
|
106
|
+
if (!preflight(PATCH_PREFLIGHT)) return 1
|
|
136
107
|
|
|
137
108
|
const cspellCode = runLintStep('cspell', 'npx', ['cspell', '.'])
|
|
138
109
|
if (cspellCode !== 0) return cspellCode
|
|
@@ -20,6 +20,58 @@ alwaysApply: true
|
|
|
20
20
|
Слеш у гілці перетворюється на дефіс: `feat/skill-meta` → `.worktrees/feat-skill-meta/`.
|
|
21
21
|
Git-гілка лишається з оригінальним імʼям (`feat/skill-meta`).
|
|
22
22
|
|
|
23
|
+
## Налаштування редакторів
|
|
24
|
+
|
|
25
|
+
Worktree-каталоги — це вкладені копії всього дерева. Якщо редактор індексує їх,
|
|
26
|
+
пошук дає дублі, а file-watcher вантажить диск. Тому редактор має **виключити**
|
|
27
|
+
`.worktrees/` (а для Zed ще й приватний `.claude/worktrees/`) з пошуку та сканування.
|
|
28
|
+
|
|
29
|
+
Канон перевіряється **лише** якщо файл налаштувань уже є — проєктам без VS Code / Zed
|
|
30
|
+
нічого не нав'язується.
|
|
31
|
+
|
|
32
|
+
### VS Code
|
|
33
|
+
|
|
34
|
+
У `.vscode/settings.json` обовʼязково виключи `**/.worktrees/**` з пошуку і з дерева
|
|
35
|
+
файлів. `node_modules` додавати не треба — VS Code виключає його з пошуку дефолтно.
|
|
36
|
+
|
|
37
|
+
```json title=".vscode/settings.json"
|
|
38
|
+
{
|
|
39
|
+
"search.exclude": {
|
|
40
|
+
"**/.worktrees/**": true
|
|
41
|
+
},
|
|
42
|
+
"files.exclude": {
|
|
43
|
+
"**/.worktrees/**": true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Канон (subset — інші ключі дозволені): [settings.json.snippet.json](./policy/vscode_settings/template/settings.json.snippet.json)
|
|
49
|
+
|
|
50
|
+
### Zed
|
|
51
|
+
|
|
52
|
+
Zed **замінює** масив `file_scan_exclusions` цілком (не зливає з дефолтами), тому в
|
|
53
|
+
`.zed/settings.json` перелічуй і дефолти Zed, і `**/.worktrees`, і `**/.claude/worktrees`.
|
|
54
|
+
Перевірка вимагає **всі** записи канону (бо інакше дефолти губляться).
|
|
55
|
+
|
|
56
|
+
```json title=".zed/settings.json"
|
|
57
|
+
{
|
|
58
|
+
"file_scan_exclusions": [
|
|
59
|
+
"**/.git",
|
|
60
|
+
"**/.svn",
|
|
61
|
+
"**/.hg",
|
|
62
|
+
"**/.DS_Store",
|
|
63
|
+
"**/Thumbs.db",
|
|
64
|
+
"**/node_modules",
|
|
65
|
+
"**/.worktrees",
|
|
66
|
+
"**/.claude/worktrees"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Канон (усі елементи обовʼязкові, зайві дозволені): [settings.json.snippet.json](./policy/zed_settings/template/settings.json.snippet.json)
|
|
72
|
+
|
|
73
|
+
`.zed/settings.json` без стабільної схеми в Schema Store — додай його до `.v8rignore` (як `.vscode/*`, див. **text.mdc**).
|
|
74
|
+
|
|
23
75
|
## Команди
|
|
24
76
|
|
|
25
77
|
- **Створити** (опис обовʼязковий): `npx @nitra/cursor worktree add <branch> "<навіщо>"`
|
package/schemas/target.json
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"format": "uri",
|
|
13
13
|
"description": "Опційне посилання на JSON Schema для IDE-валідації; очікувано https://unpkg.com/@nitra/cursor/schemas/target.json"
|
|
14
14
|
},
|
|
15
|
+
"check": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": ["template"],
|
|
18
|
+
"description": "Режим перевірки концерну. 'template' → без власного <name>.rego: канон зі template/<target>.snippet|deny|contains.<ext> звіряється generic deep-subset-ом (checkSnippet/checkDeny/checkContains/checkTextSubset). Сніпет — єдине джерело істини: його зміна одразу змінює enforce, без правок rego й міграторів. Без поля → класичний rego-режим (пара з <name>.rego)."
|
|
19
|
+
},
|
|
15
20
|
"files": {
|
|
16
21
|
"oneOf": [
|
|
17
22
|
{
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard для дефолтної синхронізації `npx @nitra/cursor` (гілка без підкоманди).
|
|
3
|
+
*
|
|
4
|
+
* Дефолтний sync (`runSync` у `bin/n-cursor.js`) скаффолдить у `cwd()` керовані
|
|
5
|
+
* артефакти — `.cursor/rules/`, `.cursor/skills/`, `.claude/`, `AGENTS.md`,
|
|
6
|
+
* `CLAUDE.md`, `.n-cursor.json`, `.gitignore` — і запускає `bun install`. Усе це
|
|
7
|
+
* розраховане на **корінь** проєкту-споживача. Якщо бінар викликати напряму
|
|
8
|
+
* (`bun npm/bin/n-cursor.js`, `node …/n-cursor.js`) з піддиректорії git-репо,
|
|
9
|
+
* `cwd()` — та піддиректорія, і конфіг розкидається не туди.
|
|
10
|
+
*
|
|
11
|
+
* `bun run start`/`npm start` цей кейс не створює (менеджер скидає cwd на корінь
|
|
12
|
+
* пакета), але прямий виклик бінаря — створює. Тому guard прив'язаний до
|
|
13
|
+
* **git-кореня**, а не до конкретного монорепо: CLI публічний і легітимно
|
|
14
|
+
* запускається в корені будь-якого репо-споживача.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import { realpathSync } from 'node:fs'
|
|
18
|
+
import { cwd } from 'node:process'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Корінь git-репозиторію для `dir` через `git rev-parse --show-toplevel`.
|
|
22
|
+
* Повертає realpath-шлях кореня або `null`, якщо `dir` поза git-репо чи `git`
|
|
23
|
+
* недоступний — у такому разі визначити корінь неможливо, тож не блокуємо.
|
|
24
|
+
* @param {string} [dir] каталог, з якого питаємо git
|
|
25
|
+
* @returns {string | null} абсолютний (realpath) корінь репо або null
|
|
26
|
+
*/
|
|
27
|
+
export function gitToplevel(dir = cwd()) {
|
|
28
|
+
const res = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' })
|
|
29
|
+
if (res.status !== 0 || typeof res.stdout !== 'string') return null
|
|
30
|
+
const top = res.stdout.trim()
|
|
31
|
+
return top.length > 0 ? top : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Безпечний realpath: повертає реальний шлях `dir`, а якщо його не існує —
|
|
36
|
+
* сам `dir` без змін (порівняння нижче все одно дасть коректний результат).
|
|
37
|
+
* Потрібен бо `git rev-parse --show-toplevel` віддає realpath (symlink'и
|
|
38
|
+
* розгорнуті), а `cwd()` — ні; без нормалізації корінь під symlink-шляхом
|
|
39
|
+
* (типово `/var` → `/private/var` на macOS) хибно вважався б піддиректорією.
|
|
40
|
+
* @param {string} dir шлях для нормалізації
|
|
41
|
+
* @returns {string} realpath або вихідний шлях
|
|
42
|
+
*/
|
|
43
|
+
function safeRealpath(dir) {
|
|
44
|
+
try {
|
|
45
|
+
return realpathSync(dir)
|
|
46
|
+
} catch {
|
|
47
|
+
return dir
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Кидає помилку, якщо `dir` — піддиректорія git-репозиторію (тобто не його
|
|
53
|
+
* корінь). Поза git-репо (немає toplevel) — пропускає без помилки.
|
|
54
|
+
*
|
|
55
|
+
* Викликати лише перед дефолтним sync (до перших мутацій файлів), не для
|
|
56
|
+
* підкоманд із власним `--root` чи read-only-логікою.
|
|
57
|
+
* @param {string} [dir] каталог, що перевіряємо (типово `cwd()`)
|
|
58
|
+
* @throws {Error} коли `dir` всередині git-репо, але не його корінь
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
61
|
+
export function assertCwdIsProjectRoot(dir = cwd()) {
|
|
62
|
+
const top = gitToplevel(dir)
|
|
63
|
+
if (top === null) return
|
|
64
|
+
const here = safeRealpath(dir)
|
|
65
|
+
if (here === top) return
|
|
66
|
+
throw new Error(
|
|
67
|
+
`❌ @nitra/cursor запущено не в корені проєкту.\n` +
|
|
68
|
+
` Поточний каталог: ${here}\n` +
|
|
69
|
+
` Корінь git-репо: ${top}\n` +
|
|
70
|
+
` Дефолтна синхронізація скаффолдить .cursor/, .claude/, CLAUDE.md, .n-cursor.json\n` +
|
|
71
|
+
` і робить bun install у поточному каталозі — із піддиректорії це розкидало б\n` +
|
|
72
|
+
` конфіг не туди. Перейдіть у корінь репозиторію: cd ${top}`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Авто-встановлення зовнішніх CLI-залежностей пакету `@nitra/cursor`.
|
|
3
|
+
*
|
|
4
|
+
* `ensureTool(toolId)` — єдиний seam резолву зовнішніх бінарників: PATH → кеш → авто-install → hard-fail.
|
|
5
|
+
* Новий тул = один запис у реєстрі `TOOLS`, без дублювання install-логіки в кожному `lint.mjs`/`fix.mjs`.
|
|
6
|
+
*
|
|
7
|
+
* Per-platform matrix: macOS → brew, Windows → scoop (fallback: GitHub Release), Linux → GitHub Release binary.
|
|
8
|
+
* Бінарники кешуються у `~/.cache/@nitra/cursor/bin/` (Linux/Mac), `%LOCALAPPDATA%\@nitra\cursor\bin\` (Win).
|
|
9
|
+
*
|
|
10
|
+
* `ensureHkInstall(hkBin)` — реєструє git pre-commit hook через `hk install`; пропускається в CI.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, renameSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { arch, env, platform } from 'node:process'
|
|
17
|
+
|
|
18
|
+
import { resolveCmd } from '../utils/resolve-cmd.mjs'
|
|
19
|
+
|
|
20
|
+
/** Префікс `v` у git-тегу релізу (`v1.2.3` → `1.2.3`). */
|
|
21
|
+
const TAG_V_PREFIX_RE = /^v/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Повертає каталог керованого кешу бінарників для поточного OS.
|
|
25
|
+
* @returns {string} абсолютний шлях
|
|
26
|
+
*/
|
|
27
|
+
function getCacheDir() {
|
|
28
|
+
if (platform === 'win32') {
|
|
29
|
+
const localAppData = env['LOCALAPPDATA'] ?? join(homedir(), 'AppData', 'Local')
|
|
30
|
+
return join(localAppData, '@nitra', 'cursor', 'bin')
|
|
31
|
+
}
|
|
32
|
+
return join(homedir(), '.cache', '@nitra', 'cursor', 'bin')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Мапить `process.arch` у формат, що вживається в назвах GitHub-release ресурсів.
|
|
37
|
+
* @param {'x64'|'arm64'|string} nodeArch значення `process.arch`
|
|
38
|
+
* @param {'hk'|'conftest'|'actionlint'} style стиль іменування платформи
|
|
39
|
+
* @returns {string} рядок архітектури для asset-шаблону
|
|
40
|
+
*/
|
|
41
|
+
function mapArch(nodeArch, style) {
|
|
42
|
+
if (style === 'actionlint') {
|
|
43
|
+
return nodeArch === 'x64' ? 'amd64' : 'arm64'
|
|
44
|
+
}
|
|
45
|
+
if (style === 'conftest') {
|
|
46
|
+
return nodeArch === 'x64' ? 'x86_64' : 'arm64'
|
|
47
|
+
}
|
|
48
|
+
// hk / shellcheck / dotenv-linter: x64 → x86_64, arm64 → aarch64
|
|
49
|
+
return nodeArch === 'x64' ? 'x86_64' : 'aarch64'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {object} ToolEntry
|
|
54
|
+
* @property {string} brew формула brew (macOS)
|
|
55
|
+
* @property {string|null} scoop назва пакету scoop (Windows); null = недоступний
|
|
56
|
+
* @property {string} github репо у форматі `owner/repo`
|
|
57
|
+
* @property {(ver: string) => string} asset повертає назву release-ресурсу для Linux
|
|
58
|
+
* @property {string} archStyle стиль маппінгу архітектури: 'hk'|'conftest'|'actionlint'
|
|
59
|
+
* @property {boolean} [archive] чи є release-ресурс архівом (tar) — default `true`; `false` = сирий бінарник (download + chmod)
|
|
60
|
+
* @property {((ver: string) => string)|null} [binFinder] для архівів де бінарник не у корені; повертає відносний шлях
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/** @type {Record<string, ToolEntry>} */
|
|
64
|
+
const TOOLS = {
|
|
65
|
+
hk: {
|
|
66
|
+
brew: 'hk',
|
|
67
|
+
scoop: 'hk',
|
|
68
|
+
github: 'jdx/hk',
|
|
69
|
+
archStyle: 'hk',
|
|
70
|
+
asset: _ver => `hk-${mapArch(arch, 'hk')}-unknown-linux-gnu.tar.gz`,
|
|
71
|
+
binFinder: null
|
|
72
|
+
},
|
|
73
|
+
conftest: {
|
|
74
|
+
brew: 'conftest',
|
|
75
|
+
scoop: 'conftest',
|
|
76
|
+
github: 'open-policy-agent/conftest',
|
|
77
|
+
archStyle: 'conftest',
|
|
78
|
+
asset: ver => `conftest_${ver}_Linux_${mapArch(arch, 'conftest')}.tar.gz`,
|
|
79
|
+
binFinder: null
|
|
80
|
+
},
|
|
81
|
+
shellcheck: {
|
|
82
|
+
brew: 'shellcheck',
|
|
83
|
+
scoop: 'shellcheck',
|
|
84
|
+
github: 'koalaman/shellcheck',
|
|
85
|
+
archStyle: 'hk',
|
|
86
|
+
asset: ver => `shellcheck-v${ver}.linux.${mapArch(arch, 'hk')}.tar.xz`,
|
|
87
|
+
binFinder: ver => `shellcheck-v${ver}/shellcheck`
|
|
88
|
+
},
|
|
89
|
+
actionlint: {
|
|
90
|
+
brew: 'actionlint',
|
|
91
|
+
scoop: 'actionlint',
|
|
92
|
+
github: 'rhysd/actionlint',
|
|
93
|
+
archStyle: 'actionlint',
|
|
94
|
+
asset: ver => `actionlint_${ver}_linux_${mapArch(arch, 'actionlint')}.tar.gz`,
|
|
95
|
+
binFinder: null
|
|
96
|
+
},
|
|
97
|
+
'dotenv-linter': {
|
|
98
|
+
brew: 'dotenv-linter',
|
|
99
|
+
scoop: null,
|
|
100
|
+
github: 'dotenv-linter/dotenv-linter',
|
|
101
|
+
archStyle: 'hk',
|
|
102
|
+
asset: _ver => `dotenv-linter-linux-${mapArch(arch, 'hk')}.tar.gz`,
|
|
103
|
+
binFinder: null
|
|
104
|
+
},
|
|
105
|
+
opa: {
|
|
106
|
+
brew: 'opa',
|
|
107
|
+
scoop: 'opa',
|
|
108
|
+
github: 'open-policy-agent/opa',
|
|
109
|
+
archStyle: 'actionlint',
|
|
110
|
+
archive: false,
|
|
111
|
+
asset: _ver => `opa_linux_${mapArch(arch, 'actionlint')}`,
|
|
112
|
+
binFinder: null
|
|
113
|
+
},
|
|
114
|
+
regal: {
|
|
115
|
+
brew: 'regal',
|
|
116
|
+
scoop: null,
|
|
117
|
+
github: 'StyraInc/regal',
|
|
118
|
+
archStyle: 'conftest',
|
|
119
|
+
archive: false,
|
|
120
|
+
asset: _ver => `regal_Linux_${mapArch(arch, 'conftest')}`,
|
|
121
|
+
binFinder: null
|
|
122
|
+
},
|
|
123
|
+
hadolint: {
|
|
124
|
+
brew: 'hadolint',
|
|
125
|
+
scoop: 'hadolint',
|
|
126
|
+
github: 'hadolint/hadolint',
|
|
127
|
+
archStyle: 'conftest',
|
|
128
|
+
archive: false,
|
|
129
|
+
asset: _ver => `hadolint-linux-${mapArch(arch, 'conftest')}`,
|
|
130
|
+
binFinder: null
|
|
131
|
+
},
|
|
132
|
+
kubeconform: {
|
|
133
|
+
brew: 'kubeconform',
|
|
134
|
+
scoop: 'kubeconform',
|
|
135
|
+
github: 'yannh/kubeconform',
|
|
136
|
+
archStyle: 'actionlint',
|
|
137
|
+
asset: _ver => `kubeconform-linux-${mapArch(arch, 'actionlint')}.tar.gz`,
|
|
138
|
+
binFinder: null
|
|
139
|
+
},
|
|
140
|
+
kubescape: {
|
|
141
|
+
brew: 'kubescape',
|
|
142
|
+
scoop: 'kubescape',
|
|
143
|
+
github: 'kubescape/kubescape',
|
|
144
|
+
archStyle: 'actionlint',
|
|
145
|
+
archive: false,
|
|
146
|
+
asset: ver => `kubescape_${ver}_linux_${mapArch(arch, 'actionlint')}`,
|
|
147
|
+
binFinder: null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Отримує останній тег з GitHub Releases API через curl (sync).
|
|
153
|
+
* @param {string} repo репо у форматі `owner/repo`
|
|
154
|
+
* @param {string} curlBin абсолютний шлях до curl
|
|
155
|
+
* @returns {string} рядок версії без префікса `v`, наприклад `0.4.1`
|
|
156
|
+
*/
|
|
157
|
+
function fetchLatestVersion(repo, curlBin) {
|
|
158
|
+
const url = `https://api.github.com/repos/${repo}/releases/latest`
|
|
159
|
+
const r = spawnSync(curlBin, ['-sSL', '-H', 'Accept: application/vnd.github+json', url], { encoding: 'utf8' })
|
|
160
|
+
if (r.error) throw new Error(`curl failed: ${r.error.message}`)
|
|
161
|
+
if (r.status !== 0) throw new Error(`curl exit ${r.status}: ${(r.stderr ?? '').slice(0, 300)}`)
|
|
162
|
+
let parsed
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(r.stdout)
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error(`GitHub API response is not JSON: ${r.stdout.slice(0, 200)}`)
|
|
167
|
+
}
|
|
168
|
+
const tag = parsed['tag_name']
|
|
169
|
+
if (!tag) throw new Error(`GitHub API: tag_name missing for ${repo}`)
|
|
170
|
+
return tag.replace(TAG_V_PREFIX_RE, '')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Завантажує та розпаковує GitHub Release бінарник у кеш-директорію.
|
|
175
|
+
* Повертає абсолютний шлях до бінарника.
|
|
176
|
+
* @param {string} toolId ключ у TOOLS
|
|
177
|
+
* @param {ToolEntry} entry опис тула
|
|
178
|
+
* @param {string} cacheDir абсолютний шлях до кешу
|
|
179
|
+
* @returns {string} абсолютний шлях до готового бінарника
|
|
180
|
+
*/
|
|
181
|
+
function installFromGithub(toolId, entry, cacheDir) {
|
|
182
|
+
const curlBin = resolveCmd('curl')
|
|
183
|
+
if (!curlBin) throw new Error(`curl не знайдено в PATH — потрібен для завантаження ${toolId}`)
|
|
184
|
+
const tarBin = resolveCmd('tar')
|
|
185
|
+
if (!tarBin) throw new Error(`tar не знайдено в PATH — потрібен для встановлення ${toolId}`)
|
|
186
|
+
|
|
187
|
+
const ver = fetchLatestVersion(entry.github, curlBin)
|
|
188
|
+
const assetName = entry.asset(ver)
|
|
189
|
+
const downloadUrl = `https://github.com/${entry.github}/releases/download/v${ver}/${assetName}`
|
|
190
|
+
|
|
191
|
+
mkdirSync(cacheDir, { recursive: true })
|
|
192
|
+
const archivePath = join(cacheDir, assetName)
|
|
193
|
+
|
|
194
|
+
const dlResult = spawnSync(curlBin, ['-sSL', '-o', archivePath, downloadUrl], { encoding: 'utf8' })
|
|
195
|
+
if (dlResult.error) throw new Error(`Завантаження ${toolId} не вдалось: ${dlResult.error.message}`)
|
|
196
|
+
if (dlResult.status !== 0)
|
|
197
|
+
throw new Error(`curl exit ${dlResult.status} при завантаженні ${toolId}: ${(dlResult.stderr ?? '').slice(0, 300)}`)
|
|
198
|
+
|
|
199
|
+
// Сирий бінарник (archive: false) — завантажений файл і є бінарником: перейменовуємо у <toolId> + chmod.
|
|
200
|
+
if (entry.archive === false) {
|
|
201
|
+
const binPath = join(cacheDir, toolId)
|
|
202
|
+
renameSync(archivePath, binPath)
|
|
203
|
+
chmodSync(binPath, 0o755)
|
|
204
|
+
return binPath
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// .tar.xz потребує -J замість -z
|
|
208
|
+
const isXz = assetName.endsWith('.tar.xz')
|
|
209
|
+
const tarFlags = isXz ? ['-xJf'] : ['-xzf']
|
|
210
|
+
const extractResult = spawnSync(tarBin, [...tarFlags, archivePath, '-C', cacheDir], { encoding: 'utf8' })
|
|
211
|
+
if (extractResult.error) throw new Error(`tar failed for ${toolId}: ${extractResult.error.message}`)
|
|
212
|
+
if (extractResult.status !== 0)
|
|
213
|
+
throw new Error(`tar exit ${extractResult.status} для ${toolId}: ${(extractResult.stderr ?? '').slice(0, 300)}`)
|
|
214
|
+
|
|
215
|
+
const binRelPath = entry.binFinder ? entry.binFinder(ver) : toolId
|
|
216
|
+
const binPath = join(cacheDir, binRelPath)
|
|
217
|
+
if (!existsSync(binPath)) {
|
|
218
|
+
throw new Error(`Бінарник ${toolId} не знайдено після розпакування: ${binPath}`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const rmBin = resolveCmd('rm')
|
|
222
|
+
if (rmBin) spawnSync(rmBin, [archivePath])
|
|
223
|
+
|
|
224
|
+
return binPath
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Встановлює тул через brew (macOS). Hard-fail на будь-яку помилку.
|
|
229
|
+
* @param {string} toolId ключ у TOOLS
|
|
230
|
+
* @param {ToolEntry} entry опис тула
|
|
231
|
+
* @returns {string} абсолютний шлях до встановленого бінарника
|
|
232
|
+
*/
|
|
233
|
+
function installViaBrew(toolId, entry) {
|
|
234
|
+
const brewBin = resolveCmd('brew')
|
|
235
|
+
if (!brewBin) throw new Error(`brew не знайдено в PATH. Встанови Homebrew: https://brew.sh`)
|
|
236
|
+
const r = spawnSync(brewBin, ['install', entry.brew], { stdio: 'inherit', encoding: 'utf8' })
|
|
237
|
+
if (r.error) throw new Error(`brew install ${toolId} не вдалось: ${r.error.message}`)
|
|
238
|
+
if (r.status !== 0) throw new Error(`brew install ${toolId} завершився з кодом ${r.status}`)
|
|
239
|
+
const resolved = resolveCmd(toolId)
|
|
240
|
+
if (!resolved) throw new Error(`${toolId} не знайдено в PATH після brew install`)
|
|
241
|
+
return resolved
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Встановлює тул через scoop (Windows). Кидає якщо scoop недоступний або пакет null.
|
|
246
|
+
* @param {string} toolId ключ у TOOLS
|
|
247
|
+
* @param {ToolEntry} entry опис тула
|
|
248
|
+
* @returns {string} абсолютний шлях до встановленого бінарника
|
|
249
|
+
*/
|
|
250
|
+
function installViaScoop(toolId, entry) {
|
|
251
|
+
if (!entry.scoop) {
|
|
252
|
+
throw new Error(`${toolId} недоступний у Scoop. Встанови вручну:\n https://github.com/${entry.github}/releases`)
|
|
253
|
+
}
|
|
254
|
+
const scoopBin = resolveCmd('scoop')
|
|
255
|
+
if (!scoopBin) throw new Error(`scoop не знайдено в PATH. Встанови Scoop: https://scoop.sh`)
|
|
256
|
+
const r = spawnSync(scoopBin, ['install', entry.scoop], { stdio: 'inherit', encoding: 'utf8' })
|
|
257
|
+
if (r.error) throw new Error(`scoop install ${toolId} не вдалось: ${r.error.message}`)
|
|
258
|
+
if (r.status !== 0) throw new Error(`scoop install ${toolId} завершився з кодом ${r.status}`)
|
|
259
|
+
const resolved = resolveCmd(toolId)
|
|
260
|
+
if (!resolved) throw new Error(`${toolId} не знайдено в PATH після scoop install`)
|
|
261
|
+
return resolved
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Виконує авто-встановлення тула відповідно до поточного OS.
|
|
266
|
+
* @param {string} toolId ключ у TOOLS
|
|
267
|
+
* @param {ToolEntry} entry опис тула
|
|
268
|
+
* @param {string} cacheDir каталог кешу для Linux-бінарників
|
|
269
|
+
* @returns {string} абсолютний шлях до бінарника
|
|
270
|
+
*/
|
|
271
|
+
function autoInstall(toolId, entry, cacheDir) {
|
|
272
|
+
if (platform === 'darwin') return installViaBrew(toolId, entry)
|
|
273
|
+
if (platform === 'win32') {
|
|
274
|
+
try {
|
|
275
|
+
return installViaScoop(toolId, entry)
|
|
276
|
+
} catch {
|
|
277
|
+
// Scoop недоступний або тул не в Scoop → GitHub Release fallback
|
|
278
|
+
return installFromGithub(toolId, entry, cacheDir)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Linux
|
|
282
|
+
return installFromGithub(toolId, entry, cacheDir)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Будує install-hint повідомлення для hard-fail.
|
|
287
|
+
* @param {string} toolId ключ у TOOLS
|
|
288
|
+
* @param {ToolEntry} entry опис тула
|
|
289
|
+
* @returns {string} рядок помилки з підказками
|
|
290
|
+
*/
|
|
291
|
+
function buildHint(toolId, entry) {
|
|
292
|
+
const lines = [
|
|
293
|
+
`❌ ${toolId} не знайдено в PATH і авто-встановлення відключено (N_CURSOR_NO_AUTO_INSTALL).`,
|
|
294
|
+
' Встанови:'
|
|
295
|
+
]
|
|
296
|
+
if (platform === 'darwin') {
|
|
297
|
+
lines.push(` macOS: brew install ${entry.brew}`)
|
|
298
|
+
} else if (platform === 'win32') {
|
|
299
|
+
if (entry.scoop) lines.push(` Windows: scoop install ${entry.scoop}`)
|
|
300
|
+
lines.push(` або: https://github.com/${entry.github}/releases`)
|
|
301
|
+
} else {
|
|
302
|
+
lines.push(` Linux: https://github.com/${entry.github}/releases`)
|
|
303
|
+
}
|
|
304
|
+
return lines.join('\n')
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Резолвить і за необхідності авто-встановлює зовнішній CLI-тул.
|
|
309
|
+
*
|
|
310
|
+
* Порядок: PATH → кеш → авто-install (якщо не N_CURSOR_NO_AUTO_INSTALL) → hard-fail.
|
|
311
|
+
* Повертає абсолютний шлях або кидає Error.
|
|
312
|
+
* @param {string} toolId ключ у реєстрі TOOLS (`'hk'`, `'conftest'`, `'shellcheck'`, `'actionlint'`, `'dotenv-linter'`, `'opa'`, `'regal'`, `'hadolint'`, `'kubeconform'`, `'kubescape'`)
|
|
313
|
+
* @returns {string} абсолютний шлях до бінарника
|
|
314
|
+
*/
|
|
315
|
+
export function ensureTool(toolId) {
|
|
316
|
+
const entry = TOOLS[toolId]
|
|
317
|
+
if (!entry) throw new Error(`ensureTool: невідомий тул '${toolId}'`)
|
|
318
|
+
|
|
319
|
+
// 1. PATH
|
|
320
|
+
const fromPath = resolveCmd(toolId)
|
|
321
|
+
if (fromPath) return fromPath
|
|
322
|
+
|
|
323
|
+
// 2. Кеш
|
|
324
|
+
const cacheDir = getCacheDir()
|
|
325
|
+
const cachedBin = join(cacheDir, toolId)
|
|
326
|
+
if (existsSync(cachedBin)) return cachedBin
|
|
327
|
+
|
|
328
|
+
// 3. Авто-install (якщо не заблоковано)
|
|
329
|
+
if (!env['N_CURSOR_NO_AUTO_INSTALL']) {
|
|
330
|
+
return autoInstall(toolId, entry, cacheDir)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 4. Hard-fail з per-OS підказкою
|
|
334
|
+
throw new Error(buildHint(toolId, entry))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Реєструє git pre-commit hook через `hk install`.
|
|
339
|
+
* Пропускається в CI (`process.env.CI`). Попереджає (не кидає) на помилку.
|
|
340
|
+
* @param {string} hkBin абсолютний шлях до бінарника hk
|
|
341
|
+
* @returns {void}
|
|
342
|
+
*/
|
|
343
|
+
export function ensureHkInstall(hkBin) {
|
|
344
|
+
if (env['CI']) return
|
|
345
|
+
|
|
346
|
+
const r = spawnSync(hkBin, ['install'], { stdio: 'inherit', encoding: 'utf8' })
|
|
347
|
+
if (r.error) {
|
|
348
|
+
console.warn(`⚠️ hk install не вдалось: ${r.error.message}`)
|
|
349
|
+
} else if (r.status !== 0) {
|
|
350
|
+
console.warn(`⚠️ hk install завершився з кодом ${r.status}`)
|
|
351
|
+
}
|
|
352
|
+
}
|