#!/usr/bin/env node /** * Sync the vendored shared core in a plugin repo against the canonical * obsidian-plugin-core checkout. * * Run from a plugin repo root (each plugin vendors a copy of this script): * node scripts/sync-shared.mjs # copy canonical -> src/shared/ * node scripts/sync-shared.mjs --check # exit 1 if the vendored copy drifted * * Run from obsidian-plugin-core itself: * node scripts/sync-shared.mjs --manifest # regenerate shared/MANIFEST.sha256 * * THE DRIFT GATE HAS TO WORK WITHOUT THE CANONICAL REPO. This script used to exit 0 from * `--check` whenever ../obsidian-plugin-core was absent — which is ALWAYS true in CI and in a * fresh clone. So `npm test`'s drift check enforced nothing on any pull request, and a * hand-edited `src/shared/verifyLicense.mjs` merged green: exactly the drift class this repo * exists to prevent, and exactly how two divergent copies of the verifier happened. * * So `sync:shared` also writes MANIFEST.sha256 — the SHA-256 of every synced file — INTO the * vendored tree, and `--check` validates the vendored files against that manifest when the * canonical repo is not around. Editing a vendored file now fails the check on any machine, * with or without the canonical checkout; editing the manifest to match is a deliberate act * that shows up in the diff of a file whose header says not to. */ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; /** [canonical-relative, plugin-relative] pairs kept in sync. */ const FILES = [ ["shared/verifyLicense.mjs", "src/shared/verifyLicense.mjs"], ["shared/verifyLicense.d.mts", "src/shared/verifyLicense.d.mts"], ["shared/suiteLicense.mjs", "src/shared/suiteLicense.mjs"], ["shared/suiteLicense.d.mts", "src/shared/suiteLicense.d.mts"], ["shared/revokedLicenses.mjs", "src/shared/revokedLicenses.mjs"], ["shared/revokedLicenses.d.mts", "src/shared/revokedLicenses.d.mts"], ["shared/licenseTransition.mjs", "src/shared/licenseTransition.mjs"], ["shared/licenseTransition.d.mts", "src/shared/licenseTransition.d.mts"], ["shared/featureGates.mjs", "src/shared/featureGates.mjs"], ["shared/featureGates.d.mts", "src/shared/featureGates.d.mts"], ["shared/hash.mjs", "src/shared/hash.mjs"], ["shared/hash.d.mts", "src/shared/hash.d.mts"], ["shared/engine/protocol.mjs", "src/shared/engine/protocol.mjs"], ["shared/engine/protocol.d.mts", "src/shared/engine/protocol.d.mts"], ["shared/engine/chunk.mjs", "src/shared/engine/chunk.mjs"], ["shared/engine/chunk.d.mts", "src/shared/engine/chunk.d.mts"], ["shared/engine/installPlan.mjs", "src/shared/engine/installPlan.mjs"], ["shared/engine/installPlan.d.mts", "src/shared/engine/installPlan.d.mts"], ["shared/engine/engineRelease.mjs", "src/shared/engine/engineRelease.mjs"], ["shared/engine/engineRelease.d.mts", "src/shared/engine/engineRelease.d.mts"], ["shared/engine/EngineHost.ts", "src/shared/engine/EngineHost.ts"], ["shared/engine/EngineBroker.ts", "src/shared/engine/EngineBroker.ts"], ["shared/engine/EngineInstallModal.ts", "src/shared/engine/EngineInstallModal.ts"], ["shared/signals/signalsAggregate.mjs", "src/shared/signals/signalsAggregate.mjs"], ["shared/signals/signalsAggregate.d.mts", "src/shared/signals/signalsAggregate.d.mts"], ["shared/signals/SignalStore.ts", "src/shared/signals/SignalStore.ts"], ["shared/signals/SignalsBroker.ts", "src/shared/signals/SignalsBroker.ts"], ["scripts/sync-shared.mjs", "scripts/sync-shared.mjs"], ]; /** Generated, not synced: it is the checksum OF the synced files. */ const MANIFEST = ["shared/MANIFEST.sha256", "src/shared/MANIFEST.sha256"]; const MANIFEST_HEADER = [ "# SHA-256 of every file in the vendored shared tree, keyed by its canonical path.", "# GENERATED by scripts/sync-shared.mjs. Do not edit, and do not edit a shared file", "# without re-running `npm run sync:shared` — `npm test` verifies this manifest even", "# when the canonical obsidian-plugin-core checkout is not present (i.e. in CI).", ]; /** Compare content, not line endings — git autocrlf checkouts differ per OS. */ const normalize = (text) => text.replace(/\r\n/g, "\n"); const sha256 = (text) => crypto.createHash("sha256").update(normalize(text), "utf8").digest("hex"); /** * @param {string} root repo root * @param {(pair: string[]) => string} pick canonical-relative or plugin-relative * @returns {string} the manifest body */ export function buildManifest(root, pick) { const lines = [...MANIFEST_HEADER]; for (const pair of FILES) { const file = path.join(root, pick(pair)); if (!fs.existsSync(file)) throw new Error(`sync-shared: cannot hash a missing file: ${file}`); lines.push(`${sha256(fs.readFileSync(file, "utf8"))} ${pair[0]}`); } return lines.join("\n") + "\n"; } const canonicalManifest = (root) => buildManifest(root, ([from]) => from); const vendoredManifest = (root) => buildManifest(root, ([, to]) => to); /** Write `body` to `file` only when it would change it. Returns true when it wrote. */ function writeIfChanged(file, body) { const have = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; if (have !== null && normalize(have) === normalize(body)) return false; fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, body); return true; } /** The vendored tree, checked against the manifest that shipped with it. No canonical repo * needed — this is the check that actually runs on a pull request. */ function checkAgainstManifest(repoRoot) { const manifestPath = path.join(repoRoot, MANIFEST[1]); if (!fs.existsSync(manifestPath)) { console.error( `sync-shared: ${MANIFEST[1]} is missing — the vendored tree cannot be verified.\n` + 'Run "npm run sync:shared" against the canonical obsidian-plugin-core checkout.' ); return 1; } let want; try { want = vendoredManifest(repoRoot); } catch (error) { console.error(String(error instanceof Error ? error.message : error)); return 1; } const have = fs.readFileSync(manifestPath, "utf8"); if (normalize(have) === normalize(want)) { console.log(`sync-shared: vendored tree matches ${MANIFEST[1]} (${FILES.length} files).`); return 0; } // Name the files, not just the mismatch — the whole point is to say what was hand-edited. const expected = new Map( normalize(have) .split("\n") .filter((line) => line && !line.startsWith("#")) .map((line) => { const [hash, file] = line.split(/\s+/); return [file, hash]; }) ); let drifted = 0; for (const [from, to] of FILES) { const file = path.join(repoRoot, to); const actual = fs.existsSync(file) ? sha256(fs.readFileSync(file, "utf8")) : ""; if (expected.get(from) !== actual) { console.error(`sync-shared: DRIFT in ${to} (does not match ${MANIFEST[1]})`); drifted++; } } if (drifted === 0) { console.error(`sync-shared: ${MANIFEST[1]} does not match the files it lists — it was edited by hand.`); drifted = 1; } console.error( `sync-shared: ${drifted} file(s) drifted from the canonical shared tree. Edit them in ` + 'obsidian-plugin-core and run "npm run sync:shared" — never in this repo.' ); return 1; } function main(argv) { const repoRoot = process.cwd(); const canonical = process.env.PLUGIN_CORE_PATH || path.resolve(repoRoot, "..", "obsidian-plugin-core"); const check = argv.includes("--check"); const manifestOnly = argv.includes("--manifest"); // Regenerate the canonical manifest in obsidian-plugin-core itself. Its own test suite // runs this in --check mode, so a shared file edited without regenerating fails there too. if (manifestOnly) { const file = path.join(repoRoot, MANIFEST[0]); const body = canonicalManifest(repoRoot); if (check) { const have = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; if (normalize(have) !== normalize(body)) { console.error(`sync-shared: ${MANIFEST[0]} is stale — run "npm run manifest:shared".`); return 1; } console.log(`sync-shared: ${MANIFEST[0]} is current.`); return 0; } console.log(writeIfChanged(file, body) ? `sync-shared: wrote ${MANIFEST[0]}.` : `sync-shared: ${MANIFEST[0]} is current.`); return 0; } if (!fs.existsSync(canonical)) { // No canonical checkout: CI, or a fresh clone. This is the case the old script treated // as "nothing to do", which is what made the gate decorative. The vendored manifest is // the authority here. if (check) return checkAgainstManifest(repoRoot); console.log(`sync-shared: canonical repo not found at ${canonical} — nothing to copy.`); return 0; } let drifted = 0; for (const [from, to] of FILES) { const src = path.join(canonical, from); const dest = path.join(repoRoot, to); if (!fs.existsSync(src)) { console.error(`sync-shared: missing canonical file ${src}`); return 1; } const want = fs.readFileSync(src, "utf8"); const have = fs.existsSync(dest) ? fs.readFileSync(dest, "utf8") : null; if (have !== null && normalize(want) === normalize(have)) continue; if (check) { console.error(`sync-shared: DRIFT in ${to} (canonical: ${from})`); drifted++; } else { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, want); console.log(`sync-shared: updated ${to}`); } } if (check) { if (drifted > 0) { console.error(`sync-shared: ${drifted} file(s) drifted — run "npm run sync:shared" to update.`); return 1; } // The files match the canonical tree; the manifest must match them too, or CI (which // has no canonical tree and can only consult the manifest) would disagree with us. return checkAgainstManifest(repoRoot); } // Keep both manifests honest: the canonical one, so obsidian-plugin-core's own test passes, // and the vendored one, which is the only thing CI can check. const body = canonicalManifest(canonical); if (writeIfChanged(path.join(canonical, MANIFEST[0]), body)) { console.log(`sync-shared: refreshed ${MANIFEST[0]} in the canonical repo`); } if (writeIfChanged(path.join(repoRoot, MANIFEST[1]), body)) console.log(`sync-shared: updated ${MANIFEST[1]}`); console.log("sync-shared: done."); return 0; } // Importable (obsidian-plugin-core's own manifest test uses buildManifest) and runnable. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { process.exit(main(process.argv.slice(2))); } export { FILES, MANIFEST, main };