diff --git a/scripts/sync-sched-publish.mjs b/scripts/sync-sched-publish.mjs new file mode 100644 index 0000000..ea37f1a --- /dev/null +++ b/scripts/sync-sched-publish.mjs @@ -0,0 +1,334 @@ +// sched → GitHub: полный синк курируемой копии (ранбук wiki:3332, task:1711). +// +// Алгоритм (один коммит на волну, pre-push CI-эквивалентный гейт): +// collect — ls-files dev минус исключения (.wiki/.tasks/.agents/AGENTS/CLAUDE/ +// branding-pora/pngtree/327274997/cat-clock/MIGRATION) +// copy — mkdir -p + cp в pub (untracked dev-мусор не копируется) +// bleach — dev-приватное → публичное (yarnrc/Dockerfile/compose/nuxt/ +// sched-links/publish-check/CHANGELOG/examples) +// scrub — dist: rm -rf + cp из dev + скраб git.kzntsv (в т.ч. \u002F и \\u002F) +// lock — yarn.lock регенерация против npmjs (gotcha 8): если +// `yarn install --immutable` падает YN0028 → `yarn install` → перепроверка +// gate — CI-эквивалент ЛОКАЛЬНО до пуша (gotcha 10/11): +// tsc --noEmit + vitest (vitest.ci.config.ts) прямыми вызовами +// node_modules (обходит Windows yarn-workspace-bin-болезнь) +// commit — один чистый коммит (amend при --amend) +// push — только если gate зелёный (без --push — стоп перед пушем) +// +// Windows-заметка: `yarn workspace run` не добавляет root .bin в PATH +// (command not found: tsc) — гейт вызывает node node_modules/... напрямую. +// +// Использование: +// node scripts/sync-sched-publish.mjs # полный цикл до пуша (стоп, показать) +// node scripts/sync-sched-publish.mjs --push # + push origin main +// node scripts/sync-sched-publish.mjs --amend # amend последнего коммита (после фикса dev) +// node scripts/sync-sched-publish.mjs --only=lock # только фаза +// node scripts/sync-sched-publish.mjs --no-copy # пропустить фазы copy/bleach/scrub +// +// Возврат: 0 = зелёно, 1 = что-то упало (причины в stdout, мутаций нет после точки отказа). +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const HOME = os.homedir().replace(/\\/g, "/"); +const DEV = process.env.SCHED_DEV || `${HOME}/projects/sched`; +const PUB = process.env.SCHED_PUB || `${HOME}/projects/sched-upstream`; + +const args = process.argv.slice(2); +const PUSH = args.includes("--push"); +const AMEND = args.includes("--amend"); +const ONLY = args.includes("--only=") ? args.find((a) => a.startsWith("--only=")).slice(7) : null; +const NO_COPY = args.includes("--no-copy"); + +// ── helpers ──────────────────────────────────────────────────────────────── +const sh = (cmd, opts = {}) => + execSync(cmd, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", ...opts }); +const shSilent = (cmd) => { + try { return sh(cmd).trim(); } catch { return null; } +}; +const log = (s) => console.log(s); +const fail = (s) => { console.error(`\n✗ ${s}`); process.exitCode = 1; }; + +function copyTree(srcFile, dstFile) { + if (!fs.existsSync(srcFile)) return; // удалён в dev → удалится в pub через git add -A + fs.mkdirSync(path.dirname(dstFile), { recursive: true }); + fs.copyFileSync(srcFile, dstFile); +} + +// ── фазы ─────────────────────────────────────────────────────────────────── +function collect() { + const out = shSilent(`git -C "${DEV}" ls-files`); + if (out === null) { fail("dev не git-репо"); return null; } + const EXCL = /^\.wiki\/|^\.tasks\/|^\.agents\/|^AGENTS\.md$|^CLAUDE\.md$|^branding\/pora-|^pngtree-|^327274997_|^cat-clock-|^MIGRATION\.md$/; + return out.split("\n").filter((f) => f && !EXCL.test(f)); +} + +function copy(files) { + for (const f of files) copyTree(`${DEV}/${f}`, `${PUB}/${f}`); + log(`copy: ${files.length} файлов`); +} + +// выбеливание — точечные замены, идемпотентно (эталон: верифицированный b0afd14) +function bleach() { + const sub = (rel, pairs) => { + const p = `${PUB}/${rel}`; + if (!fs.existsSync(p)) return; + let s = fs.readFileSync(p, "utf8"); + let dirty = false; + for (const [old, nw] of pairs) if (s.includes(old)) { s = s.replaceAll(old, nw); dirty = true; } + if (dirty) fs.writeFileSync(p, s); + }; + + // .yarnrc.yml — канон целиком (3 строки, фиксированный) + fs.writeFileSync(`${PUB}/.yarnrc.yml`, `nodeLinker: node-modules\nenableTelemetry: false\nnpmRegistryServer: "https://registry.npmjs.org"\n`); + + sub("docker-compose.dev.yml", [ + ["# npm-based image: pull @schedjs/daemon from the private verdaccio.", + "# npm-based image: pull @schedjs/daemon from the public npm registry."], + ["NPM_REGISTRY: ${NPM_REGISTRY:-https://verdaccio.kzntsv.site}", + "NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org}"], + ["NPM_TOKEN: ${VERDACCIO_TOKEN:-}", "NPM_TOKEN: ${NPM_TOKEN:-}"], + ]); + + sub("apps/daemon/Dockerfile", [ + ["# Build (public registry):\n# docker build -t schedd:0.10.2 apps/daemon\n# Build (private verdaccio — our internal registry):\n# docker build --secret id=verdaccio_token,env=VERDACCIO_TOKEN \\\n# --build-arg NPM_REGISTRY=https://verdaccio.kzntsv.site \\\n# -t registry.kzntsv.site/sched-daemon:0.10.2 apps/daemon\n#", + "# Build:\n# docker build -t schedd:0.12.1 apps/daemon\n#\n# Private-registry mirror (optional, internal use): pass the registry URL as\n# NPM_REGISTRY + a BuildKit secret to install from a mirror instead of npmjs.\n#"], + ["(verdaccio serves none)", "(a mirror serves none)"], + ["# VERDACCIO_TOKEN comes from the BuildKit secret (mount type=secret).", + "# NPM_TOKEN comes from the BuildKit secret (mount type=secret)."], + ["id=verdaccio_token", "id=npm_token"], + ["/run/secrets/verdaccio_token", "/run/secrets/npm_token"], + ]); + + sub("docs/nuxt.config.ts", [ + ["?? 'https://git.kzntsv.site/victor/sched'", "?? 'https://github.com/schedjs/schedjs'"], + ["?? 'https://verdaccio.kzntsv.site'", "?? 'https://registry.npmjs.org'"], + ["?? 'registry.kzntsv.site'", "?? 'ghcr.io/schedjs'"], + ]); + + sub("docs/modules/sched-links.ts", [ + ["?? 'https://git.kzntsv.site/victor/sched'", "?? 'https://github.com/schedjs/schedjs'"], + ["?? 'https://verdaccio.kzntsv.site'", "?? 'https://registry.npmjs.org'"], + ["?? 'registry.kzntsv.site'", "?? 'ghcr.io/schedjs'"], + ["VERDACCIO_TOKEN", "NPM_TOKEN"], + ]); + + sub("CHANGELOG.md", [ + ["на verdaccio", "на npm"], ["→ verdaccio", "→ npm"], + ["registry.kzntsv.site/sched-daemon", "ghcr.io/schedjs/sched-daemon"], + ]); + + for (const p of ["scripts/publish-check.mjs", "scripts/publish-check.test.mjs"]) { + sub(p, [["registry.kzntsv.site/sched-daemon", "ghcr.io/schedjs/sched-daemon"]]); + } + + for (const f of ["examples/admin-client-python/generate.ps1", "examples/admin-client-ts/generate.ps1"]) { + sub(f, [[" (verdaccio subpath export);", ";"]]); + } + for (const f of [ + "examples/admin-client-python/README.md", + "examples/admin-client-ts/README.md", + "examples/admin-client-python/tests/test_admin_client.py", + ]) { + sub(f, [["verdaccio", "npm"]]); + } + log("bleach: выбелено"); +} + +function scrubDist() { + const devDist = `${DEV}/docs/dist`; + const pubDist = `${PUB}/docs/dist`; + if (!fs.existsSync(devDist)) { log("scrub: нет dev docs/dist — пропуск"); return; } + fs.rmSync(pubDist, { recursive: true, force: true }); + fs.cpSync(devDist, pubDist, { recursive: true }); + + // скраб остатков kzntsv (прямые + \u002F + \\u002F в _payload.json) + const pairs = [ + ["git.kzntsv.site/victor/sched", "github.com/schedjs/schedjs"], + ["verdaccio.kzntsv.site/:_authToken=${VERDACCIO_TOKEN}", "registry.npmjs.org/:_authToken=${NPM_TOKEN}"], + ["verdaccio.kzntsv.site", "registry.npmjs.org"], + ["registry.kzntsv.site/sched-daemon", "ghcr.io/schedjs/sched-daemon"], + ["registry.kzntsv.site", "ghcr.io/schedjs"], + ["VERDACCIO_TOKEN", "NPM_TOKEN"], + ]; + const esc1 = "\\u002F", esc2 = "\\\\u002F"; // \u002F и \\u002F (JSON-двойной) + const escPairs = [ + ["git.kzntsv.site" + esc2 + "victor" + esc2 + "sched", "github.com" + esc2 + "schedjs" + esc2 + "schedjs"], + ["git.kzntsv.site" + esc1 + "victor" + esc1 + "sched", "github.com" + esc1 + "schedjs" + esc1 + "schedjs"], + ["registry.kzntsv.site" + esc2 + "sched-daemon", "ghcr.io/schedjs" + esc2 + "sched-daemon"], + ["registry.kzntsv.site" + esc1 + "sched-daemon", "ghcr.io/schedjs" + esc1 + "sched-daemon"], + ["verdaccio.kzntsv.site" + esc2 + ":_authToken=${VERDACCIO_TOKEN}", "registry.npmjs.org" + esc2 + ":_authToken=${NPM_TOKEN}"], + ["verdaccio.kzntsv.site" + esc1 + ":_authToken=${VERDACCIO_TOKEN}", "registry.npmjs.org" + esc1 + ":_authToken=${NPM_TOKEN}"], + ["verdaccio.kzntsv.site" + esc2, "registry.npmjs.org" + esc2], + ["verdaccio.kzntsv.site" + esc1, "registry.npmjs.org" + esc1], + ["registry.kzntsv.site" + esc2, "ghcr.io/schedjs" + esc2], + ["registry.kzntsv.site" + esc1, "ghcr.io/schedjs" + esc1], + ]; + + const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = `${dir}/${e.name}`; + return e.isDirectory() ? walk(p) : [p]; + }); + let changed = 0; + for (const f of walk(pubDist)) { + if (f.endsWith(".svg")) continue; + let data; + try { data = fs.readFileSync(f, "utf8"); } catch { continue; } + let orig = data; + for (const [old, nw] of pairs) data = data.split(old).join(nw); + for (const [old, nw] of escPairs) data = data.split(old).join(nw); + if (data !== orig) { fs.writeFileSync(f, data); changed++; } + } + log(`scrub: dist скопирован, изменено ${changed} файлов`); + + // logo.svg НЕ коммитить (gotcha 4) + if (fs.existsSync(`${pubDist}/logo.svg`)) fs.rmSync(`${pubDist}/logo.svg`); +} + +function lock() { + // сначала immutable; если падает — регенерация и перепроверка + const imm = shSilent(`cd "${PUB}" && yarn install --immutable`); + if (imm === null || imm.includes("YN0028") || /Failed with errors/.test(imm)) { + log("lock: immutable упал — регенерирую против npmjs"); + const regen = shSilent(`cd "${PUB}" && yarn install`); + if (regen === null || /Failed with errors/.test(regen)) { fail("lock: yarn install упал"); return false; } + const imm2 = shSilent(`cd "${PUB}" && yarn install --immutable`); + if (imm2 === null || /Failed with errors/.test(imm2) || imm2.includes("YN0028")) { + fail("lock: immutable после регенерации всё ещё красный"); return false; + } + } + log("lock: yarn.lock валиден против npmjs"); + return true; +} + +function gate() { + // CI-эквивалент прямыми вызовами node_modules (Windows: yarn workspace run не видит .bin) + // gotcha 5: build до tsc (cross-package types через dist) — собрать dist всех пакетов + // топологический порядок (как yarn workspaces -pt): пакет раньше своих @schedjs-потребителей + const buildCfgs = []; + for (const sub of ["packages", "apps"]) { + for (const dir of fs.readdirSync(`${PUB}/${sub}`)) { + const cfg = `${PUB}/${sub}/${dir}/tsconfig.build.json`; + if (fs.existsSync(cfg)) buildCfgs.push(`${sub}/${dir}`); + } + } + const order = []; + const remaining = new Set(buildCfgs); + const depsOf = (p) => { + const pj = JSON.parse(fs.readFileSync(`${PUB}/${p}/package.json`, "utf8")); + const deps = { ...(pj.dependencies || {}), ...(pj.devDependencies || {}), ...(pj.peerDependencies || {}) }; + return Object.keys(deps).filter((d) => d.startsWith("@schedjs/")); + }; + const localName = (p) => { + const pj = JSON.parse(fs.readFileSync(`${PUB}/${p}/package.json`, "utf8")); + return pj.name; + }; + const localNames = new Map(buildCfgs.map((p) => [localName(p), p])); + while (remaining.size) { + let progress = false; + for (const p of [...remaining]) { + const unmet = depsOf(p).filter((d) => localNames.has(d) && !order.includes(localNames.get(d))); + if (unmet.length === 0) { order.push(p); remaining.delete(p); progress = true; } + } + if (!progress) { fail("gate: цикл зависимостей build-пакетов"); return false; } + } + for (const pkg of order) { + const clean = `${PUB}/${pkg}/dist`; + if (fs.existsSync(clean)) fs.rmSync(clean, { recursive: true, force: true }); + const b = shSilent(`cd "${PUB}/${pkg}" && node ../../node_modules/typescript/bin/tsc -p tsconfig.build.json`); + if (b === null || /error TS/.test(b)) { + fail(`gate: build ${pkg} упал`); + return false; + } + // полный build-скрипт пакета может включать bundle (esbuild) — повторить + const pj = JSON.parse(fs.readFileSync(`${PUB}/${pkg}/package.json`, "utf8")); + if ((pj.scripts?.build || "").includes("bundle.mjs")) { + const bun = shSilent(`cd "${PUB}/${pkg}" && node scripts/bundle.mjs`); + if (bun === null) { fail(`gate: bundle ${pkg} упал`); return false; } + } + } + log(`gate: build ${order.length} пакетов (dist, топо-порядок)`); + + const tsc = shSilent(`cd "${PUB}" && node node_modules/typescript/bin/tsc --noEmit`); + if (tsc === null || /error TS/.test(tsc)) { + const errs = (tsc || "").split("\n").filter((l) => l.includes("error TS")).slice(0, 10); + fail(`gate: tsc — ${errs.length} ошибок${errs.length ? "\n" + errs.join("\n") : ""}`); + return false; + } + log("gate: tsc зелёный"); + + const vit = shSilent(`cd "${PUB}" && node node_modules/vitest/vitest.mjs run --config vitest.ci.config.ts`); + if (vit === null || /failed/.test(vit)) { + fail("gate: vitest упал (смотри вывод)"); + return false; + } + const passLine = (vit.match(/(\d+) passed/) || [])[1] || "?"; + log(`gate: vitest зелёный (${passLine} passed)`); + return true; +} + +function grepCheck() { + const hits = shSilent(`cd "${PUB}" && grep -rnE 'git\\.kzntsv|verdaccio|vds\\.kzntsv|registry\\.kzntsv|VERDACCIO' --exclude-dir=node_modules --exclude-dir=.git .`); + if (hits && hits.length > 0) { + fail(`grep: остались ${hits.split("\n").length} вхождений kzntsv/verdaccio`); + return false; + } + log("grep: 0 вхождений kzntsv/verdaccio"); + return true; +} + +function commit() { + const msg = `chore: sync curated copy from dev (${shSilent(`cd "${DEV}" && git log -1 --format=%h`) || "?"})`; + sh(`cd "${PUB}" && git add -A`); + const staged = shSilent(`cd "${PUB}" && git status -s`); + if (!staged || staged.trim() === "") { + log("commit: нет изменений — волна уже синхронизирована"); + return true; + } + if (AMEND) { + shSilent(`cd "${PUB}" && git commit --amend --no-edit`); + log("commit: amended"); + } else { + sh(`cd "${PUB}" && git commit -m "${msg}"`); + log(`commit: ${shSilent(`cd "${PUB}" && git log -1 --format=%s`)}`); + } + return true; +} + +// ── main ──────────────────────────────────────────────────────────────────── +if (!fs.existsSync(`${PUB}/.git`)) { fail("pub не git-репо"); process.exit(1); } +if (!fs.existsSync(`${DEV}/.git`)) { fail("dev не git-репо"); process.exit(1); } + +const files = collect(); +if (!files) process.exit(1); + +if (ONLY && ONLY !== "lock") { fail("--only поддерживает: lock"); process.exit(1); } + +let ok = true; +if (!ONLY) { + if (!NO_COPY) { copy(files); bleach(); scrubDist(); } + ok = grepCheck() && ok; +} +if (ONLY === "lock") { + ok = lock() && ok; +} else if (!NO_COPY) { + ok = lock() && ok; +} +if (ok && !ONLY && !NO_COPY) { + ok = gate() && ok; + if (ok) ok = commit() && ok; +} + +if (!ok) { + log("\n✗ цикл прерван — мутаций после точки отказа нет. Фикс в dev → повтор."); + process.exit(1); +} +if (!PUSH) { + log(`\n✓ готово до пуша: ${shSilent(`cd "${PUB}" && git log --oneline -1`)}`); + log(" запусти с --push чтобы отправить origin main"); + process.exit(0); +} +const p = shSilent(`cd "${PUB}" && git push origin main`); +if (p === null || /rejected/.test(p)) { fail("push отклонён — pull --ff-only и повтори"); process.exit(1); } +log("✓ pushed origin main");