@nitra/cursor 1.8.139 → 1.8.141

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 CHANGED
@@ -551,6 +551,20 @@ async function removeOrphanManagedSkillDirs(skillsRoot, configSkills) {
551
551
  return removed.toSorted((a, b) => a.localeCompare(b))
552
552
  }
553
553
 
554
+ /**
555
+ * Рендерить коротку секцію для CLAUDE.md: не розпаралелювати лінт (ESLint) між shells/субагентами.
556
+ * @returns {string[]} рядки для вставки (з порожнім рядком на початку)
557
+ */
558
+ function buildClaudeLintParallelismSectionLines() {
559
+ return [
560
+ '',
561
+ '## Лінт і ESLint (без паралельних запусків)',
562
+ '',
563
+ 'Щоб не запускати **кілька** одночасних **`eslint`** (і не перевантажувати диск/CPU), **заборонено** стартувати `bun run lint` / `lint-js` / `eslint` **паралельно** в різних Bash-задачах, **фонових** shells чи **субагентах** (Task тощо). Має бути **один** послідовний прогон на сесію; команда **`/n-lint`** — **не** ділити на паралельні підзадачі. Деталі: `.cursor/skills/n-lint/SKILL.md`.',
564
+ '',
565
+ ]
566
+ }
567
+
554
568
  /**
555
569
  * Рендерить секцію Skills для CLAUDE.md з урахуванням наявних slash-команд.
556
570
  * @returns {Promise<string[]>} готові рядки секції (або порожній масив)
@@ -610,6 +624,8 @@ async function syncClaudeMd(ignore) {
610
624
  lines.push(`@${RULES_DIR}/${mdcFile}`)
611
625
  }
612
626
 
627
+ lines.push(...buildClaudeLintParallelismSectionLines())
628
+
613
629
  const skillsSectionLines = await buildClaudeSkillsSectionLines()
614
630
  lines.push(...skillsSectionLines, '')
615
631
  const claudeMdPath = join(cwd(), 'CLAUDE.md')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.139",
3
+ "version": "1.8.141",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -9,7 +9,8 @@
9
9
  * - backend: `mirror.gcr.io/library/alpine:*`, `scratch`, `mirror.gcr.io/library/debian:` з тегом, що
10
10
  * містить `slim` (не повний `debian:bookworm`), за винятком PHP/Python — `mirror.gcr.io/library/php:*` або
11
11
  * `mirror.gcr.io/library/python:*`
12
- * - frontend: `mirror.gcr.io/library/nginx:*` або `mirror.gcr.io/openresty/openresty:*`
12
+ * - frontend: `mirror.gcr.io/nginxinc/nginx-unprivileged:*` (preferred) або `mirror.gcr.io/library/nginx:*`,
13
+ * `mirror.gcr.io/openresty/openresty:*`
13
14
  *
14
15
  * Якщо в Dockerfile є крок `bun install` і це не frontend-образ (фінальний stage — alpine),
15
16
  * то очікується компіляція в один бінарник через `bun build --compile` у build stage, а у
@@ -18,8 +19,10 @@
18
19
  * Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
19
20
  * дозволений runtime (alpine, scratch, debian slim, за потреби php/python, nginx або openresty).
20
21
  *
21
- * Для `mirror.gcr.io/library/nginx` у будь-якому `FROM` очікується тег `alpine-slim` (docker.mdc:
22
- * мінімальні образи), не `latest` / `alpine` / інші.
22
+ * Для nginx-образів (`mirror.gcr.io/nginxinc/nginx-unprivileged`, `mirror.gcr.io/library/nginx`) у
23
+ * будь-якому `FROM` очікується тег `alpine-slim` (docker.mdc: мінімальні образи), не `latest` /
24
+ * `alpine` / інші. `nginx-unprivileged` запускається від не-root користувача (uid=101) без явного
25
+ * `USER` у Dockerfile — перевірка non-root для нього пропускається.
23
26
  *
24
27
  * Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
25
28
  * тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
@@ -40,6 +43,7 @@ const BUN_WORD_RE = /\bbun\b/iu
40
43
  const USER_LINE_RE = /^\s*USER\s+([^\s#]+)/iu
41
44
 
42
45
  const NGINX_MIRROR_PREFIX = 'mirror.gcr.io/library/nginx'
46
+ const NGINX_UNPRIVILEGED_MIRROR_PREFIX = 'mirror.gcr.io/nginxinc/nginx-unprivileged'
43
47
 
44
48
  /**
45
49
  * @typedef {{
@@ -94,6 +98,7 @@ const RUNTIME_IMAGES = /** @type {const} */ ([
94
98
  'mirror.gcr.io/library/php',
95
99
  'mirror.gcr.io/library/python',
96
100
  'mirror.gcr.io/library/nginx',
101
+ 'mirror.gcr.io/nginxinc/nginx-unprivileged',
97
102
  'mirror.gcr.io/openresty/openresty'
98
103
  ])
