diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9bac79447e7243b8fc2cbf77742829bf233d0987..1ba81b28d29f99f363df38418f275a9d4344c812 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,10 +9,22 @@ # nodejs.org даёт HTTP=000 (и по IPv4, и по IPv6), а внутренний GitLab # отвечает за 0.06 c. Доступен только hub.mos.ru. # -# Пока платформа не откроет доступ к внешнему реестру или не поднимет -# внутреннее зеркало npm, джоба помечена allow_failure: true — чтобы -# постоянно красный пайплайн не блокировал слияния. Как только реестр -# заработает, снять allow_failure и вернуть правило защищённой ветки. +# Решение — обёртка scripts/npmMirrorFallback.mjs (схема из Pi): основная +# попытка идёт в реестр npm, при сетевой неудаче перебираются зеркала, +# заданные переменными окружения. Адрес зеркала НЕ зашит в репозиторий: +# если платформа поднимет внутреннее зеркало (или у раннера откроется доступ +# к внешнему), оно задаётся в Settings → CI/CD → Variables: +# +# NPM_PRIMARY — основной реестр (напр. внутреннее зеркало hub.mos.ru) +# NPM_MIRROR — первое зеркало (по умолчанию npmmirror = Alibaba) +# NPM_MIRROR_2 — второе зеркало (по умолчанию Tencent Cloud) +# NPM_MIRROR_2_OFF=1 — отключить второе зеркало +# +# Подробнее — docs/NPM_MIRRORS.md. +# +# Пока рабочего реестра нет, джоба помечена allow_failure: true — чтобы +# постоянно красный пайплайн не блокировал слияния. Как только установка +# пройдёт успешно, снять allow_failure и вернуть правило защищённой ветки. stages: - check @@ -43,11 +55,22 @@ check: when: - runner_system_failure - stuck_or_timeout_failure + # Лог попыток реестров: при разборе красной джобы видно, куда именно + # ходил npm и чем это закончилось. + variables: + NPM_MIRROR_LOG: $CI_PROJECT_DIR/npm-mirror.log + artifacts: + when: always + paths: + - npm-mirror.log + expire_in: 1 week script: + # Установка через обёртку: основной реестр, затем зеркала (см. шапку файла + # и docs/NPM_MIRRORS.md). # --no-audit/--no-fund: не ходим за advisory-фидом — самая хрупкая и # самая ненужная на CI часть установки. # --prefer-offline + --cache: сначала смотрим в кэш раннера. - - npm ci --cache .npm --prefer-offline --no-audit --no-fund + - node scripts/npmMirrorFallback.mjs ci --cache .npm --prefer-offline --no-audit --no-fund - npm run check rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 6bacb0c68f1ce7b0568e9364c6842ac0cfe0b4e9..f75ae958fa48b0342a5a7d4412ebc6fb7823fe47 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -14,6 +14,7 @@ | `docs/PARSER_LIMITATIONS.md` | Границы обратного парсера JS/TS: что распознаётся, что упрощается и как узнать об упрощении | | `docs/GUIDE.md` | Сценарии пользователя: нарисовать → сгенерировать → вернуть из текста | | `docs/ROADMAP.md` | Что сделано, что дальше, что сознательно отложено | +| `docs/NPM_MIRRORS.md` | Установка зависимостей и зеркала npm в CI (схема из Pi) | | `docs/REFACTORING.md` | Известные слабые места и договорённости на будущее | | `docs/AGENTS.md` | Этот файл | diff --git a/docs/NPM_MIRRORS.md b/docs/NPM_MIRRORS.md new file mode 100644 index 0000000000000000000000000000000000000000..440556e8ab580c743124f4c61355a18ff3186bbd --- /dev/null +++ b/docs/NPM_MIRRORS.md @@ -0,0 +1,81 @@ +# npm-зеркала и установка зависимостей в CI + +## Зачем это + +У раннеров `appsechub` (hub.mos.ru) нет выхода в интернет: `npm ci` к +`registry.npmjs.org` падает по `ETIMEDOUT`, а `npm run check` даже не +запускается. Доступен только внутренний GitLab. + +Чтобы пайплайн можно было включить одной переменной в настройках GitLab — +без правки файлов репозитория — установка идёт через обёртку +`scripts/npmMirrorFallback.mjs`. Это порт схемы установки из Pi. + +## Как это работает + +Обёртка запускает `npm` от вашего имени и, если **сетевая** команда упала, +повторяет её на зеркалах: + +``` +основной реестр (конфиг npm или NPM_PRIMARY) + -> зеркало 1 (NPM_MIRROR, по умолчанию npmmirror = Alibaba) + -> зеркало 2 (NPM_MIRROR_2, по умолчанию Tencent Cloud) +``` + +Несетевые команды (`npm root -g`, `npm config get ...`) выполняются один раз. +Если в аргументах уже есть `--registry` или npm нацелен на последнее зеркало, +повтора нет: вызывающий уже решил сам. + +Ретраятся только сетевые команды: `install`, `i`, `add`, `update`, `up`, +`ci`, `pack`, `view`, `info`, `outdated`, `audit`, `exec`, `x`, `dedupe`, +`publish`. + +## Переменные окружения + +| Переменная | Смысл | +| ------------------ | ---------------------------------------------------------------------- | +| `NPM_PRIMARY` | основной реестр (по умолчанию — конфиг npm) | +| `NPM_MIRROR` | первое зеркало (по умолчанию `https://registry.npmmirror.com`) | +| `NPM_MIRROR_2` | второе зеркало (по умолчанию `https://mirrors.cloud.tencent.com/npm/`) | +| `NPM_MIRROR_2_OFF` | `1`/`true` — не пробовать второе зеркало | +| `NPM_MIRROR_LOG` | дописывать строку на каждую попытку в этот файл | +| `NPM_MIRROR_OUT` | `1`/`true` — только напечатать план реестров, npm не запускать | + +**Адрес внутреннего зеркала в репозиторий не заводится.** Когда платформа +поднимет npm-зеркало (или у раннера откроют доступ к внешнему реестру), +достаточно задать переменную в GitLab: _Settings → CI/CD → Variables_. +Правок в коде не требуется. + +### Как задать внутреннее зеркало + +```text +NPM_PRIMARY = https://<внутренний-реестр>/repository/npm/ +NPM_MIRROR_2_OFF = 1 # зачем пробовать чужие зеркала, если есть своё +``` + +## Диагностика + +Посмотреть, какие реестры будут использованы, не запуская npm: + +```bash +NPM_MIRROR_OUT=1 node scripts/npmMirrorFallback.mjs ci +# {"primary":"(npm default)","mirrors":["https://registry.npmmirror.com",...]} +``` + +В CI обёртка пишет лог попыток в `npm-mirror.log`, он сохраняется как +artifact джобы на одну неделю — по нему видно, куда ходил npm и чем это +закончилось. + +## Почему джоба `check` пока `allow_failure: true` + +Пока рабочего реестра нет, установка всё равно упадёт, и красная джоба +блокировала бы все слияния. `allow_failure: true` снимается, как только +`node scripts/npmMirrorFallback.mjs ci ...` в CI отработает успешно. + +## Проверка + +Тесты «мозга» обёртки (решение о переборе зеркал) — `scripts/test/npmMirrorFallback.test.ts`, +запускаются вместе со всем `npm test`: + +```bash +node --test scripts/test/npmMirrorFallback.test.ts +``` diff --git a/package.json b/package.json index bab50d1061220674a57fbd43cad07cb7709e6609..805c293fdcc4af89f2792d5055d69a01e309deea 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit -p tsconfig.json", - "test": "node --test \"packages/core/test/**/*.test.ts\" \"packages/extension/test/**/*.test.ts\"", + "npm:wrapped": "node scripts/npmMirrorFallback.mjs", + "test": "node --test \"packages/core/test/**/*.test.ts\" \"packages/extension/test/**/*.test.ts\" \"scripts/test/**/*.test.ts\"", "check": "npm run lint && npm run format:check && npm run typecheck && npm run test", "build": "npm run build --workspace ./packages/extension" }, diff --git a/packages/extension/test/architecture.test.ts b/packages/extension/test/architecture.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3512223cd3ac45da7c3341ee5642e08458e1792e --- /dev/null +++ b/packages/extension/test/architecture.test.ts @@ -0,0 +1,262 @@ +/** + * Static architecture guards: cycles, layer direction, and the core boundary. + * + * Why a test and not a linter rule + * -------------------------------- + * ESLint with `eslint-plugin-import`/`no-cycle` would need a new dev dependency and a resolver + * tuned for `.ts`-suffixed specifiers (AGENTS.md rule 2), and it would still not know *which* + * direction each edge is allowed to point. The rules live in docs/ARCHITECTURE.md (the L0-L5 + * table, column "не знает о") and docs/AGENTS.md (rule 1: the core never imports `vscode` or the + * extension). Those are statements about this repo's own file layout, so the cheapest honest + * check reads the sources directly. + * + * What it catches + * --------------- + * - A cycle anywhere in the import graph. TypeScript happily compiles one and it only bites at + * runtime (an empty binding, an `undefined` during module init), usually far from the edit that + * introduced it. `import type` edges are erased at compile time and a type-only cycle is legal + * TypeScript, so the cycle walk skips them; the direction walk does not (see below). + * - An upward layer edge. `ir.ts` (L1) reaching into `codegen/javascript.ts` (L2) or `model.ts` + * (L0) knowing about code generation would compile fine and quietly break the "lower layer + * knows less" contract the whole layering rests on. An `import type` from a higher layer still + * counts: it costs nothing at runtime, but the lower layer's signatures then name the higher + * layer, which is exactly the coupling the architecture table forbids. + * - A dynamic `import()`/`require()`, which is invisible to the static graph above and would make + * both of the previous guards lie; it has its own test. + * - An undeclared source file. The layer map below is data, and data goes stale: a new file that + * is not listed would otherwise be silently exempt from every direction check. + * + * What it does NOT do: it reads text, not a resolved module graph, so it cannot see a dependency + * that a path alias or a bundler would introduce. There is no such alias here — `@drakon/core/*` + * is a plain workspace export and every intra-repo import is a relative `.ts` path. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, relative, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "..", "..", ".."); +const sourceRoots = [ + join(repoRoot, "packages", "core", "src"), + join(repoRoot, "packages", "extension", "src"), + join(repoRoot, "packages", "extension", "webview"), +]; + +/** + * Layer of every source file, from the table in docs/ARCHITECTURE.md. A file may import from its + * own layer or a lower one, never a higher one. `index.ts` is the public barrel: it re-exports + * across layers on purpose, so it is exempt from the direction rule (it is still cycle-checked). + */ +const LAYERS: Record = { + "packages/core/src/model.ts": 0, + "packages/core/src/edit.ts": 1, + "packages/core/src/codegen/ir.ts": 1, + "packages/core/src/parse/types.ts": 1, + "packages/core/src/parse/drakonJson.ts": 1, + "packages/core/src/parse/javascript.ts": 1, + "packages/core/src/parse/registry.ts": 1, + "packages/core/src/codegen/types.ts": 2, + "packages/core/src/codegen/javascript.ts": 2, + "packages/core/src/codegen/pseudocode.ts": 2, + "packages/core/src/codegen/registry.ts": 2, + "packages/core/src/index.ts": "facade", + "packages/extension/src/diagramDocument.ts": 3, + "packages/extension/src/drakonEditorProvider.ts": 4, + "packages/extension/src/codegen.ts": 4, + "packages/extension/src/showDiagram.ts": 4, + "packages/extension/src/preview.ts": 4, + "packages/extension/src/webviewHtml.ts": 4, + "packages/extension/src/extension.ts": 5, + "packages/extension/webview/icons.ts": 5, + "packages/extension/webview/main.ts": 5, + "packages/extension/webview/widget.ts": 5, +}; + +interface Edge { + from: string; + to: string; + typeOnly: boolean; +} + +function walk(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...walk(full)); + else if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** A specifier that is not part of this repo (a package, `node:`, or a path alias). */ +function isExternal(specifier: string): boolean { + return !specifier.startsWith("."); +} + +function projectPath(file: string): string { + return relative(repoRoot, file).split("\\").join("/"); +} + +/** `import type { A } from "x"` and `export type { A } from "x"` carry no runtime edge. */ +function isTypeOnly(clause: string): boolean { + const trimmed = clause.trim(); + if (/^type\s/.test(trimmed)) return true; + const braces = trimmed.match(/^\{([\s\S]*)\}$/); + if (!braces) return false; + const names = (braces[1] ?? "") + .split(",") + .map((name) => name.trim()) + .filter(Boolean); + return names.length > 0 && names.every((name) => /^type\s/.test(name)); +} + +/** Static `import`/`export ... from` plus side-effect `import "x"`. */ +function edgesOf(source: string, file: string): Edge[] { + const edges: Edge[] = []; + for (const match of source.matchAll(/\b(import|export)\s+([\s\S]*?)\sfrom\s+["']([^"']+)["']/g)) { + const [, , clause, specifier] = match; + edges.push({ from: file, to: specifier as string, typeOnly: isTypeOnly(clause as string) }); + } + for (const match of source.matchAll(/\bimport\s+["']([^"']+)["']/g)) { + edges.push({ from: file, to: match[1] as string, typeOnly: false }); + } + return edges; +} + +function dynamicSpecifiers(source: string): string[] { + const found: string[] = []; + for (const match of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) { + found.push(match[1] as string); + } + for (const match of source.matchAll(/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g)) { + found.push(match[1] as string); + } + return found; +} + +/** Resolve a relative specifier against the importing file, keeping repo-relative `/` paths. */ +function toFile(fromFile: string, specifier: string): string | undefined { + const resolved = resolve(dirname(fromFile), specifier); + return resolved.startsWith(repoRoot) ? projectPath(resolved) : undefined; +} + +const files = sourceRoots + .flatMap((root) => walk(root)) + .map(projectPath) + .sort(); +const sources = new Map(files.map((file) => [file, readFileSync(resolve(repoRoot, file), "utf8")])); + +/** Every intra-repo edge, plus the ones that point outside the known files (filtered by caller). */ +function edges(file: string): Edge[] { + return edgesOf(sources.get(file) as string, file); +} + +test("every source file declares its layer", () => { + const undeclared = files.filter((file) => !(file in LAYERS)); + assert.deepEqual(undeclared, [], "add the new file to LAYERS in this test"); +}); + +test("the import graph has no cycles", () => { + const graph = new Map(); + for (const file of files) { + const targets = edges(file) + // `import type` is erased by the compiler and a type-only cycle is legal TypeScript. + .filter((edge) => !edge.typeOnly) + .map((edge) => toFile(file, edge.to)) + .filter((target): target is string => target !== undefined && sources.has(target)); + graph.set(file, targets); + } + + const WHITE = 0; + const GREY = 1; + const BLACK = 2; + const colour = new Map(files.map((file) => [file, WHITE])); + const stack: string[] = []; + const cycles: string[] = []; + + const visit = (file: string): void => { + colour.set(file, GREY); + stack.push(file); + for (const next of graph.get(file) ?? []) { + const state = colour.get(next); + if (state === GREY) { + const start = stack.indexOf(next); + cycles.push([...stack.slice(start), next].join(" -> ")); + } else if (state === WHITE) { + visit(next); + } + } + stack.pop(); + colour.set(file, BLACK); + }; + + for (const file of files) { + if (colour.get(file) === WHITE) visit(file); + } + + assert.deepEqual(cycles, [], "circular imports, caught at runtime only"); +}); + +test("no import points from a lower layer to a higher one", () => { + const violations: string[] = []; + for (const file of files) { + const from = LAYERS[file]; + // An undeclared file is a failure of the first test; the direction rule cannot judge a file + // whose layer is unknown, so it skips it rather than invent a layer for it. + if (from === undefined || from === "facade") continue; + for (const edge of edges(file)) { + const target = toFile(file, edge.to); + if (target === undefined) continue; + const to = LAYERS[target]; + if (to === undefined || to === "facade") continue; + if (to > from) { + violations.push( + `${file} (L${from}) -> ${target} (L${to})${edge.typeOnly ? " [type]" : ""}`, + ); + } + } + } + assert.deepEqual(violations, [], "an upward edge breaks the layer contract"); +}); + +test("the core never imports the host: no vscode, no extension package", () => { + const violations: string[] = []; + for (const file of files) { + if (!file.startsWith("packages/core/")) continue; + const source = sources.get(file) as string; + const specifiers = [...edges(file).map((edge) => edge.to), ...dynamicSpecifiers(source)]; + for (const specifier of specifiers) { + const forbidden = + specifier === "vscode" || + specifier.startsWith("vscode/") || + specifier.includes("packages/extension") || + specifier.startsWith("@drakon/extension"); + if (forbidden) violations.push(`${file} -> ${specifier}`); + } + } + assert.deepEqual(violations, [], "AGENTS.md rule 1: the core must stay platform-free"); +}); + +test("no source bypasses the static graph with import() or require()", () => { + const violations: string[] = []; + for (const file of files) { + const found = dynamicSpecifiers(sources.get(file) as string); + if (found.length > 0) violations.push(`${file}: ${found.join(", ")}`); + } + assert.deepEqual(violations, [], "a dynamic import makes the cycle and layer guards blind"); +}); + +test("relative imports carry an explicit .ts extension", () => { + const violations: string[] = []; + for (const file of files) { + for (const edge of edges(file)) { + if (!isExternal(edge.to) && !edge.to.endsWith(".ts")) { + violations.push(`${file} -> ${edge.to}`); + } + } + } + assert.deepEqual(violations, [], "AGENTS.md rule 2: node --test resolves sources directly"); +}); diff --git a/scripts/npmMirrorFallback.d.mts b/scripts/npmMirrorFallback.d.mts new file mode 100644 index 0000000000000000000000000000000000000000..cfef433a55c23f9af3165beb238c73f7bf3e9085 --- /dev/null +++ b/scripts/npmMirrorFallback.d.mts @@ -0,0 +1,15 @@ +// Типы для обёртки npm с перебором реестров (scripts/npmMirrorFallback.mjs). +// Скрипт остаётся обычным .mjs (его запускает node в CI без сборки), а этот +// файл даёт typecheck и eslint видимую подпись экспортируемой чистой функции. + +export declare const MIRROR_1_DEFAULT: string; +export declare const MIRROR_2_DEFAULT: string; +export declare const NETWORK_COMMANDS: ReadonlySet; + +export declare function planRegistryAttempts(input: { + command: string; + args: readonly string[]; + status: number; + mirrors: readonly string[]; + forwardedRegistry?: string; +}): { shouldRetry: boolean; chain: string[] }; diff --git a/scripts/npmMirrorFallback.mjs b/scripts/npmMirrorFallback.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c4d00b704d194748548a9cb261d02e6f91d95785 --- /dev/null +++ b/scripts/npmMirrorFallback.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// Обёртка npm с перебором реестров — порт схемы Pi в CI проекта. +// +// Зачем. У раннеров appsechub нет выхода в интернет (см. .gitlab-ci.yml): +// `npm ci` к registry.npmjs.org падает по таймауту. Схема Pi решает это так: +// сначала пробуем основной реестр, при сетевой неудаче — по очереди зеркала. +// Если платформа поднимет внутреннее зеркало npm, оно подставляется первым +// кандидатом через NPM_PRIMARY, и код менять не придётся. +// +// Поток для сетевой команды (install/ci/update/...): +// основной реестр (конфиг npm или NPM_PRIMARY) +// -> зеркало 1 (NPM_MIRROR, по умолчанию npmmirror = Alibaba) +// -> зеркало 2 (NPM_MIRROR_2, по умолчанию Tencent; NPM_MIRROR_2_OFF=1 отключает) +// +// Несетевые команды (root -g, pm bin -g, ...) выполняются один раз против +// основного реестра. Если в аргументах уже есть `--registry` или npm уже +// нацелен на последнее зеркало, повтор не делается: вызывающий уже решил сам. +// +// Переопределяется переменными окружения: +// NPM_PRIMARY задать основной реестр (по умолчанию — конфиг npm) +// NPM_MIRROR первое зеркало +// NPM_MIRROR_2 второе зеркало +// NPM_MIRROR_2_OFF 1/true — пропустить второе зеркало +// NPM_MIRROR_LOG дописывать строку на попытку в этот файл +// NPM_MIRROR_OUT если 1/true — только печатать реестры, не запускать npm +// (используется тестом и диагностикой CI) +// +// Подключение в CI: `NPM_COMMAND="node scripts/npmMirrorFallback.mjs"` и далее +// в скрипте джобы вместо `npm ci ...` вызывать `$NPM_COMMAND ci ...`. + +import { spawnSync } from "node:child_process"; +import { appendFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Зеркало по умолчанию: npmmirror (Alibaba). */ +export const MIRROR_1_DEFAULT = "https://registry.npmmirror.com"; +/** Второе зеркало по умолчанию: Tencent Cloud. */ +export const MIRROR_2_DEFAULT = "https://mirrors.cloud.tencent.com/npm/"; + +/** Команды, которые ходят в сеть и потому заслуживают повтора на зеркале. */ +export const NETWORK_COMMANDS = new Set([ + "install", + "i", + "add", + "update", + "up", + "ci", + "pack", + "view", + "info", + "outdated", + "audit", + "exec", + "x", + "dedupe", + "publish", +]); + +/** + * Решает, какие зеркала пробовать после неудачи основного реестра. + * + * Чистая функция без побочных эффектов — её и проверяют тесты; сам запуск npm + * остаётся тонкой обвязкой вокруг этого решения. + * + * @param {object} input + * @param {string} input.command имя команды npm (первый не-флаговый аргумент) + * @param {string[]} input.args полный список аргументов + * @param {number} input.status код возврата основной попытки + * @param {string[]} input.mirrors зеркала в порядке обхода + * @param {string} [input.forwardedRegistry] реестр, уже навязанный npm (env) + * @returns {{ shouldRetry: boolean, chain: string[] }} + */ +export function planRegistryAttempts({ command, args, status, mirrors, forwardedRegistry }) { + const pinned = args.includes("--registry") || forwardedRegistry === MIRROR_2_DEFAULT; + if (status === 0 || !NETWORK_COMMANDS.has(command) || pinned) { + return { shouldRetry: false, chain: [] }; + } + const chain = mirrors.filter((registry) => registry && registry !== forwardedRegistry); + return { shouldRetry: chain.length > 0, chain }; +} + +function resolveNpmCli() { + // Предпочитаем вызывать JS-точку входа npm напрямую: на Windows так + // аргументы с пробелами доживают до npm без участия шелла. + const candidates = [ + join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), + join(dirname(process.execPath), "lib", "node_modules", "npm", "bin", "npm-cli.js"), + ]; + return candidates.find((p) => existsSync(p)); +} + +function log(message) { + if (process.env.NPM_MIRROR_LOG) { + try { + appendFileSync(process.env.NPM_MIRROR_LOG, `${new Date().toISOString()} ${message}\n`); + } catch { + /* логирование не должно ломать npm */ + } + } +} + +function run(args, registry) { + const cli = resolveNpmCli(); + const env = { ...process.env }; + if (registry) env.npm_config_registry = registry; + const options = { stdio: "inherit", env }; + + if (cli) { + return spawnSync(process.execPath, [cli, ...args], options); + } + const isWin = process.platform === "win32"; + return spawnSync(isWin ? "npm.cmd" : "npm", args, { ...options, shell: isWin }); +} + +function resolveMirrors() { + const secondOff = /^(1|true|yes)$/i.test(process.env.NPM_MIRROR_2_OFF || ""); + return [ + process.env.NPM_MIRROR || MIRROR_1_DEFAULT, + secondOff ? undefined : process.env.NPM_MIRROR_2 || MIRROR_2_DEFAULT, + ].filter(Boolean); +} + +function main() { + const args = process.argv.slice(2); + const primary = process.env.NPM_PRIMARY || undefined; + const mirrors = resolveMirrors(); + + if (/^(1|true|yes)$/i.test(process.env.NPM_MIRROR_OUT || "")) { + // Диагностический режим: показать, что и в каком порядке будет запущено. + console.log(JSON.stringify({ primary: primary ?? "(npm default)", mirrors })); + process.exit(0); + } + + const command = args.find((a) => !a.startsWith("-")) ?? ""; + log(`run primary registry=${primary ?? "(npm default)"} args=${JSON.stringify(args)}`); + const first = run(args, primary); + + const { shouldRetry, chain } = planRegistryAttempts({ + command, + args, + status: first.status ?? 1, + mirrors, + forwardedRegistry: process.env.npm_config_registry, + }); + + if (first.error) { + console.error(`npm-mirror-fallback: ${first.error.message}`); + process.exit(1); + } + if (!shouldRetry) { + process.exit(first.status ?? 1); + } + + log(`primary failed (status=${first.status}); trying ${chain.join(" -> ")}`); + console.error(`\nnpm-mirror-fallback: primary registry failed, trying ${chain.join(" then ")}\n`); + + for (const registry of chain) { + const attempt = run(args, registry); + log(`attempt ${registry} finished status=${attempt.status}`); + if (attempt.status === 0) { + process.exit(0); + } + } + + process.exit(first.status ?? 1); +} + +// Запускаем main только при прямом вызове: тест импортирует чистую функцию и +// не должен трогать npm. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/scripts/test/npmMirrorFallback.test.ts b/scripts/test/npmMirrorFallback.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d3cfe88114a6c2f253d6ca11f8a310c19242aff --- /dev/null +++ b/scripts/test/npmMirrorFallback.test.ts @@ -0,0 +1,90 @@ +// Тесты решения о переборе реестров — «мозга» обёртки npm-mirror-fallback. +// +// Сам скрипт запускает npm и потому в юнит-тесте не наблюдаем; проверяем ровно +// ту чистую функцию, которая решает, какие реестры пробовать и в каком порядке. +// Ошибочное решение здесь стоит дорого: лишний поход в недоступный реестр +// растягивает джобу до таймаута, а пропущенный fallback оставляет CI красным. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { MIRROR_1_DEFAULT, MIRROR_2_DEFAULT, planRegistryAttempts } from "../npmMirrorFallback.mjs"; + +const mirrors = [MIRROR_1_DEFAULT, MIRROR_2_DEFAULT]; + +test("сетевая команда с упавшим реестром перебирает зеркала по порядку", () => { + const plan = planRegistryAttempts({ + command: "ci", + args: ["ci", "--cache", ".npm"], + status: 1, + mirrors, + }); + + assert.equal(plan.shouldRetry, true); + assert.deepEqual(plan.chain, mirrors); +}); + +test("успешный реестр — второй попытки нет", () => { + const plan = planRegistryAttempts({ command: "ci", args: ["ci"], status: 0, mirrors }); + + assert.equal(plan.shouldRetry, false); + assert.deepEqual(plan.chain, []); +}); + +test("несетевая команда не ходит в зеркала", () => { + const plan = planRegistryAttempts({ command: "root", args: ["root", "-g"], status: 1, mirrors }); + + assert.equal(plan.shouldRetry, false); + assert.deepEqual(plan.chain, []); +}); + +test("явный --registry в аргументах отменяет fallback", () => { + const plan = planRegistryAttempts({ + command: "install", + args: ["install", "--registry", "https://nexus.hub.mos.ru/repository/npm/"], + status: 1, + mirrors, + }); + + assert.equal(plan.shouldRetry, false); + assert.deepEqual(plan.chain, []); +}); + +test("последнее зеркало, уже переданное в npm, не повторяется", () => { + const plan = planRegistryAttempts({ + command: "install", + args: ["install"], + status: 1, + mirrors, + forwardedRegistry: MIRROR_1_DEFAULT, + }); + + assert.equal(plan.shouldRetry, true); + assert.deepEqual(plan.chain, [MIRROR_2_DEFAULT]); +}); + +test("отключённое второе зеркало не попадает в цепочку", () => { + const plan = planRegistryAttempts({ + command: "ci", + args: ["ci"], + status: 1, + mirrors: [MIRROR_1_DEFAULT], + }); + + assert.equal(plan.shouldRetry, true); + assert.deepEqual(plan.chain, [MIRROR_1_DEFAULT]); +}); + +test("пустая цепочка зеркал не считается поводом для повтора", () => { + const plan = planRegistryAttempts({ command: "ci", args: ["ci"], status: 1, mirrors: [] }); + + assert.equal(plan.shouldRetry, false); + assert.deepEqual(plan.chain, []); +}); + +test("короткие сетевые алиасы тоже перебирают зеркала", () => { + for (const command of ["i", "add", "up", "x"]) { + const plan = planRegistryAttempts({ command, args: [command], status: 1, mirrors }); + assert.equal(plan.shouldRetry, true, `команда ${command} должна перебирать зеркала`); + } +}); diff --git a/tsconfig.json b/tsconfig.json index d0f7d0757994a0ea4b911b7870ab9c2b024044a7..77e8ff6774204ed8bc7edea42d54a4cfbdf67843 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "packages/extension/src/**/*.ts", "packages/extension/webview/**/*.ts", "packages/extension/test/**/*.ts", + "scripts/**/*.ts", "esbuild.js", "eslint.config.js" ]