@nitra/cursor 1.8.80 → 1.8.83
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 +83 -36
- package/mdc/abie.mdc +10 -4
- package/package.json +1 -1
- package/scripts/check-abie.mjs +43 -7
- package/scripts/upgrade-nitra-cursor-and-install.mjs +201 -0
package/bin/n-cursor.js
CHANGED
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
*
|
|
33
33
|
* Якщо в корені є package.json і в ньому ще немає \@nitra/cursor у devDependencies (і не оголошено
|
|
34
34
|
* в dependencies), CLI дописує devDependencies з діапазоном ^<version> поточного пакету — зручно після npx.
|
|
35
|
+
*
|
|
36
|
+
* Перед копіюванням правил (режим без підкоманди): оновлення \@nitra/cursor у package.json до
|
|
37
|
+
* останньої версії з npm (крім workspace:/file:/link: тощо), `bun i`, далі файли беруться з
|
|
38
|
+
* `node_modules/@nitra/cursor`, якщо пакет з’явився після встановлення.
|
|
35
39
|
*/
|
|
36
40
|
|
|
37
41
|
import { existsSync } from 'node:fs'
|
|
@@ -40,10 +44,8 @@ import { basename, dirname, join } from 'node:path'
|
|
|
40
44
|
import { cwd } from 'node:process'
|
|
41
45
|
import { fileURLToPath } from 'node:url'
|
|
42
46
|
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
readBundledPackageVersion
|
|
46
|
-
} from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
|
|
47
|
+
import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
|
|
48
|
+
import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitra-cursor-and-install.mjs'
|
|
47
49
|
import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
|
|
48
50
|
import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
|
|
49
51
|
|
|
@@ -68,17 +70,18 @@ const BUNDLED_PACKAGE_ROOT = join(binDir, '..')
|
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
72
|
* Імена правил (без .mdc) з каталогу mdc поточної інсталяції пакету
|
|
73
|
+
* @param {string} [bundledMdcDir] каталог `mdc/` у корені пакету (за замовчуванням — з поточного процесу)
|
|
71
74
|
* @returns {Promise<string[]>} відсортовані імена файлів правил без суфікса .mdc
|
|
72
75
|
*/
|
|
73
|
-
async function discoverBundledRuleNames() {
|
|
74
|
-
if (!existsSync(
|
|
76
|
+
async function discoverBundledRuleNames(bundledMdcDir = BUNDLED_MDC_DIR) {
|
|
77
|
+
if (!existsSync(bundledMdcDir)) {
|
|
75
78
|
throw new Error(
|
|
76
79
|
`Не знайдено каталог правил пакету.\n` +
|
|
77
|
-
`Очікуваний шлях: ${
|
|
80
|
+
`Очікуваний шлях: ${bundledMdcDir}\n` +
|
|
78
81
|
`Перевстановіть ${PACKAGE_NAME} або створіть ${CONFIG_FILE} вручну.`
|
|
79
82
|
)
|
|
80
83
|
}
|
|
81
|
-
const names = await readdir(
|
|
84
|
+
const names = await readdir(bundledMdcDir)
|
|
82
85
|
const rules = names
|
|
83
86
|
.filter(n => n.endsWith('.mdc'))
|
|
84
87
|
.map(n => n.slice(0, -'.mdc'.length))
|
|
@@ -91,13 +94,14 @@ async function discoverBundledRuleNames() {
|
|
|
91
94
|
|
|
92
95
|
/**
|
|
93
96
|
* Імена skills (id без префікса n-) з каталогу skills пакету — лише підкаталоги `<id>/` без префікса n-
|
|
97
|
+
* @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету
|
|
94
98
|
* @returns {Promise<string[]>} відсортовані id
|
|
95
99
|
*/
|
|
96
|
-
async function discoverBundledSkillNames() {
|
|
97
|
-
if (!existsSync(
|
|
100
|
+
async function discoverBundledSkillNames(bundledSkillsDir = BUNDLED_SKILLS_DIR) {
|
|
101
|
+
if (!existsSync(bundledSkillsDir)) {
|
|
98
102
|
return []
|
|
99
103
|
}
|
|
100
|
-
const entries = await readdir(
|
|
104
|
+
const entries = await readdir(bundledSkillsDir, { withFileTypes: true })
|
|
101
105
|
return entries
|
|
102
106
|
.filter(e => e.isDirectory() && !e.name.startsWith('.') && !e.name.startsWith(RULE_PREFIX))
|
|
103
107
|
.map(e => e.name)
|
|
@@ -152,14 +156,17 @@ async function migrateLegacyConfigIfNeeded() {
|
|
|
152
156
|
|
|
153
157
|
/**
|
|
154
158
|
* Зчитує конфіг .n-cursor.json з поточної директорії
|
|
159
|
+
* @param {{ bundledMdcDir?: string, bundledSkillsDir?: string }} [paths] каталоги з пакету-джерела (після `bun i` — зазвичай `node_modules/@nitra/cursor`)
|
|
155
160
|
* @returns {Promise<{ $schema: string, rules: string[], skills: string[], version?: string } & Record<string, unknown>>} rules, skills (id без префікса n-); поле version у файлі за наявності ігнорується при синхронізації правил
|
|
156
161
|
*/
|
|
157
|
-
async function readConfig() {
|
|
162
|
+
async function readConfig(paths = {}) {
|
|
163
|
+
const bundledMdcDir = paths.bundledMdcDir ?? BUNDLED_MDC_DIR
|
|
164
|
+
const bundledSkillsDir = paths.bundledSkillsDir ?? BUNDLED_SKILLS_DIR
|
|
158
165
|
await migrateLegacyConfigIfNeeded()
|
|
159
166
|
const configPath = join(cwd(), CONFIG_FILE)
|
|
160
167
|
if (!existsSync(configPath)) {
|
|
161
|
-
const rules = await discoverBundledRuleNames()
|
|
162
|
-
const skills = await discoverBundledSkillNames()
|
|
168
|
+
const rules = await discoverBundledRuleNames(bundledMdcDir)
|
|
169
|
+
const skills = await discoverBundledSkillNames(bundledSkillsDir)
|
|
163
170
|
const defaultConfig = { $schema: CONFIG_SCHEMA_URL, rules, skills }
|
|
164
171
|
await writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8')
|
|
165
172
|
console.log(
|
|
@@ -181,7 +188,7 @@ async function readConfig() {
|
|
|
181
188
|
if ('skills' in config) {
|
|
182
189
|
throw new Error(`У ${CONFIG_FILE} поле "skills" має бути масивом рядків`)
|
|
183
190
|
}
|
|
184
|
-
config.skills = await discoverBundledSkillNames()
|
|
191
|
+
config.skills = await discoverBundledSkillNames(bundledSkillsDir)
|
|
185
192
|
}
|
|
186
193
|
|
|
187
194
|
if (config.$schema !== CONFIG_SCHEMA_URL) {
|
|
@@ -210,14 +217,15 @@ function normalizeRuleName(ruleName) {
|
|
|
210
217
|
/**
|
|
211
218
|
* Читає вміст правила з каталогу `mdc/` установленого пакету (наприклад `node_modules/@nitra/cursor/mdc` або кеш npx).
|
|
212
219
|
* @param {string} rule елемент масиву rules з `.n-cursor.json`
|
|
220
|
+
* @param {string} [bundledMdcDir] каталог `mdc/` у корені пакету-джерела
|
|
213
221
|
* @returns {Promise<string>} текст правила для запису в `.cursor/rules/n-*.mdc`
|
|
214
222
|
*/
|
|
215
|
-
function readBundledRuleContent(rule) {
|
|
223
|
+
function readBundledRuleContent(rule, bundledMdcDir = BUNDLED_MDC_DIR) {
|
|
216
224
|
const bundledName = normalizeRuleName(rule)
|
|
217
|
-
const bundledPath = join(
|
|
225
|
+
const bundledPath = join(bundledMdcDir, bundledName)
|
|
218
226
|
if (!existsSync(bundledPath)) {
|
|
219
227
|
throw new Error(
|
|
220
|
-
`Немає файлу ${bundledName} у ${
|
|
228
|
+
`Немає файлу ${bundledName} у ${bundledMdcDir}. Оновіть ${PACKAGE_NAME} або приберіть "${rule}" з rules у ${CONFIG_FILE}.`
|
|
221
229
|
)
|
|
222
230
|
}
|
|
223
231
|
return readFile(bundledPath, 'utf8')
|
|
@@ -457,17 +465,18 @@ async function syncClaudeMd(configRules, configSkills) {
|
|
|
457
465
|
/**
|
|
458
466
|
* Повністю перезаписує AGENTS.md у корені cwd з npm/AGENTS.template.md
|
|
459
467
|
* @param {string[]} configSkills id skills з конфігу
|
|
468
|
+
* @param {string} [agentsTemplatePath] шлях до AGENTS.template.md у корені пакету-джерела
|
|
460
469
|
* @returns {Promise<void>} завершення запису файлу
|
|
461
470
|
*/
|
|
462
|
-
async function syncAgentsMd(configSkills) {
|
|
463
|
-
if (!existsSync(
|
|
471
|
+
async function syncAgentsMd(configSkills, agentsTemplatePath = BUNDLED_AGENTS_TEMPLATE_PATH) {
|
|
472
|
+
if (!existsSync(agentsTemplatePath)) {
|
|
464
473
|
throw new Error(
|
|
465
474
|
`Не знайдено шаблон ${AGENTS_TEMPLATE_FILE} у пакеті.\n` +
|
|
466
|
-
`Очікуваний шлях: ${
|
|
475
|
+
`Очікуваний шлях: ${agentsTemplatePath}\n` +
|
|
467
476
|
`Перевстановіть ${PACKAGE_NAME}.`
|
|
468
477
|
)
|
|
469
478
|
}
|
|
470
|
-
const templateText = await readFile(
|
|
479
|
+
const templateText = await readFile(agentsTemplatePath, 'utf8')
|
|
471
480
|
const mdcFiles = await listProjectRulesMdcFiles()
|
|
472
481
|
const skillItems = await buildSkillBulletItems(configSkills)
|
|
473
482
|
const body = renderAgentsTemplate(templateText, mdcFiles, skillItems)
|
|
@@ -485,10 +494,11 @@ async function syncAgentsMd(configSkills) {
|
|
|
485
494
|
/**
|
|
486
495
|
* Копіює лише skills зі списку configSkills (джерело: skills/<id>/ у пакеті)
|
|
487
496
|
* @param {string[]} configSkills id без префікса n-
|
|
497
|
+
* @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
|
|
488
498
|
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих копіювань
|
|
489
499
|
*/
|
|
490
|
-
async function syncSkills(configSkills) {
|
|
491
|
-
if (configSkills.length === 0 || !existsSync(
|
|
500
|
+
async function syncSkills(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
|
|
501
|
+
if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
|
|
492
502
|
return { success: 0, fail: 0 }
|
|
493
503
|
}
|
|
494
504
|
|
|
@@ -500,7 +510,7 @@ async function syncSkills(configSkills) {
|
|
|
500
510
|
|
|
501
511
|
for (const skillId of configSkills) {
|
|
502
512
|
const id = normalizeSkillId(skillId)
|
|
503
|
-
const srcDir = join(
|
|
513
|
+
const srcDir = join(bundledSkillsDir, id)
|
|
504
514
|
const destDirName = managedSkillDirName(skillId)
|
|
505
515
|
const destDir = join(skillsRoot, destDirName)
|
|
506
516
|
|
|
@@ -534,10 +544,11 @@ async function syncSkills(configSkills) {
|
|
|
534
544
|
* Синхронізує .claude/commands/n-<id>.md зі skills пакету.
|
|
535
545
|
* Кожен файл містить посилання на відповідний cursor skill, а не копію інструкцій.
|
|
536
546
|
* @param {string[]} configSkills id без префікса n-
|
|
547
|
+
* @param {string} [bundledSkillsDir] каталог `skills/` у корені пакету-джерела
|
|
537
548
|
* @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
|
|
538
549
|
*/
|
|
539
|
-
async function syncCommands(configSkills) {
|
|
540
|
-
if (configSkills.length === 0 || !existsSync(
|
|
550
|
+
async function syncCommands(configSkills, bundledSkillsDir = BUNDLED_SKILLS_DIR) {
|
|
551
|
+
if (configSkills.length === 0 || !existsSync(bundledSkillsDir)) {
|
|
541
552
|
return { success: 0, fail: 0 }
|
|
542
553
|
}
|
|
543
554
|
|
|
@@ -549,7 +560,7 @@ async function syncCommands(configSkills) {
|
|
|
549
560
|
|
|
550
561
|
for (const skillId of configSkills) {
|
|
551
562
|
const id = normalizeSkillId(skillId)
|
|
552
|
-
const srcSkillMd = join(
|
|
563
|
+
const srcSkillMd = join(bundledSkillsDir, id, 'SKILL.md')
|
|
553
564
|
const destDirName = managedSkillDirName(skillId)
|
|
554
565
|
const destFile = join(commandsDir, `${RULE_PREFIX}${id}.md`)
|
|
555
566
|
|
|
@@ -730,6 +741,24 @@ async function runChecks(requestedRules) {
|
|
|
730
741
|
}
|
|
731
742
|
}
|
|
732
743
|
|
|
744
|
+
/**
|
|
745
|
+
* Читає поле `version` з `package.json` пакету за абсолютним шляхом до його кореня.
|
|
746
|
+
* @param {string} packageRoot корінь пакету (тека з `package.json`)
|
|
747
|
+
* @returns {Promise<string | null>} semver рядком або null, якщо файлу/поля немає або JSON некоректний
|
|
748
|
+
*/
|
|
749
|
+
async function readBundledVersionAt(packageRoot) {
|
|
750
|
+
const p = join(packageRoot, 'package.json')
|
|
751
|
+
if (!existsSync(p)) {
|
|
752
|
+
return null
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
|
756
|
+
return typeof pkg.version === 'string' ? pkg.version : null
|
|
757
|
+
} catch {
|
|
758
|
+
return null
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
733
762
|
/**
|
|
734
763
|
* Копіює правила з каталогу `mdc/` установленого пакету та синхронізує `.cursor/rules`
|
|
735
764
|
* @returns {Promise<void>}
|
|
@@ -737,18 +766,36 @@ async function runChecks(requestedRules) {
|
|
|
737
766
|
async function runSync() {
|
|
738
767
|
console.log(`\n🔧 ${PACKAGE_NAME} — завантаження cursor-правил\n`)
|
|
739
768
|
|
|
769
|
+
const projectRoot = cwd()
|
|
770
|
+
let effectivePackageRoot
|
|
771
|
+
try {
|
|
772
|
+
effectivePackageRoot = await upgradeNitraCursorToLatestAndBunInstall(projectRoot, BUNDLED_PACKAGE_ROOT)
|
|
773
|
+
} catch (error) {
|
|
774
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
775
|
+
console.error(`❌ Не вдалося оновити ${PACKAGE_NAME} або виконати bun i: ${msg}`)
|
|
776
|
+
throw error
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const bundledMdcDir = join(effectivePackageRoot, 'mdc')
|
|
780
|
+
const bundledSkillsDir = join(effectivePackageRoot, 'skills')
|
|
781
|
+
const bundledAgentsTemplatePath = join(effectivePackageRoot, AGENTS_TEMPLATE_FILE)
|
|
782
|
+
|
|
740
783
|
let config
|
|
741
784
|
try {
|
|
742
|
-
config = await readConfig()
|
|
785
|
+
config = await readConfig({ bundledMdcDir, bundledSkillsDir })
|
|
743
786
|
} catch (error) {
|
|
744
787
|
console.error(`❌ ${error.message}`)
|
|
745
788
|
throw error
|
|
746
789
|
}
|
|
747
790
|
|
|
748
791
|
const { rules, skills, version } = config
|
|
749
|
-
const bundledVer = await
|
|
792
|
+
const bundledVer = await readBundledVersionAt(effectivePackageRoot)
|
|
750
793
|
if (bundledVer) {
|
|
751
|
-
|
|
794
|
+
const line =
|
|
795
|
+
effectivePackageRoot === BUNDLED_PACKAGE_ROOT
|
|
796
|
+
? `📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer}`
|
|
797
|
+
: `📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer} (шлях: ${effectivePackageRoot})`
|
|
798
|
+
console.log(`${line}\n`)
|
|
752
799
|
}
|
|
753
800
|
if (version) {
|
|
754
801
|
console.log(`⚠️ Поле "version" у ${CONFIG_FILE} ігнорується; правила беруться з установленого пакету.\n`)
|
|
@@ -757,7 +804,7 @@ async function runSync() {
|
|
|
757
804
|
console.log(`📋 Skills до синхронізації: ${skills.length}`)
|
|
758
805
|
|
|
759
806
|
try {
|
|
760
|
-
const { destPath } = await syncSetupBunDepsAction(cwd(),
|
|
807
|
+
const { destPath } = await syncSetupBunDepsAction(cwd(), effectivePackageRoot)
|
|
761
808
|
console.log(`📝 Оновлено ${destPath} (composite setup-bun-deps з пакету)\n`)
|
|
762
809
|
} catch (error) {
|
|
763
810
|
console.error(`❌ Не вдалося записати setup-bun-deps action: ${error.message}`)
|
|
@@ -776,7 +823,7 @@ async function runSync() {
|
|
|
776
823
|
|
|
777
824
|
try {
|
|
778
825
|
process.stdout.write(` ⬇ ${rule} → ${RULES_DIR}/${fileName} ... `)
|
|
779
|
-
const content = await readBundledRuleContent(rule)
|
|
826
|
+
const content = await readBundledRuleContent(rule, bundledMdcDir)
|
|
780
827
|
await writeFile(destPath, content, 'utf8')
|
|
781
828
|
console.log(`✅`)
|
|
782
829
|
successCount++
|
|
@@ -801,7 +848,7 @@ async function runSync() {
|
|
|
801
848
|
}
|
|
802
849
|
|
|
803
850
|
try {
|
|
804
|
-
const { success: skillOk, fail: skillFail } = await syncSkills(skills)
|
|
851
|
+
const { success: skillOk, fail: skillFail } = await syncSkills(skills, bundledSkillsDir)
|
|
805
852
|
if (skills.length > 0) {
|
|
806
853
|
console.log(`\n🧩 Skills: ${skillOk} скопійовано, ${skillFail} з помилками`)
|
|
807
854
|
}
|
|
@@ -821,7 +868,7 @@ async function runSync() {
|
|
|
821
868
|
}
|
|
822
869
|
|
|
823
870
|
try {
|
|
824
|
-
const { success: cmdOk, fail: cmdFail } = await syncCommands(skills)
|
|
871
|
+
const { success: cmdOk, fail: cmdFail } = await syncCommands(skills, bundledSkillsDir)
|
|
825
872
|
if (skills.length > 0) {
|
|
826
873
|
console.log(`\n⌨️ Commands: ${cmdOk} скопійовано, ${cmdFail} з помилками`)
|
|
827
874
|
}
|
|
@@ -841,7 +888,7 @@ async function runSync() {
|
|
|
841
888
|
}
|
|
842
889
|
|
|
843
890
|
try {
|
|
844
|
-
await syncAgentsMd(skills)
|
|
891
|
+
await syncAgentsMd(skills, bundledAgentsTemplatePath)
|
|
845
892
|
} catch (error) {
|
|
846
893
|
console.error(`❌ Не вдалося оновити ${AGENTS_FILE}: ${error.message}`)
|
|
847
894
|
throw error
|
package/mdc/abie.mdc
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Правила для проєктів AbInBev Efes
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.9'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**), видалення **HealthCheckPolicy** у **ru**),
|
|
7
|
+
Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**), видалення **HealthCheckPolicy** у **ru**), гілки **dev**, **ua**, **ru** у **clean-merged-branch**, а також заборона артефактів **Firebase Hosting** у корені репозиторію.
|
|
8
8
|
|
|
9
9
|
**`npx @nitra/cursor check abie`** виконується лише якщо в **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень.
|
|
10
10
|
|
|
@@ -36,7 +36,7 @@ spec:
|
|
|
36
36
|
|
|
37
37
|
## k8s: overlay **HTTPRoute** (**ua** / **ru**)
|
|
38
38
|
|
|
39
|
-
За наявності **Deployment** під **k8s** і наявності **Vite** (**`vite.config.js`**, **`vite.config.mjs`** або **`vite.config.ts`** у каталозі пакета) у **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** цього пакета потрібні **inline JSON6902** у **`patches`**: **target** **`kind: HTTPRoute`**, **непорожній `name`** (як у маніфесті маршруту). Мають бути зміни **`/spec/hostnames`** (домени abie — у скрипті) та **`/spec/parentRefs/0/namespace`** (**`ua`** / **`ru`**). Для **ru** — анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket
|
|
39
|
+
За наявності **Deployment** під **k8s** і наявності **Vite** (**`vite.config.js`**, **`vite.config.mjs`** або **`vite.config.ts`** у каталозі пакета) у **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** цього пакета потрібні **inline JSON6902** у **`patches`**: **target** **`kind: HTTPRoute`**, **непорожній `name`** (як у маніфесті маршруту). Мають бути зміни **`/spec/hostnames`** (домени abie — у скрипті) та **`/spec/parentRefs/0/namespace`** (**`ua`** / **`ru`**). Для **ru** — анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`** лише якщо в **тому ж** **`ru/kustomization.yaml`** є згадка **`HASURA_GRAPHQL_JWT_SECRET`** (типово patch на **ConfigMap** Hasura). Як обирати **`op`** (**add** / **replace** тощо) у patch — **k8s.mdc** (розділ про JSON patch у kustomization).
|
|
40
40
|
|
|
41
41
|
```yaml title="…/ua/kustomization.yaml (фрагмент)"
|
|
42
42
|
- target:
|
|
@@ -66,7 +66,9 @@ spec:
|
|
|
66
66
|
value: ru
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Якщо в цьому ж файлі є **`HASURA_GRAPHQL_JWT_SECRET`** (Hasura з JWT), додай окремий patch на **HTTPRoute** з анотацією для WebSocket:
|
|
70
|
+
|
|
71
|
+
```yaml title="…/ru/kustomization.yaml (фрагмент, після patch на ConfigMap з HASURA_GRAPHQL_JWT_SECRET)"
|
|
70
72
|
- target:
|
|
71
73
|
kind: HTTPRoute
|
|
72
74
|
name: my-httproute
|
|
@@ -133,6 +135,10 @@ spec:
|
|
|
133
135
|
preem: 'true' # буде замінено через kustomize
|
|
134
136
|
```
|
|
135
137
|
|
|
138
|
+
## Firebase Hosting
|
|
139
|
+
|
|
140
|
+
У корені репозиторію не тримати конфіг і кеш **Firebase Hosting**: видали **`.firebaserc`**, **`firebase.json`** та каталог **`.firebase/`**, якщо вони є.
|
|
141
|
+
|
|
136
142
|
## Git branches
|
|
137
143
|
|
|
138
144
|
У **`.github/workflows/clean-merged-branch.yml`** у кроці **`phpdocker-io/github-actions-delete-abandoned-branches`** значення **`with.ignore_branches`** має містити **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно):
|
package/package.json
CHANGED
package/scripts/check-abie.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* **`phpdocker-io/github-actions-delete-abandoned-branches`** у **`with.ignore_branches`** мають бути
|
|
9
9
|
* **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно).
|
|
10
10
|
*
|
|
11
|
+
* **Firebase Hosting:** у корені репозиторію не має бути **`.firebaserc`**, **`firebase.json`** та каталогу **`.firebase/`**.
|
|
12
|
+
*
|
|
11
13
|
* **k8s:** якщо під деревом із сегментом **`k8s`** є YAML з **`kind: Deployment`**, у тій самій директорії
|
|
12
14
|
* має існувати **`hc.yaml`** із **`HealthCheckPolicy`** (**`networking.gke.io/v1`**), modeline **`$schema`**
|
|
13
15
|
* як у abie.mdc, **`/healthz`**, порт **8080**, **`targetRef`** на **Service** з тим самим **`metadata.name`**.
|
|
@@ -23,7 +25,8 @@
|
|
|
23
25
|
*
|
|
24
26
|
* **HTTPRoute (overlay):** лише якщо в каталозі пакета (батько **`k8s`**) є **`vite.config.js`**, **`vite.config.mjs`** або **`vite.config.ts`**
|
|
25
27
|
* — тоді в **`ua`/`ru` kustomization** потрібен patch на **`kind: HTTPRoute`**, **непорожній `target.name`**: **`/spec/hostnames`**
|
|
26
|
-
* (домени abie.mdc), **`/spec/parentRefs/0/namespace`** (**ua** / **ru**); для **ru** — **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket
|
|
28
|
+
* (домени abie.mdc), **`/spec/parentRefs/0/namespace`** (**ua** / **ru**); для **ru** — **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`**,
|
|
29
|
+
* якщо в тому ж **`kustomization.yaml`** згадується **`HASURA_GRAPHQL_JWT_SECRET`** (Hasura + JWT).
|
|
27
30
|
* Вибір **`op`** — **k8s.mdc**.
|
|
28
31
|
*/
|
|
29
32
|
import { existsSync } from 'node:fs'
|
|
@@ -39,6 +42,9 @@ import { walkDir } from './utils/walkDir.mjs'
|
|
|
39
42
|
|
|
40
43
|
const CONFIG_FILE = '.n-cursor.json'
|
|
41
44
|
|
|
45
|
+
/** Маркер у kustomization.yaml: якщо зустрічається у файлі — для overlay ru у patch HTTPRoute потрібна анотація gwin…websocket. */
|
|
46
|
+
const HASURA_JWT_SECRET_IN_KUSTOMIZATION = 'HASURA_GRAPHQL_JWT_SECRET'
|
|
47
|
+
|
|
42
48
|
/** Очікуваний URL **`$schema`** для **hc.yaml** (abie.mdc). */
|
|
43
49
|
export const ABIE_HC_SCHEMA_URL = 'https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json'
|
|
44
50
|
|
|
@@ -602,11 +608,12 @@ export function getCombinedNginxRunPatchTextFromKustomization(raw) {
|
|
|
602
608
|
* Перевіряє сукупний текст patch(ів) **HTTPRoute** (будь-яке **target.name**) на відповідність abie.mdc.
|
|
603
609
|
* @param {string} combined текст одного або кількох inline **patch**, розділених символом нового рядка
|
|
604
610
|
* @param {'ua' | 'ru'} mode **ua** або **ru**
|
|
611
|
+
* @param {string} [fullKustomizationRaw] повний текст **kustomization.yaml** — для **ru** визначає, чи потрібна анотація **gwin…websocket** (лише якщо є **`HASURA_GRAPHQL_JWT_SECRET`**)
|
|
605
612
|
* @returns {string | null} повідомлення про помилку або **null**
|
|
606
613
|
*/
|
|
607
|
-
export function validateAbieNginxRunHttpRoutePatches(combined, mode) {
|
|
614
|
+
export function validateAbieNginxRunHttpRoutePatches(combined, mode, fullKustomizationRaw) {
|
|
608
615
|
if (typeof combined !== 'string' || combined.trim() === '') {
|
|
609
|
-
return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}; для ru —
|
|
616
|
+
return `очікується patch target kind HTTPRoute з непорожнім target.name (hostnames, parentRefs namespace ${mode}; для ru — gwin… websocket лише за наявності HASURA_GRAPHQL_JWT_SECRET у файлі) — abie.mdc`
|
|
610
617
|
}
|
|
611
618
|
if (!/path:\s*\/spec\/hostnames\b/m.test(combined)) {
|
|
612
619
|
return 'HTTPRoute: потрібен path /spec/hostnames у patch (abie.mdc)'
|
|
@@ -622,8 +629,12 @@ export function validateAbieNginxRunHttpRoutePatches(combined, mode) {
|
|
|
622
629
|
if (!namespaceOk) {
|
|
623
630
|
return `HTTPRoute: потрібен path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
|
|
624
631
|
}
|
|
625
|
-
|
|
626
|
-
|
|
632
|
+
const ruNeedsWebsocket =
|
|
633
|
+
mode === 'ru' &&
|
|
634
|
+
typeof fullKustomizationRaw === 'string' &&
|
|
635
|
+
fullKustomizationRaw.includes(HASURA_JWT_SECRET_IN_KUSTOMIZATION)
|
|
636
|
+
if (ruNeedsWebsocket && !/gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/m.test(combined)) {
|
|
637
|
+
return 'HTTPRoute (ru): за наявності HASURA_GRAPHQL_JWT_SECRET у kustomization потрібна анотація gwin.yandex.cloud/rules.http.upgradeTypes: websocket (abie.mdc)'
|
|
627
638
|
}
|
|
628
639
|
return null
|
|
629
640
|
}
|
|
@@ -636,7 +647,7 @@ export function validateAbieNginxRunHttpRoutePatches(combined, mode) {
|
|
|
636
647
|
*/
|
|
637
648
|
export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
|
|
638
649
|
const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
|
|
639
|
-
return validateAbieNginxRunHttpRoutePatches(combined, mode) === null
|
|
650
|
+
return validateAbieNginxRunHttpRoutePatches(combined, mode, raw) === null
|
|
640
651
|
}
|
|
641
652
|
|
|
642
653
|
/**
|
|
@@ -947,7 +958,7 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
|
|
|
947
958
|
return
|
|
948
959
|
}
|
|
949
960
|
const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
|
|
950
|
-
const v = validateAbieNginxRunHttpRoutePatches(combined, 'ru')
|
|
961
|
+
const v = validateAbieNginxRunHttpRoutePatches(combined, 'ru', raw)
|
|
951
962
|
if (v !== null) {
|
|
952
963
|
fail(`${rel}: ${v}`)
|
|
953
964
|
return
|
|
@@ -959,6 +970,30 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
|
|
|
959
970
|
}
|
|
960
971
|
}
|
|
961
972
|
|
|
973
|
+
/**
|
|
974
|
+
* Перевіряє відсутність артефактів Firebase Hosting у корені репозиторію (abie.mdc).
|
|
975
|
+
* @param {string} root корінь репозиторію
|
|
976
|
+
* @param {(msg: string) => void} passFn успішне повідомлення
|
|
977
|
+
* @param {(msg: string) => void} failFn повідомлення про порушення
|
|
978
|
+
* @returns {void}
|
|
979
|
+
*/
|
|
980
|
+
function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
|
|
981
|
+
for (const name of ['.firebaserc', 'firebase.json']) {
|
|
982
|
+
const abs = join(root, name)
|
|
983
|
+
if (existsSync(abs)) {
|
|
984
|
+
failFn(`Знайдено заборонений файл Firebase Hosting: ${name} — видали його (abie.mdc)`)
|
|
985
|
+
} else {
|
|
986
|
+
passFn(`Немає ${name}`)
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const firebaseDir = join(root, '.firebase')
|
|
990
|
+
if (existsSync(firebaseDir)) {
|
|
991
|
+
failFn('Знайдено директорію .firebase — видали її (abie.mdc)')
|
|
992
|
+
} else {
|
|
993
|
+
passFn('Немає .firebase/')
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
962
997
|
/**
|
|
963
998
|
* Перевіряє відповідність проєкту правилам abie.mdc.
|
|
964
999
|
* @returns {Promise<number>} 0 — OK, 1 — є порушення
|
|
@@ -975,6 +1010,7 @@ export async function check() {
|
|
|
975
1010
|
}
|
|
976
1011
|
|
|
977
1012
|
pass('Правило abie увімкнено — виконуємо перевірки')
|
|
1013
|
+
ensureNoFirebaseHostingArtifacts(root, pass, fail)
|
|
978
1014
|
|
|
979
1015
|
const cleanMergedPath = join(root, '.github/workflows/clean-merged-branch.yml')
|
|
980
1016
|
if (existsSync(cleanMergedPath)) {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Перед синхронізацією правил CLI підтягує останню опубліковану версію `@nitra/cursor` з npm registry
|
|
3
|
+
* у кореневий `package.json` і запускає `bun i` у корені проєкту.
|
|
4
|
+
*
|
|
5
|
+
* Якщо залежність уже задана через `workspace:`, `file:`, `link:` тощо, запис у registry не
|
|
6
|
+
* змінюється і `bun i` не викликається — так зберігається робота монорепо та сценаріїв з `workspace:`, `file:` чи `link:`.
|
|
7
|
+
*
|
|
8
|
+
* Після встановлення повертається шлях до `node_modules/@nitra/cursor`, якщо каталог з
|
|
9
|
+
* `package.json` існує; інакше — fallback (корінь пакету поточного процесу CLI, наприклад кеш npx).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFile } from 'node:child_process'
|
|
13
|
+
import { promisify } from 'node:util'
|
|
14
|
+
import { existsSync } from 'node:fs'
|
|
15
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const PACKAGE_NAME = '@nitra/cursor'
|
|
19
|
+
const NPM_LATEST_URL = 'https://registry.npmjs.org/@nitra/cursor/latest'
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Чи не можна безпечно підставити semver з npm замість поточного специфікатора залежності.
|
|
25
|
+
* @param {string} specifier значення з package.json
|
|
26
|
+
* @returns {boolean} true — залишити як є (монорепо, git, tarball тощо)
|
|
27
|
+
*/
|
|
28
|
+
export function shouldSkipNpmVersionUpgrade(specifier) {
|
|
29
|
+
const s = String(specifier).trim()
|
|
30
|
+
if (!s) {
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
if (/^workspace:/i.test(s)) {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
if (/^file:/i.test(s)) {
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
if (/^link:/i.test(s)) {
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
if (/^portal:/i.test(s)) {
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
if (/^git(\+|:\/\/)/i.test(s)) {
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
if (/^npm:/i.test(s)) {
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
if (/^https?:\/\//i.test(s)) {
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
if (s.startsWith('./') || s.startsWith('../')) {
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Остання версія пакета з npm (поле `version` у JSON dist-tag `latest`).
|
|
62
|
+
* @returns {Promise<string>} semver без префікса `^`
|
|
63
|
+
*/
|
|
64
|
+
export async function fetchLatestNitraCursorVersionFromNpm() {
|
|
65
|
+
const res = await fetch(NPM_LATEST_URL, {
|
|
66
|
+
headers: { accept: 'application/json' }
|
|
67
|
+
})
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`npm registry: ${res.status} ${res.statusText} для ${PACKAGE_NAME}`)
|
|
70
|
+
}
|
|
71
|
+
const data = await res.json()
|
|
72
|
+
if (!data || typeof data.version !== 'string' || !data.version.trim()) {
|
|
73
|
+
throw new Error(`npm registry: у відповіді для ${PACKAGE_NAME} немає поля version`)
|
|
74
|
+
}
|
|
75
|
+
return data.version.trim()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Шлях до встановленого пакета в `node_modules` або fallback.
|
|
80
|
+
* @param {string} projectRoot корінь репозиторію
|
|
81
|
+
* @param {string} fallbackPackageRoot корінь пакету з поточного процесу
|
|
82
|
+
* @returns {string} абсолютний шлях до каталогу з `mdc/`, `scripts/` тощо
|
|
83
|
+
*/
|
|
84
|
+
export function resolveInstalledPackageRoot(projectRoot, fallbackPackageRoot) {
|
|
85
|
+
const installed = join(projectRoot, 'node_modules', PACKAGE_NAME)
|
|
86
|
+
if (existsSync(join(installed, 'package.json'))) {
|
|
87
|
+
return installed
|
|
88
|
+
}
|
|
89
|
+
return fallbackPackageRoot
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Запускає `bun i` у вказаному каталозі з виводом у поточний stdio.
|
|
94
|
+
* @param {string} projectRoot cwd для процесу
|
|
95
|
+
* @returns {Promise<void>} завершується після успішного `bun i`
|
|
96
|
+
*/
|
|
97
|
+
async function runBunInstall(projectRoot) {
|
|
98
|
+
try {
|
|
99
|
+
await execFileAsync('bun', ['i'], { cwd: projectRoot, stdio: 'inherit' })
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const exitCode = typeof error?.code === 'number' ? error.code : null
|
|
102
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
103
|
+
throw new Error(`bun i завершився з кодом ${exitCode}`)
|
|
104
|
+
}
|
|
105
|
+
throw error
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Де зараз оголошено `@nitra/cursor` у package.json.
|
|
111
|
+
* @param {Record<string, unknown>} pkg вміст package.json як об'єкт після читання JSON
|
|
112
|
+
* @returns {{ section: 'devDependencies' | 'dependencies', value: string } | null} секція та специфікатор або null, якщо залежності немає
|
|
113
|
+
*/
|
|
114
|
+
function findNitraCursorDependency(pkg) {
|
|
115
|
+
const dev = pkg.devDependencies
|
|
116
|
+
if (dev && typeof dev === 'object' && !Array.isArray(dev) && PACKAGE_NAME in dev) {
|
|
117
|
+
const value = dev[PACKAGE_NAME]
|
|
118
|
+
if (typeof value === 'string') {
|
|
119
|
+
return { section: 'devDependencies', value }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const deps = pkg.dependencies
|
|
123
|
+
if (deps && typeof deps === 'object' && !Array.isArray(deps) && PACKAGE_NAME in deps) {
|
|
124
|
+
const value = deps[PACKAGE_NAME]
|
|
125
|
+
if (typeof value === 'string') {
|
|
126
|
+
return { section: 'dependencies', value }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Оновлює `@nitra/cursor` до `^<latest>` з npm (якщо дозволено специфікатором), виконує `bun i`,
|
|
134
|
+
* повертає корінь пакету для читання `mdc/` та інших файлів синхронізації.
|
|
135
|
+
* @param {string} projectRoot корінь цільового репозиторію (`cwd()`)
|
|
136
|
+
* @param {string} fallbackPackageRoot корінь пакету з `import.meta.url` (кеш npx або workspace)
|
|
137
|
+
* @returns {Promise<string>} абсолютний шлях до кореня `@nitra/cursor` для копіювання файлів
|
|
138
|
+
*/
|
|
139
|
+
export async function upgradeNitraCursorToLatestAndBunInstall(projectRoot, fallbackPackageRoot) {
|
|
140
|
+
const pkgPath = join(projectRoot, 'package.json')
|
|
141
|
+
if (!existsSync(pkgPath)) {
|
|
142
|
+
return resolveInstalledPackageRoot(projectRoot, fallbackPackageRoot)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let raw
|
|
146
|
+
try {
|
|
147
|
+
raw = await readFile(pkgPath, 'utf8')
|
|
148
|
+
} catch {
|
|
149
|
+
return resolveInstalledPackageRoot(projectRoot, fallbackPackageRoot)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let pkg
|
|
153
|
+
try {
|
|
154
|
+
pkg = JSON.parse(raw)
|
|
155
|
+
} catch {
|
|
156
|
+
return resolveInstalledPackageRoot(projectRoot, fallbackPackageRoot)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
|
|
160
|
+
return resolveInstalledPackageRoot(projectRoot, fallbackPackageRoot)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const found = findNitraCursorDependency(pkg)
|
|
164
|
+
|
|
165
|
+
if (found && shouldSkipNpmVersionUpgrade(found.value)) {
|
|
166
|
+
console.log(`⏭️ ${PACKAGE_NAME}: специфікатор «${found.value}» — без оновлення з npm та без bun i\n`)
|
|
167
|
+
return resolveInstalledPackageRoot(projectRoot, fallbackPackageRoot)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const latest = await fetchLatestNitraCursorVersionFromNpm()
|
|
171
|
+
const desired = `^${latest}`
|
|
172
|
+
|
|
173
|
+
if (!found) {
|
|
174
|
+
if (!pkg.devDependencies || typeof pkg.devDependencies !== 'object' || Array.isArray(pkg.devDependencies)) {
|
|
175
|
+
pkg.devDependencies = {}
|
|
176
|
+
}
|
|
177
|
+
pkg.devDependencies[PACKAGE_NAME] = desired
|
|
178
|
+
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
|
|
179
|
+
console.log(`📝 Додано ${PACKAGE_NAME}@${desired} у devDependencies (остання з npm)\n`)
|
|
180
|
+
await runBunInstall(projectRoot)
|
|
181
|
+
console.log(`📦 Виконано bun i у корені проєкту\n`)
|
|
182
|
+
return resolveInstalledPackageRoot(projectRoot, fallbackPackageRoot)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (found.value === desired) {
|
|
186
|
+
console.log(`📌 ${PACKAGE_NAME} уже ${desired} у package.json — виконуємо bun i\n`)
|
|
187
|
+
} else {
|
|
188
|
+
if (found.section === 'devDependencies') {
|
|
189
|
+
pkg.devDependencies[PACKAGE_NAME] = desired
|
|
190
|
+
} else {
|
|
191
|
+
pkg.dependencies[PACKAGE_NAME] = desired
|
|
192
|
+
}
|
|
193
|
+
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
|
|
194
|
+
console.log(`📝 Оновлено ${PACKAGE_NAME} → ${desired} у package.json\n`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await runBunInstall(projectRoot)
|
|
198
|
+
console.log(`📦 Виконано bun i у корені проєкту\n`)
|
|
199
|
+
|
|
200
|
+
return resolveInstalledPackageRoot(projectRoot, fallbackPackageRoot)
|
|
201
|
+
}
|