@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.
@@ -1,6 +1,7 @@
1
1
  /**
2
- * CLI-обгортка над канонічним `lint-text` (text.mdc): preflight на `shellcheck`, `patch`
3
- * (для авто-фіксу shellcheck) і `dotenv-linter`; далі послідовно
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
- * з неінформативним повідомленням. Preflight збирає всі відсутні бінарники до першого кроку.
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
- let preflightOk = true
132
- for (const dep of [SHELLCHECK_PREFLIGHT, PATCH_PREFLIGHT, DOTENV_LINTER_PREFLIGHT]) {
133
- if (!preflight(dep)) preflightOk = false
134
- }
135
- if (!preflightOk) return 1
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
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "check": "template",
4
+ "files": { "single": ".vscode/settings.json" }
5
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "search.exclude": {
3
+ "**/.worktrees/**": true
4
+ },
5
+ "files.exclude": {
6
+ "**/.worktrees/**": true
7
+ }
8
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/target.json",
3
+ "check": "template",
4
+ "files": { "single": ".zed/settings.json" }
5
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "file_scan_exclusions": [
3
+ "**/.git",
4
+ "**/.svn",
5
+ "**/.hg",
6
+ "**/.DS_Store",
7
+ "**/Thumbs.db",
8
+ "**/node_modules",
9
+ "**/.worktrees",
10
+ "**/.claude/worktrees"
11
+ ]
12
+ }
@@ -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> "<навіщо>"`
@@ -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
+ }