@nitra/cursor 1.8.112 → 1.8.114
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/bin/n-cursor.js +81 -5
- package/mdc/ga.mdc +32 -1
- package/mdc/js-pino.mdc +0 -2
- package/mdc/k8s.mdc +13 -0
- package/mdc/nginx-default-tpl.mdc +1 -0
- package/package.json +1 -1
- package/scripts/check-ga.mjs +62 -1
- package/scripts/check-js-pino.mjs +3 -1
- package/scripts/check-k8s.mjs +255 -1
package/bin/n-cursor.js
CHANGED
|
@@ -752,6 +752,76 @@ async function removeOrphanManagedCommandFiles(commandsDir, configSkills) {
|
|
|
752
752
|
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
753
753
|
}
|
|
754
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Синхронізує .claude/commands/{dirName}.md для всіх локальних скілів з .cursor/skills/
|
|
757
|
+
* що не керуються пакетом (відсутні в configSkills).
|
|
758
|
+
* @param {string[]} configSkills id керованих skills (вже оброблені syncCommands)
|
|
759
|
+
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
|
|
760
|
+
*/
|
|
761
|
+
async function syncLocalOnlySkillCommands(configSkills) {
|
|
762
|
+
const skillsRoot = join(cwd(), SKILLS_DIR)
|
|
763
|
+
if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
|
|
764
|
+
|
|
765
|
+
const commandsDir = join(cwd(), COMMANDS_DIR)
|
|
766
|
+
await mkdir(commandsDir, { recursive: true })
|
|
767
|
+
|
|
768
|
+
const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
769
|
+
const allDirNames = await listProjectSkillDirNames()
|
|
770
|
+
const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
|
|
771
|
+
|
|
772
|
+
let success = 0
|
|
773
|
+
let fail = 0
|
|
774
|
+
|
|
775
|
+
for (const dirName of localOnly) {
|
|
776
|
+
const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
|
|
777
|
+
const destFile = join(commandsDir, `${dirName}.md`)
|
|
778
|
+
|
|
779
|
+
process.stdout.write(` ⬇ ${dirName} → ${COMMANDS_DIR}/${dirName}.md ... `)
|
|
780
|
+
try {
|
|
781
|
+
let desc = ''
|
|
782
|
+
if (existsSync(skillMdPath)) {
|
|
783
|
+
const raw = await readFile(skillMdPath, 'utf8')
|
|
784
|
+
const parsed = extractSkillDescription(raw)
|
|
785
|
+
if (parsed) desc = skillDescriptionSafeForMarkdownInline(parsed)
|
|
786
|
+
}
|
|
787
|
+
const header = desc ? `# ${dirName} — ${desc}\n\n` : ''
|
|
788
|
+
const body = `${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
|
|
789
|
+
await writeFile(destFile, body, 'utf8')
|
|
790
|
+
console.log(`✅`)
|
|
791
|
+
success++
|
|
792
|
+
} catch (error) {
|
|
793
|
+
console.log(`❌`)
|
|
794
|
+
console.error(` Помилка: ${errorMessage(error)}`)
|
|
795
|
+
fail++
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return { success, fail }
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Видаляє .claude/commands/{dirName}.md файли локальних скілів, яких більше немає в .cursor/skills/
|
|
803
|
+
* @param {string} commandsDir абсолютний шлях до .claude/commands
|
|
804
|
+
* @param {string[]} configSkills id керованих skills
|
|
805
|
+
* @returns {Promise<string[]>} імена видалених файлів
|
|
806
|
+
*/
|
|
807
|
+
async function removeOrphanLocalSkillCommandFiles(commandsDir, configSkills) {
|
|
808
|
+
if (!existsSync(commandsDir)) return []
|
|
809
|
+
|
|
810
|
+
const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
|
|
811
|
+
const allDirNames = new Set(await listProjectSkillDirNames())
|
|
812
|
+
const names = await readdir(commandsDir)
|
|
813
|
+
const removed = []
|
|
814
|
+
|
|
815
|
+
for (const name of names.filter(n => n.endsWith('.md') && !n.startsWith(RULE_PREFIX))) {
|
|
816
|
+
const dirName = name.slice(0, -3)
|
|
817
|
+
if (!managedDirNames.has(dirName) && !allDirNames.has(dirName)) {
|
|
818
|
+
await unlink(join(commandsDir, name))
|
|
819
|
+
removed.push(name)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return removed.toSorted((a, b) => a.localeCompare(b))
|
|
823
|
+
}
|
|
824
|
+
|
|
755
825
|
/**
|
|
756
826
|
* Людинозрозумілий текст винятку для логів.
|
|
757
827
|
* @param {unknown} error виняток із catch
|
|
@@ -1031,13 +1101,19 @@ async function runSync() {
|
|
|
1031
1101
|
|
|
1032
1102
|
await runSyncStep('❌ Commands: ', async () => {
|
|
1033
1103
|
const { success: cmdOk, fail: cmdFail } = await syncCommands(skills, bundledSkillsDir)
|
|
1034
|
-
|
|
1035
|
-
|
|
1104
|
+
const { success: localOk, fail: localFail } = await syncLocalOnlySkillCommands(skills)
|
|
1105
|
+
const totalOk = cmdOk + localOk
|
|
1106
|
+
const totalFail = cmdFail + localFail
|
|
1107
|
+
if (totalOk + totalFail > 0) {
|
|
1108
|
+
console.log(`\n⌨️ Commands: ${totalOk} скопійовано, ${totalFail} з помилками`)
|
|
1036
1109
|
}
|
|
1037
|
-
const
|
|
1110
|
+
const commandsDir = join(cwd(), COMMANDS_DIR)
|
|
1111
|
+
const removedCmds = await removeOrphanManagedCommandFiles(commandsDir, skills)
|
|
1038
1112
|
logRemovedManagedItems('commands', COMMANDS_DIR, removedCmds)
|
|
1039
|
-
|
|
1040
|
-
|
|
1113
|
+
const removedLocalCmds = await removeOrphanLocalSkillCommandFiles(commandsDir, skills)
|
|
1114
|
+
logRemovedManagedItems('commands (local)', COMMANDS_DIR, removedLocalCmds)
|
|
1115
|
+
if (totalFail > 0) {
|
|
1116
|
+
throw new Error(`Не вдалося скопіювати ${totalFail} commands`)
|
|
1041
1117
|
}
|
|
1042
1118
|
})
|
|
1043
1119
|
|
package/mdc/ga.mdc
CHANGED
|
@@ -4,7 +4,7 @@ alwaysApply: true
|
|
|
4
4
|
version: '1.3'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
У `.github/workflows/` лише **`.yml`**. Мають бути **`clean-ga-workflows.yml`**, **`clean-merged-branch.yml`**, **`lint-ga.yml`**. Якщо є **`apply-k8s.yml`** / **`apply-nats-consumer.yml`** — paths у тригері як у фрагментах.
|
|
7
|
+
У `.github/workflows/` лише **`.yml`**. Мають бути **`clean-ga-workflows.yml`**, **`clean-merged-branch.yml`**, **`lint-ga.yml`**, **`git-ai.yml`**. Якщо є **`apply-k8s.yml`** / **`apply-nats-consumer.yml`** — paths у тригері як у фрагментах.
|
|
8
8
|
|
|
9
9
|
Повинен бути файл .github/workflows/clean-ga-workflows.yml, зі змістом:
|
|
10
10
|
|
|
@@ -110,6 +110,37 @@ jobs:
|
|
|
110
110
|
run: bun run lint-ga
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
Повинен бути файл .github/workflows/git-ai.yml, зі змістом:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
name: Git AI
|
|
117
|
+
|
|
118
|
+
on:
|
|
119
|
+
pull_request:
|
|
120
|
+
types: [closed]
|
|
121
|
+
|
|
122
|
+
jobs:
|
|
123
|
+
git-ai:
|
|
124
|
+
if: github.event.pull_request.merged == true
|
|
125
|
+
runs-on: ubuntu-latest
|
|
126
|
+
permissions:
|
|
127
|
+
contents: write
|
|
128
|
+
|
|
129
|
+
steps:
|
|
130
|
+
- name: Install git-ai
|
|
131
|
+
run: |
|
|
132
|
+
curl -fsSL https://usegitai.com/install.sh | bash
|
|
133
|
+
echo "$HOME/.git-ai/bin" >> $GITHUB_PATH
|
|
134
|
+
- name: Run git-ai
|
|
135
|
+
id: run-git-ai
|
|
136
|
+
env:
|
|
137
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
138
|
+
run: |
|
|
139
|
+
git config --global user.name "github-actions[bot]"
|
|
140
|
+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
|
141
|
+
git-ai ci github run
|
|
142
|
+
```
|
|
143
|
+
|
|
113
144
|
**Локальний composite** (`uses: ./.github/actions/setup-bun-deps` або `./npm/github-actions/setup-bun-deps`): **спочатку** обов’язковий крок **`actions/checkout@v6`** (`persist-credentials: false`), інакше runner не знайде `action.yml`. Сам composite: **`actions/setup-node@v6`** (**Node 24**), **Bun**, **`actions/cache@v5`**, **`bun install --frozen-lockfile`**.
|
|
114
145
|
|
|
115
146
|
```json title=".vscode/extensions.json"
|
package/mdc/js-pino.mdc
CHANGED
|
@@ -10,8 +10,6 @@ version: '1.1'
|
|
|
10
10
|
В **/k8s/base/configmap.yaml повинен бути заданий OTEL_RESOURCE_ATTRIBUTES: 'service.name=<project_name>,service.namespace=<project_namespace>'
|
|
11
11
|
а в директоріях з kustomize повинні бути перевизначені значення OTEL_RESOURCE_ATTRIBUTES і в них service.namespace повинен відповідати namespace, в якому знаходиться дана директорія.
|
|
12
12
|
|
|
13
|
-
Configmap повинен бути з тією самою назвою, що і deployment, якщо у deployment тільки один configmap.
|
|
14
|
-
|
|
15
13
|
## Перевірка
|
|
16
14
|
|
|
17
15
|
`npx @nitra/cursor check js-pino`
|
package/mdc/k8s.mdc
CHANGED
|
@@ -210,6 +210,19 @@ spec:
|
|
|
210
210
|
|
|
211
211
|
**Точні умови та повідомлення `fail`** — верхній JSDoc **`npm/scripts/check-k8s.mjs`**.
|
|
212
212
|
|
|
213
|
+
## ConfigMap: ім'я збігається з Deployment
|
|
214
|
+
|
|
215
|
+
Якщо в `k8s/base/` є **`configmap.yaml`** і **Deployment**, і цей Deployment посилається рівно на **один** ConfigMap — `metadata.name` ConfigMap має збігатися з `metadata.name` Deployment. Точні умови перевірки — **`check-k8s.mjs`**.
|
|
216
|
+
|
|
217
|
+
## ConfigMap для Hasura-Deployment
|
|
218
|
+
|
|
219
|
+
Якщо в `k8s/base/` поруч із **`configmap.yaml`** є **Deployment** з образом **`hasura/graphql-engine`**, у `data` ConfigMap **обов'язково** має бути ключ **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`**. Точні умови перевірки — **`check-k8s.mjs`**.
|
|
220
|
+
|
|
221
|
+
```yaml
|
|
222
|
+
data:
|
|
223
|
+
HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS: 'true'
|
|
224
|
+
```
|
|
225
|
+
|
|
213
226
|
## Kustomize: структура каталогів (`base` / overlays)
|
|
214
227
|
|
|
215
228
|
Трансформуй дерева **`**/k8s`**, щоб **винести спільне** через [Kustomize](https://kustomize.io/): один канонічний **`base`** і тонкі **overlays** для інших середовищ.
|
|
@@ -3,6 +3,7 @@ description: Правила nginx для статичних файлів
|
|
|
3
3
|
version: '1.2'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
+
> **Автоміграція:** `npx @nitra/cursor check nginx-default-tpl` автоматично перейменовує `default.tpl.conf` → `default.conf.template` (або перезаписує вміст, якщо обидва файли існують). Якщо шаблон відсутній — перевірка пропускається.
|
|
6
7
|
|
|
7
8
|
default.conf.template повинен виглядати так:
|
|
8
9
|
|
package/package.json
CHANGED
package/scripts/check-ga.mjs
CHANGED
|
@@ -256,7 +256,7 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
|
|
|
256
256
|
pass('Всі workflows мають розширення .yml')
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
for (const f of ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml']) {
|
|
259
|
+
for (const f of ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']) {
|
|
260
260
|
if (files.includes(f)) {
|
|
261
261
|
pass(`${f} існує`)
|
|
262
262
|
} else {
|
|
@@ -265,6 +265,66 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
|
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Перевіряє git-ai.yml: тригер pull_request з types: [closed], умова merged у job, виклик git-ai.
|
|
270
|
+
* @param {string} wfDir директорія workflows
|
|
271
|
+
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
272
|
+
* @param {(msg: string) => void} failFn callback при помилці
|
|
273
|
+
*/
|
|
274
|
+
async function checkGitAiWorkflow(wfDir, passFn, failFn) {
|
|
275
|
+
const gitAiWf = join(wfDir, 'git-ai.yml')
|
|
276
|
+
if (!existsSync(gitAiWf)) return
|
|
277
|
+
const content = await readFile(gitAiWf, 'utf8')
|
|
278
|
+
const root = parseWorkflowYaml(content)
|
|
279
|
+
|
|
280
|
+
if (root) {
|
|
281
|
+
// on.pull_request.types має містити 'closed'
|
|
282
|
+
const on = root.on
|
|
283
|
+
let hasPrClosed = false
|
|
284
|
+
if (on && typeof on === 'object') {
|
|
285
|
+
const pr = /** @type {Record<string, unknown>} */ (on)['pull_request']
|
|
286
|
+
if (pr && typeof pr === 'object') {
|
|
287
|
+
const types = /** @type {Record<string, unknown>} */ (pr).types
|
|
288
|
+
hasPrClosed = Array.isArray(types) && types.includes('closed')
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (hasPrClosed) {
|
|
292
|
+
passFn('git-ai.yml: on.pull_request.types містить closed')
|
|
293
|
+
} else {
|
|
294
|
+
failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Job if-умова: запускати лише при злитті PR
|
|
298
|
+
const jobs = root.jobs
|
|
299
|
+
let hasMergedCondition = false
|
|
300
|
+
if (jobs && typeof jobs === 'object') {
|
|
301
|
+
for (const job of Object.values(jobs)) {
|
|
302
|
+
if (job && typeof job === 'object') {
|
|
303
|
+
const ifCond = String(/** @type {Record<string, unknown>} */ (job).if ?? '')
|
|
304
|
+
if (ifCond.includes('merged')) {
|
|
305
|
+
hasMergedCondition = true
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (hasMergedCondition) {
|
|
311
|
+
passFn('git-ai.yml: job має умову merged')
|
|
312
|
+
} else {
|
|
313
|
+
failFn('git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)')
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Крок викликає git-ai ci github run
|
|
318
|
+
const hasGitAiRun = root
|
|
319
|
+
? anyRunStepIncludes(root, 'git-ai ci github run')
|
|
320
|
+
: content.includes('git-ai ci github run')
|
|
321
|
+
if (hasGitAiRun) {
|
|
322
|
+
passFn('git-ai.yml: крок виконує git-ai ci github run')
|
|
323
|
+
} else {
|
|
324
|
+
failFn('git-ai.yml: крок має містити git-ai ci github run (ga.mdc)')
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
268
328
|
/**
|
|
269
329
|
* Перевіряє відповідність проєкту правилам ga.mdc
|
|
270
330
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -318,6 +378,7 @@ export async function check() {
|
|
|
318
378
|
await checkZizmor(pass, fail)
|
|
319
379
|
await checkLintGaScript(pass, fail)
|
|
320
380
|
await checkLintGaWorkflow(wfDir, pass, fail)
|
|
381
|
+
await checkGitAiWorkflow(wfDir, pass, fail)
|
|
321
382
|
|
|
322
383
|
return reporter.getExitCode()
|
|
323
384
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* (`import` / `require` / динамічний `import()`); наявність `OTEL_RESOURCE_ATTRIBUTES`
|
|
6
6
|
* у `k8s/base/configmap.yaml`, якщо такий файл існує.
|
|
7
7
|
*
|
|
8
|
+
* Перевірка відповідності імені ConfigMap імені Deployment — у `check-k8s.mjs` (k8s.mdc).
|
|
9
|
+
*
|
|
8
10
|
* Імпорти в джерелах сканує AST через `oxc-parser` (див. `utils/bunyan-imports.mjs`),
|
|
9
11
|
* щоб виявити випадки на кшталт `import log from '@nitra/bunyan'`, які лишаються в коді
|
|
10
12
|
* після підміни залежності.
|
|
@@ -78,7 +80,7 @@ async function checkWorkspacePackage(rootDir, fail, passFn) {
|
|
|
78
80
|
passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
const configmapPath = join(rootDir, 'k8s
|
|
83
|
+
const configmapPath = join(rootDir, 'k8s', 'base', 'configmap.yaml')
|
|
82
84
|
if (existsSync(configmapPath)) {
|
|
83
85
|
const content = await readFile(configmapPath, 'utf8')
|
|
84
86
|
if (content.includes('OTEL_RESOURCE_ATTRIBUTES')) {
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -56,9 +56,14 @@
|
|
|
56
56
|
* Dockerfile — правило docker.mdc, скрипт check-docker.mjs.
|
|
57
57
|
*
|
|
58
58
|
* **Структура `HTTPRoute` для Hasura-Deployment:** звіряється канон 4 правил у **`spec.rules`** (редиректи **`<prefix>/ql`** і **`<prefix>/ql/`** на **`<prefix>/ql/console`** 302, **`PathPrefix <prefix>/ql`** + **URLRewrite** на **`/`**, окреме WebSocket-правило з **`RequestHeaderModifier`** remove **`Authorization`**). **Префікс параметризовано** (рядок перед **`/ql`** у першому Hasura-правилі). **Прив'язка** — за **`metadata.name`** у тому ж каталозі, що й **Deployment** з образом **`hasura/graphql-engine`** (див. k8s.mdc). **Додаткові правила** поверх канону дозволені.
|
|
59
|
+
*
|
|
60
|
+
* **ConfigMap для Hasura-Deployment:** якщо в `k8s/base/` є `configmap.yaml` і поруч Deployment з образом
|
|
61
|
+
* **`hasura/graphql-engine`**, то в `data` ConfigMap обов'язково має бути ключ
|
|
62
|
+
* **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (приймається булеве `true`
|
|
63
|
+
* або рядок `"true"`, без регістрової залежності).
|
|
59
64
|
*/
|
|
60
65
|
import { existsSync } from 'node:fs'
|
|
61
|
-
import { readFile, stat, unlink } from 'node:fs/promises'
|
|
66
|
+
import { readFile, readdir, stat, unlink } from 'node:fs/promises'
|
|
62
67
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
63
68
|
|
|
64
69
|
import { parseAllDocuments } from 'yaml'
|
|
@@ -1959,6 +1964,131 @@ export function isHasuraDeploymentManifest(manifest) {
|
|
|
1959
1964
|
return containerListHasHasuraImage(p.containers) || containerListHasHasuraImage(p.initContainers)
|
|
1960
1965
|
}
|
|
1961
1966
|
|
|
1967
|
+
/**
|
|
1968
|
+
* Обов'язковий ключ у **`data`** ConfigMap для Hasura-Deployment (узгоджено з k8s.mdc).
|
|
1969
|
+
*/
|
|
1970
|
+
export const HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY = 'HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS'
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Чи значення поля `data.<key>` у ConfigMap читається як логічне **true**.
|
|
1974
|
+
* ConfigMap у Kubernetes тримає значення як рядки, але в YAML часто пишуть без лапок —
|
|
1975
|
+
* тому приймаємо і булевий **true**, і рядок **"true"** (без регістрової залежності).
|
|
1976
|
+
* @param {unknown} v значення з `data[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY]`
|
|
1977
|
+
* @returns {boolean} true, якщо значення — `true` або рядок `'true'`
|
|
1978
|
+
*/
|
|
1979
|
+
function isConfigMapValueTrue(v) {
|
|
1980
|
+
if (v === true) return true
|
|
1981
|
+
if (typeof v === 'string' && v.trim().toLowerCase() === 'true') return true
|
|
1982
|
+
return false
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* Чи порушує ConfigMap вимогу щодо **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS: "true"`** (k8s.mdc).
|
|
1987
|
+
* Перевірка застосовна, коли в тому ж каталозі є Hasura-Deployment (див. `isHasuraDeploymentManifest`).
|
|
1988
|
+
* @param {unknown} manifest корінь YAML-документа ConfigMap
|
|
1989
|
+
* @returns {string | null} текст порушення або null, якщо не ConfigMap / ключ є і значення `true`
|
|
1990
|
+
*/
|
|
1991
|
+
export function hasuraConfigMapRemoteSchemaPermissionsViolation(manifest) {
|
|
1992
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
1993
|
+
return null
|
|
1994
|
+
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
1995
|
+
if (rec.kind !== 'ConfigMap') return null
|
|
1996
|
+
const data = rec.data
|
|
1997
|
+
if (data === null || data === undefined || typeof data !== 'object' || Array.isArray(data)) {
|
|
1998
|
+
return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: додай ключ зі значенням "true" (Deployment з hasura/graphql-engine — див. k8s.mdc)`
|
|
1999
|
+
}
|
|
2000
|
+
const d = /** @type {Record<string, unknown>} */ (data)
|
|
2001
|
+
if (!Object.hasOwn(d, HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY)) {
|
|
2002
|
+
return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: додай ключ зі значенням "true" (Deployment з hasura/graphql-engine — див. k8s.mdc)`
|
|
2003
|
+
}
|
|
2004
|
+
if (!isConfigMapValueTrue(d[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY])) {
|
|
2005
|
+
return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: значення має бути "true" (зараз: ${JSON.stringify(d[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY])}) (див. k8s.mdc)`
|
|
2006
|
+
}
|
|
2007
|
+
return null
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
const K8S_YAML_EXT_RE = /\.ya?ml$/iu
|
|
2011
|
+
|
|
2012
|
+
/**
|
|
2013
|
+
* Знаходить перший документ **Deployment** серед YAML-файлів каталогу (для перевірки імені ConfigMap, js-pino.mdc).
|
|
2014
|
+
* @param {string} dirPath абсолютний шлях до каталогу
|
|
2015
|
+
* @returns {Promise<Record<string, unknown> | null>} об'єкт Deployment або null
|
|
2016
|
+
*/
|
|
2017
|
+
export async function findDeploymentDocInDir(dirPath) {
|
|
2018
|
+
let entries
|
|
2019
|
+
try {
|
|
2020
|
+
entries = await readdir(dirPath)
|
|
2021
|
+
} catch {
|
|
2022
|
+
return null
|
|
2023
|
+
}
|
|
2024
|
+
for (const entry of entries) {
|
|
2025
|
+
if (!K8S_YAML_EXT_RE.test(entry)) continue
|
|
2026
|
+
let raw
|
|
2027
|
+
try {
|
|
2028
|
+
raw = await readFile(join(dirPath, entry), 'utf8')
|
|
2029
|
+
} catch {
|
|
2030
|
+
continue
|
|
2031
|
+
}
|
|
2032
|
+
let docs
|
|
2033
|
+
try {
|
|
2034
|
+
docs = parseAllDocuments(raw)
|
|
2035
|
+
} catch {
|
|
2036
|
+
continue
|
|
2037
|
+
}
|
|
2038
|
+
for (const doc of docs) {
|
|
2039
|
+
if (doc.errors.length > 0) continue
|
|
2040
|
+
const obj = doc.toJSON()
|
|
2041
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
2042
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
2043
|
+
if (rec.kind === 'Deployment') return rec
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
return null
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Збирає унікальні імена **ConfigMap**, на які посилається **Deployment**
|
|
2052
|
+
* через `spec.template.spec.containers[*].envFrom[*].configMapRef.name`
|
|
2053
|
+
* та `spec.template.spec.volumes[*].configMap.name` (для перевірки js-pino.mdc).
|
|
2054
|
+
* @param {Record<string, unknown>} deployment об'єкт Deployment
|
|
2055
|
+
* @returns {Set<string>} унікальні імена ConfigMap
|
|
2056
|
+
*/
|
|
2057
|
+
export function collectDeploymentConfigMapRefs(deployment) {
|
|
2058
|
+
/** @type {Set<string>} */
|
|
2059
|
+
const names = new Set()
|
|
2060
|
+
const spec = deployment.spec
|
|
2061
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return names
|
|
2062
|
+
const template = /** @type {Record<string, unknown>} */ (spec).template
|
|
2063
|
+
if (template === null || typeof template !== 'object' || Array.isArray(template)) return names
|
|
2064
|
+
const podSpec = /** @type {Record<string, unknown>} */ (template).spec
|
|
2065
|
+
if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) return names
|
|
2066
|
+
const ps = /** @type {Record<string, unknown>} */ (podSpec)
|
|
2067
|
+
for (const c of Array.isArray(ps.containers) ? /** @type {unknown[]} */ (ps.containers) : []) {
|
|
2068
|
+
if (c === null || typeof c !== 'object' || Array.isArray(c)) continue
|
|
2069
|
+
const envFrom = /** @type {Record<string, unknown>} */ (c).envFrom
|
|
2070
|
+
for (const ef of Array.isArray(envFrom) ? /** @type {unknown[]} */ (envFrom) : []) {
|
|
2071
|
+
if (ef !== null && typeof ef === 'object' && !Array.isArray(ef)) {
|
|
2072
|
+
const cmr = /** @type {Record<string, unknown>} */ (ef).configMapRef
|
|
2073
|
+
if (cmr !== null && typeof cmr === 'object' && !Array.isArray(cmr)) {
|
|
2074
|
+
const n = /** @type {Record<string, unknown>} */ (cmr).name
|
|
2075
|
+
if (typeof n === 'string' && n.trim() !== '') names.add(n)
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
for (const v of Array.isArray(ps.volumes) ? /** @type {unknown[]} */ (ps.volumes) : []) {
|
|
2081
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
2082
|
+
const cm = /** @type {Record<string, unknown>} */ (v).configMap
|
|
2083
|
+
if (cm !== null && typeof cm === 'object' && !Array.isArray(cm)) {
|
|
2084
|
+
const n = /** @type {Record<string, unknown>} */ (cm).name
|
|
2085
|
+
if (typeof n === 'string' && n.trim() !== '') names.add(n)
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
return names
|
|
2090
|
+
}
|
|
2091
|
+
|
|
1962
2092
|
/**
|
|
1963
2093
|
* Чи **Service** містить заборонені анотації GKE у **`metadata.annotations`** (k8s.mdc).
|
|
1964
2094
|
* @param {unknown} manifest корінь YAML-документа
|
|
@@ -3160,6 +3290,126 @@ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
|
|
|
3160
3290
|
}
|
|
3161
3291
|
}
|
|
3162
3292
|
|
|
3293
|
+
const CONFIGMAP_BASE_PATH_RE = /\/k8s\/base\/configmap\.yaml$/u
|
|
3294
|
+
|
|
3295
|
+
/**
|
|
3296
|
+
* Якщо в `k8s/base/` є `configmap.yaml` і Deployment посилається рівно на один ConfigMap —
|
|
3297
|
+
* `metadata.name` ConfigMap має збігатися з `metadata.name` Deployment (k8s.mdc).
|
|
3298
|
+
* @param {string} root корінь репозиторію
|
|
3299
|
+
* @param {string[]} yamlFilesAbs yaml під k8s
|
|
3300
|
+
* @param {(msg: string) => void} fail callback при помилці
|
|
3301
|
+
* @param {(msg: string) => void} passFn callback при успіху
|
|
3302
|
+
*/
|
|
3303
|
+
async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail, passFn) {
|
|
3304
|
+
const cmFiles = yamlFilesAbs.filter(abs => {
|
|
3305
|
+
const rel = relative(root, abs).replaceAll('\\', '/')
|
|
3306
|
+
return CONFIGMAP_BASE_PATH_RE.test(`/${rel}`) || rel === 'k8s/base/configmap.yaml'
|
|
3307
|
+
})
|
|
3308
|
+
for (const cmAbs of cmFiles) {
|
|
3309
|
+
const rel = relative(root, cmAbs).replaceAll('\\', '/') || cmAbs
|
|
3310
|
+
let raw
|
|
3311
|
+
try {
|
|
3312
|
+
raw = await readFile(cmAbs, 'utf8')
|
|
3313
|
+
} catch {
|
|
3314
|
+
continue
|
|
3315
|
+
}
|
|
3316
|
+
let cmName = null
|
|
3317
|
+
try {
|
|
3318
|
+
for (const doc of parseAllDocuments(raw)) {
|
|
3319
|
+
if (doc.errors.length > 0) continue
|
|
3320
|
+
const obj = doc.toJSON()
|
|
3321
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
3322
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
3323
|
+
if (rec.kind === 'ConfigMap') {
|
|
3324
|
+
const meta = rec.metadata
|
|
3325
|
+
if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
|
|
3326
|
+
const n = /** @type {Record<string, unknown>} */ (meta).name
|
|
3327
|
+
if (typeof n === 'string' && n.trim() !== '') cmName = n
|
|
3328
|
+
}
|
|
3329
|
+
break
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
} catch {
|
|
3334
|
+
continue
|
|
3335
|
+
}
|
|
3336
|
+
if (cmName === null) continue
|
|
3337
|
+
const deployment = await findDeploymentDocInDir(dirname(cmAbs))
|
|
3338
|
+
if (deployment === null) continue
|
|
3339
|
+
const meta = deployment.metadata
|
|
3340
|
+
const deployName =
|
|
3341
|
+
meta !== null && typeof meta === 'object' && !Array.isArray(meta)
|
|
3342
|
+
? /** @type {Record<string, unknown>} */ (meta).name
|
|
3343
|
+
: null
|
|
3344
|
+
const cmRefs = collectDeploymentConfigMapRefs(deployment)
|
|
3345
|
+
if (cmRefs.size !== 1 || typeof deployName !== 'string') continue
|
|
3346
|
+
if (cmName === deployName) {
|
|
3347
|
+
passFn(`${rel}: metadata.name '${cmName}' збігається з Deployment (k8s.mdc)`)
|
|
3348
|
+
} else {
|
|
3349
|
+
fail(
|
|
3350
|
+
`${rel}: metadata.name '${cmName}' має збігатися з назвою Deployment '${deployName}' — Deployment посилається рівно на один ConfigMap (k8s.mdc)`
|
|
3351
|
+
)
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
/**
|
|
3357
|
+
* Знаходить перший документ **ConfigMap** у файлі (з `metadata.name`).
|
|
3358
|
+
* @param {string} absPath абсолютний шлях до YAML-файлу
|
|
3359
|
+
* @returns {Promise<Record<string, unknown> | null>} об'єкт ConfigMap або null
|
|
3360
|
+
*/
|
|
3361
|
+
async function readFirstConfigMapDoc(absPath) {
|
|
3362
|
+
let raw
|
|
3363
|
+
try {
|
|
3364
|
+
raw = await readFile(absPath, 'utf8')
|
|
3365
|
+
} catch {
|
|
3366
|
+
return null
|
|
3367
|
+
}
|
|
3368
|
+
let docs
|
|
3369
|
+
try {
|
|
3370
|
+
docs = parseAllDocuments(raw)
|
|
3371
|
+
} catch {
|
|
3372
|
+
return null
|
|
3373
|
+
}
|
|
3374
|
+
for (const doc of docs) {
|
|
3375
|
+
if (doc.errors.length > 0) continue
|
|
3376
|
+
const obj = doc.toJSON()
|
|
3377
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
3378
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
3379
|
+
if (rec.kind === 'ConfigMap') return rec
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
return null
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
/**
|
|
3386
|
+
* Для кожного `k8s/base/configmap.yaml`, у каталозі якого поруч є Hasura-Deployment,
|
|
3387
|
+
* вимагає у `data` ключ **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (k8s.mdc).
|
|
3388
|
+
* @param {string} root корінь репозиторію
|
|
3389
|
+
* @param {string[]} yamlFilesAbs yaml під k8s
|
|
3390
|
+
* @param {(msg: string) => void} fail callback при помилці
|
|
3391
|
+
* @param {(msg: string) => void} passFn callback при успіху
|
|
3392
|
+
*/
|
|
3393
|
+
async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs, fail, passFn) {
|
|
3394
|
+
const cmFiles = yamlFilesAbs.filter(abs => {
|
|
3395
|
+
const rel = relative(root, abs).replaceAll('\\', '/')
|
|
3396
|
+
return CONFIGMAP_BASE_PATH_RE.test(`/${rel}`) || rel === 'k8s/base/configmap.yaml'
|
|
3397
|
+
})
|
|
3398
|
+
for (const cmAbs of cmFiles) {
|
|
3399
|
+
const rel = relative(root, cmAbs).replaceAll('\\', '/') || cmAbs
|
|
3400
|
+
const deployment = await findDeploymentDocInDir(dirname(cmAbs))
|
|
3401
|
+
if (deployment === null || !isHasuraDeploymentManifest(deployment)) continue
|
|
3402
|
+
const cm = await readFirstConfigMapDoc(cmAbs)
|
|
3403
|
+
if (cm === null) continue
|
|
3404
|
+
const violation = hasuraConfigMapRemoteSchemaPermissionsViolation(cm)
|
|
3405
|
+
if (violation !== null) {
|
|
3406
|
+
fail(`${rel}: ${violation}`)
|
|
3407
|
+
} else {
|
|
3408
|
+
passFn(`${rel}: ${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}="true" для Hasura-Deployment (k8s.mdc)`)
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3163
3413
|
/**
|
|
3164
3414
|
* Перевіряє відповідність проєкту правилам k8s.mdc.
|
|
3165
3415
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -3201,5 +3451,9 @@ export async function check() {
|
|
|
3201
3451
|
|
|
3202
3452
|
await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
|
|
3203
3453
|
|
|
3454
|
+
await validateConfigMapNameMatchesDeployment(root, yamlFiles, fail, pass)
|
|
3455
|
+
|
|
3456
|
+
await validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFiles, fail, pass)
|
|
3457
|
+
|
|
3204
3458
|
return reporter.getExitCode()
|
|
3205
3459
|
}
|