// 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");