99
104
 
@@ -103,7 +108,7 @@ const DEBIAN_VIA_MIRROR_RE = /^mirror\.gcr\.io\/library\/debian:(.+)$/i
103
108
  /**
104
109
  * Чи ref фінального `FROM` відповідає дозволеним у docker.mdc (multistage / runtime).
105
110
  * @param {string} lastLower ref без digest, lower case
106
- * @returns {boolean}
111
+ * @returns {boolean} true, якщо образ дозволений як фінальний runtime
107
112
  */
108
113
  function isAllowedFinalRuntimeImage(lastLower) {
109
114
  if (lastLower === 'scratch' || lastLower.startsWith('scratch:')) {
@@ -189,7 +194,9 @@ export function getBunCompileHint(fileContent) {
189
194
  const hasBunInstall = BUN_INSTALL_RE.test(fileContent)
190
195
  const isFinalAlpine = lastLower.startsWith('mirror.gcr.io/library/alpine:')
191
196
  const isFinalFrontend =
192
- lastLower.startsWith('mirror.gcr.io/library/nginx:') || lastLower.startsWith('mirror.gcr.io/openresty/openresty:')
197
+ lastLower.startsWith('mirror.gcr.io/library/nginx:') ||
198
+ lastLower.startsWith(`${NGINX_UNPRIVILEGED_MIRROR_PREFIX}:`) ||
199
+ lastLower.startsWith('mirror.gcr.io/openresty/openresty:')
193
200
 
194
201
  if (!hasBunInstall) return null
195
202
  if (!isFinalAlpine) return null
@@ -209,24 +216,26 @@ export function getBunCompileHint(fileContent) {
209
216
  }
210
217
 
211
218
  /**
212
- * Перевіряє, що для `mirror.gcr.io/library/nginx` у `FROM` вказано тег `alpine-slim` (docker.mdc).
219
+ * Перевіряє, що для nginx-образів (`mirror.gcr.io/nginxinc/nginx-unprivileged` або
220
+ * `mirror.gcr.io/library/nginx`) у `FROM` вказано тег `alpine-slim` (docker.mdc).
213
221
  *
214
222
  * @param {string} fileContent вміст Dockerfile/Containerfile
215
223
  * @returns {string | null} повідомлення помилки або null
216
224
  */
217
225
  export function getNginxAlpineSlimTagHint(fileContent) {
226
+ const prefixes = [NGINX_UNPRIVILEGED_MIRROR_PREFIX, NGINX_MIRROR_PREFIX]
218
227
  for (const { line, image } of parseFromStages(fileContent)) {
219
228
  const noDigest = (image.split('@')[0] || '').trim()
220
229
  const d = noDigest.toLowerCase()
221
- if (!d.startsWith(`${NGINX_MIRROR_PREFIX}:`) && d !== NGINX_MIRROR_PREFIX) {
222
- continue
223
- }
224
- if (d === NGINX_MIRROR_PREFIX) {
225
- return `рядок ${line}: \`FROM mirror.gcr.io/library/nginx\` має явний тег \`alpine-slim\` (docker.mdc: мінімальні образи), зараз без тега (типово latest)`
226
- }
227
- const tag = noDigest.slice(NGINX_MIRROR_PREFIX.length + 1)
228
- if (tag.toLowerCase() !== 'alpine-slim') {
229
- return `рядок ${line}: для nginx потрібен тег \`alpine-slim\` (docker.mdc: мінімальні образи), зараз: \`${tag}\``
230
+ const prefix = prefixes.find(p => d.startsWith(`${p}:`) || d === p)
231
+ if (prefix) {
232
+ if (d === prefix) {
233
+ return `рядок ${line}: \`FROM ${prefix}\` має явний тег \`alpine-slim\` (docker.mdc: мінімальні образи), зараз без тега (типово latest)`
234
+ }
235
+ const tag = noDigest.slice(prefix.length + 1)
236
+ if (tag.toLowerCase() !== 'alpine-slim') {
237
+ return `рядок ${line}: для nginx потрібен тег \`alpine-slim\` (docker.mdc: мінімальні образи), зараз: \`${tag}\``
238
+ }
230
239
  }
231
240
  }
232
241
  return null
@@ -247,6 +256,8 @@ export function getNonRootRuntimeHint(fileContent) {
247
256
  if (stages.length === 0) return null
248
257
 
249
258
  const last = stages.at(-1)
259
+ const lastImage = (last?.from.image || '').split('@')[0] || ''
260
+ const lastLower = lastImage.toLowerCase()
250
261
  const lastStageContent = last?.stageContent || ''
251
262
 
252
263
  /** @type {string | null} */
@@ -259,6 +270,8 @@ export function getNonRootRuntimeHint(fileContent) {
259
270
  }
260
271
 
261
272
  if (!lastUserToken) {
273
+ // nginx-unprivileged вже запускається від uid=101 (nginx) без явного USER у Dockerfile
274
+ if (lastLower.startsWith(`${NGINX_UNPRIVILEGED_MIRROR_PREFIX}:`)) return null
262
275
  return 'у фінальному stage має бути `USER <non-root>` (наприклад `USER app`) — принцип non-root (docker.mdc: не превілейований образ)'
263
276
  }
264
277
 
@@ -110,14 +110,15 @@ export function normalizeHubRepoPath(imageToken) {
110
110
  }
111
111
 
112
112
  const HUB_REPOS_REQUIRING_MIRROR = /** @type {const} */ (
113
- new Set(['oven/bun', 'library/alpine', 'library/nginx', 'library/node'])
113
+ new Set(['oven/bun', 'library/alpine', 'library/nginx', 'library/node', 'nginxinc/nginx-unprivileged'])
114
114
  )
115
115
 
116
116
  const EXPECTED_MIRROR = /** @type {const} */ ({
117
117
  'oven/bun': 'mirror.gcr.io/oven/bun',
118
118
  'library/alpine': 'mirror.gcr.io/library/alpine',
119
119
  'library/nginx': 'mirror.gcr.io/library/nginx',
120
- 'library/node': 'mirror.gcr.io/library/node'
120
+ 'library/node': 'mirror.gcr.io/library/node',
121
+ 'nginxinc/nginx-unprivileged': 'mirror.gcr.io/nginxinc/nginx-unprivileged'
121
122
  })
122
123
 
123
124
  /**
@@ -47,6 +47,27 @@ bun run lint
47
47
  - Якщо тестів **немає** або вони **не покривають** блок, який змінюєш — **спочатку** додай/розшир тести, переконайся, що вони стабільно проходять, **потім** роби рефакторинг, **потім** знову прогони тести й **`bun run lint`**, щоб підтвердити, що функціональність коректна й лінт чистий.
48
48
  - Якщо після рефакторингу тести або лінт падають — **не** залишай «половинчастий» рефакторинг: відкотись або доведи зміни до зеленого стану.
49
49
 
50
+ ## Навантаження на macOS (багато процесів `eslint` / лаги)
51
+
52
+ Часто це **не** «один ESLint розпаралелив себе всередині», а **кілька паралельних запусків** одного й того ж ланцюжка (**`bun run lint`**, **`bun run lint-js`**) у різних Bash-задачах агента (кілька shells). Кожен запуск = окремий процес **`eslint`** плюс **`oxlint`**, **`jscpd`** тощо — диск і CPU перегружаються.
53
+
54
+ **Чому взагалі кілька `eslint`:** оркестратор (Claude Code, Cursor тощо) може **розпаралелити** роботу: кілька **субагентів** / **паралельних Bash-задач** / **фонових shell**, і кожен **сам** виконує **`/n-lint`** або запускає **`eslint`**. Тоді навантаження **множиться**: не один прогон лінту, а **N прогонів** одночасно.
55
+
56
+ **Що робити агенту під час виконання цього скілу (обов’язково)**
57
+
58
+ 1. **Один** запуск **`bun run lint`** (або всі кроки **`lint`**, як у **`package.json`**) — у **одному** foreground shell, **без** `run_in_background` / фонових копій тієї ж команди.
59
+ 2. **Не** викликати **паралельні субагенти** (subagent, Task, «розбий на N паралельних завдань») лише заради лінту в одному репозиторії. Лінт не потребує шардінгу: один процес, послідовно.
60
+ 3. Якщо ти **батьок-сесія** й уже делегував дочірнім задачам **інші** кроки — **не** доручай дочірнім і **`bun run lint`**, залиш **лише** собі один **послідовний** прогон **після** змін у файлах, або **один** виклик цілого скілу на сесію.
61
+ 4. Якщо сесія/користувач уже запускає лінт — **не** дублювати; зачекати завершення або **не** стартувати лінт повторно в іншому shell.
62
+
63
+ **Що можна змінити у проєкті (локально або в `package.json`)**
64
+
65
+ - **ESLint** (CLI): за потреби явно **`--concurrency off`** (див. **`eslint --help`**).
66
+ - **oxlint**: **`--threads=1`**, якщо потрібно зменшити навантаження на CPU.
67
+ - **ESLint cache**: **`--cache`** / **`--cache-location .eslintcache`** — менше повторного читання з диска.
68
+
69
+ Канонічний рядок **`lint-js`** у репозиторіях з **`check js-lint`** фіксований; додаткові прапорці — з узгодженням канону або в споживацькому проєкті окремо.
70
+
50
71
  ## Примітка
51
72
 
52
73
  Цей скіл **не** замінює **`npx @nitra/cursor check`**: **`lint`** перевіряє лінтери/формат у **`package.json`**, а **`check`** — програмні правила пакета **`@nitra/cursor`**. За потреби запускай обидва.