// The writer half of the drift gate. check-drift.ts proves a product's forced // copies match this dev-kit's canonical; sync-drift.ts *makes* them match, by // re-deriving each copy from the same canonical bytes. It is the mechanical // inverse of the checker and shares its constants (imported below) so the two // cannot disagree about what "in sync" means. // // It only touches copies whose convergent form is mechanical — there is no // judgement anywhere: // - verbatim (biome.json, tsconfig.base.json) -> overwrite with canonical // - masked workflows (checks / bump / -> canonical, repo's product: key kept // publish-docs) // - bump.yml -> CREATED where absent, under the key the // retired bump bots carried; then those // three (upgrade-latest / sync-dev-kit / // sync-ctl-packages) are DELETED // - nix/ctl-shell.nix -> overwrite with canonical (created if absent) // - flake.nix (legacy verbatim copy only) -> the starter, repo's floating ctl pin kept // - marker-block (CLAUDE core, dprint, .gitignore) -> replace the shared block, keep the rest // - publish-docs-site.yml -> verbatim; CREATED where the repo has a // docs site and is not a turnkey // Structural per-repo files (root tsconfig.json, deployment/build-image.sh, // manifest.seed.json) have NO canonical bytes to apply — the gate checks their // shape, not their content — so they are left untouched. // // The safety net is that the gate still runs AFTER sync: a copy this writer // cannot mechanically converge (e.g. a workflow with no product key to preserve) // is left for check-drift to flag, and a convention that genuinely breaks the // product surfaces as a red lint/typecheck/test — not as a silent bad copy. import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { checkTheme, syncTheme } from "../docs-theme/sync.ts"; import { BEGIN, BUMP_WORKFLOW, type CanonicalBytes, CTL_HASH_LINE, CTL_SHELL_PATH, CTL_VERSION_LINE, carriesIntegrationMarker, DOCS_SITE_DIR, DOCS_SITE_WORKFLOW, END, GITIGNORE_BEGIN, GITIGNORE_END, hasDocsSite, importsCtlShell, isServerKitCopy, PRODUCT_LINE, PRODUCT_SENTINEL, type ProductShape, PUBLISH_IMAGE_LINE, pinnedBunVersion, RETIRED_BUMP_WORKFLOWS, RUNNER_LINE, resolveRepoName, resolveShape, SERVER_KIT_COPIES, splitAtMarkers, } from "./check-drift.ts"; export interface SyncReport { written: string[]; skipped: string[]; } export interface SyncDriftOptions { /** The GitHub repo name (`norsk-ctl-product-probe`), which decides whether the * docs site's publish may be CREATED here (never in a turnkey). The CLI * resolves it; a fixture passes it. Unknown ⇒ nothing is created. */ repoName?: string; } function writeIfChanged(path: string, content: string, rel: string, written: string[]): void { const current = existsSync(path) ? readFileSync(path, "utf8") : undefined; if (current !== content) { writeFileSync(path, content); written.push(rel); } } // checks.yml / publish-docs.yml: canonical byte-for-byte // except the one `product:` dispatch key, which the gate masks. Re-emit the // canonical with the repo's own key filled back in. A workflow with no key to // preserve (absent, or still the sentinel) is left for the gate to flag. function syncWorkflow(repoRoot: string, rel: string, canonical: string, r: SyncReport): void { const path = join(repoRoot, rel); if (!existsSync(path)) { r.skipped.push(`${rel} (absent)`); return; } const key = readFileSync(path, "utf8").match(/^\s*product:\s*(\S+)/m)?.[1]; if (!key || key === PRODUCT_SENTINEL) { r.skipped.push(`${rel} (no product: key to preserve)`); return; } writeIfChanged(path, canonical.replace(PRODUCT_LINE, `$1${key}`), rel, r.written); } const productKey = (path: string): string | undefined => { if (!existsSync(path)) return undefined; const key = readFileSync(path, "utf8").match(/^\s*product:\s*(\S+)/m)?.[1]; return key && key !== PRODUCT_SENTINEL ? key : undefined; }; // bump.yml, the one nightly bump (ci-fragility proposal B2), and the end of // the three bots it replaced. This is how it reaches a product: the old // sync-dev-kit.yml installs a dev-kit that has it and runs this writer, which // creates bump.yml under the dashboard key the old bots reported under and // deletes them -- sync-dev-kit.yml included, in the very PR it is opening. The // old bot-prs on main still triages that PR (its branch is sync-dev-kit/*), // and the new integration and build-image it brings still run on that branch, // so the transition lands like any other bump. Once landed, bump.yml owns the // night and the old three are gone. // // The key comes from bump.yml itself once it has one; before that from // upgrade-latest.yml (the nightly column's reporter), sync-dev-kit.yml, and // last checks.yml (required, always filled). With no key at all nothing is // created and nothing removed: deleting the old bots before their replacement // exists would leave the product with no nightly bump, silently. The gate then // names the missing bump.yml. function syncBump(repoRoot: string, canonical: string, r: SyncReport): void { const wf = (rel: string) => join(repoRoot, rel); const key = productKey(wf(BUMP_WORKFLOW)) ?? productKey(wf(".github/workflows/upgrade-latest.yml")) ?? productKey(wf(".github/workflows/sync-dev-kit.yml")) ?? productKey(wf(".github/workflows/checks.yml")); if (key === undefined) { r.skipped.push(`${BUMP_WORKFLOW} (no product: key to give it; the retired bump workflows are left in place)`); return; } mkdirSync(dirname(wf(BUMP_WORKFLOW)), { recursive: true }); writeIfChanged(wf(BUMP_WORKFLOW), canonical.replace(PRODUCT_LINE, `$1${key}`), BUMP_WORKFLOW, r.written); for (const rel of RETIRED_BUMP_WORKFLOWS) { if (!existsSync(wf(rel))) continue; rmSync(wf(rel)); r.written.push(rel); } } // publish-docs.yml: optional, and masked like checks.yml (only re-synced where // the repo already carries it). Every copy predating the mask was written // verbatim with the sentinel, so a copy with no key of its own takes the one // checks.yml (required, and always filled) dispatches under — otherwise no // nightly could ever converge those copies. function syncPublishDocs(repoRoot: string, canonical: string, r: SyncReport): void { const rel = ".github/workflows/publish-docs.yml"; const path = join(repoRoot, rel); if (!existsSync(path)) return; const own = readFileSync(path, "utf8").match(/^\s*product:\s*(\S+)/m)?.[1]; const checksPath = join(repoRoot, ".github/workflows/checks.yml"); const fromChecks = existsSync(checksPath) ? readFileSync(checksPath, "utf8").match(/^\s*product:\s*(\S+)/m)?.[1] : undefined; const key = own && own !== PRODUCT_SENTINEL ? own : fromChecks; if (!key || key === PRODUCT_SENTINEL) { r.skipped.push(`${rel} (no product: key to preserve)`); return; } writeIfChanged(path, canonical.replace(PRODUCT_LINE, `$1${key}`), rel, r.written); } // build-image.yml: canonical byte-for-byte except two per-repo lines — the // notify job's `product:` dispatch key and the PUBLISH_IMAGE env (the Docker Hub // repo, or empty for a build-only product). Re-emit the canonical with both of // the repo's own values restored. A copy with no product key to preserve (absent // or still the sentinel) is left for the gate to flag. PUBLISH_IMAGE is restored // via a function replacer so a value containing `$` can't be read as a // replacement backreference. function syncBuildImage(repoRoot: string, canonical: string, r: SyncReport): void { const rel = ".github/workflows/build-image.yml"; const path = join(repoRoot, rel); if (!existsSync(path)) { r.skipped.push(`${rel} (absent)`); return; } const existing = readFileSync(path, "utf8"); const key = existing.match(/^\s*product:\s*(\S+)/m)?.[1]; if (!key || key === PRODUCT_SENTINEL) { r.skipped.push(`${rel} (no product: key to preserve)`); return; } const publishLine = existing.match(/^\s*PUBLISH_IMAGE:.*$/m)?.[0]; let out = canonical.replace(PRODUCT_LINE, `$1${key}`); if (publishLine !== undefined) out = out.replace(PUBLISH_IMAGE_LINE, () => publishLine); writeIfChanged(path, out, rel, r.written); } // smoke.yml: optional (a repo carries it once it has a smoke spec), canonical // byte-for-byte except the notify/report `product:` key and the matrix // `runner:` label array. Re-emit the canonical with both restored; the runner // line goes back through a function replacer so a `$` in it is never read as a // backreference. function syncSmoke(repoRoot: string, canonical: string, r: SyncReport): void { const rel = ".github/workflows/smoke.yml"; const path = join(repoRoot, rel); if (!existsSync(path)) { r.skipped.push(`${rel} (absent)`); return; } const existing = readFileSync(path, "utf8"); const key = existing.match(/^\s*product:\s*(\S+)/m)?.[1]; if (!key || key === PRODUCT_SENTINEL) { r.skipped.push(`${rel} (no product: key to preserve)`); return; } const runnerLine = existing.match(/^\s*-?\s*runner:.*$/m)?.[0]; let out = canonical.replace(PRODUCT_LINE, `$1${key}`); if (runnerLine !== undefined) out = out.replace(RUNNER_LINE, () => runnerLine); writeIfChanged(path, out, rel, r.written); } // integration.yml: smoke.yml's twin, but only a copy that already carries the // convention marker is rewritten — a hand-owned one is left untouched and named, // because converging it is a deliberate per-repo change (hooks, a proving run), // not something a nightly sync may do to a tier the release gate believes. function syncIntegration(repoRoot: string, canonical: string, r: SyncReport): void { const rel = ".github/workflows/integration.yml"; const path = join(repoRoot, rel); if (!existsSync(path)) { r.skipped.push(`${rel} (absent)`); return; } const existing = readFileSync(path, "utf8"); if (!carriesIntegrationMarker(existing)) { r.skipped.push(`${rel} (hand-owned: no convention marker on line 1 — converge it deliberately)`); return; } const key = existing.match(/^\s*product:\s*(\S+)/m)?.[1]; if (!key || key === PRODUCT_SENTINEL) { r.skipped.push(`${rel} (no product: key to preserve)`); return; } const runnerLine = existing.match(/^\s*-?\s*runner:.*$/m)?.[0]; let out = canonical.replace(PRODUCT_LINE, `$1${key}`); if (runnerLine !== undefined) out = out.replace(RUNNER_LINE, () => runnerLine); writeIfChanged(path, out, rel, r.written); } // flake.nix is the product's own once it builds from nix/ctl-shell.nix, so sync // leaves it alone. One that does not can only be a verbatim dev-kit copy from // before the shared pieces moved into the module (the gate allowed no product // edits then), so it is replaced by the starter with the repo's floating ctl pin // (ctlVersion + the four per-platform hashes) carried across in order. function syncFlake(repoRoot: string, starter: string, r: SyncReport): void { const path = join(repoRoot, "flake.nix"); if (!existsSync(path)) { r.skipped.push("flake.nix (absent)"); return; } const existing = readFileSync(path, "utf8"); if (importsCtlShell(existing)) return; const version = existing.match(CTL_VERSION_LINE)?.[0]; const hashes = existing.match(CTL_HASH_LINE) ?? []; let out = version ? starter.replace(CTL_VERSION_LINE, version) : starter; let i = 0; out = out.replace(CTL_HASH_LINE, (m) => hashes[i++] ?? m); writeIfChanged(path, out, "flake.nix", r.written); } // Replace the region between two markers with the canonical block, preserving // everything outside it. `canonicalBlock` starts at the BEGIN marker and ends at // the END marker. function replaceMarkerBlock(existing: string, begin: string, end: string, canonicalBlock: string): string | undefined { const b = existing.indexOf(begin); const e = existing.indexOf(end); if (b === -1 || e === -1 || e < b) return undefined; const after = existing.slice(e + end.length); return existing.slice(0, b) + canonicalBlock.trimEnd() + (after.startsWith("\n") ? after : `\n${after}`); } // CLAUDE.md: the fenced conventions core lives between the markers; the repo's // own intro/outro prose is outside and kept. function syncClaude(repoRoot: string, core: string, r: SyncReport): void { const path = join(repoRoot, "CLAUDE.md"); if (!existsSync(path)) { r.skipped.push("CLAUDE.md (absent)"); return; } const out = replaceMarkerBlock(readFileSync(path, "utf8"), BEGIN, END, core); if (out === undefined) { r.skipped.push("CLAUDE.md (no marker block)"); return; } writeIfChanged(path, out, "CLAUDE.md", r.written); } // .gitignore: the shared block is marker-delimited; repo-specific ignores live // outside it. Replace the block, or prepend it if the repo has none yet (the // one-time migration case). function syncGitignore(repoRoot: string, canonicalCore: string, r: SyncReport): void { const path = join(repoRoot, ".gitignore"); const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; const replaced = replaceMarkerBlock(existing, GITIGNORE_BEGIN, GITIGNORE_END, canonicalCore); const out = replaced ?? `${canonicalCore.trimEnd()}\n${existing}`; writeIfChanged(path, out, ".gitignore", r.written); } // dprint.json: canonical outside the repo-specific-excludes markers, the repo's // own exclude entries kept between them. function syncDprint(repoRoot: string, canonical: string, r: SyncReport): void { const path = join(repoRoot, "dprint.json"); if (!existsSync(path)) { r.skipped.push("dprint.json (absent)"); return; } const c = splitAtMarkers(canonical); if (!c) throw new Error("canonical dprint.base.jsonc is missing its own marker block"); const a = splitAtMarkers(readFileSync(path, "utf8")); if (!a) { r.skipped.push("dprint.json (no marker block)"); return; } // splitAtMarkers keeps the file's trailing newline as an empty final line in // suffix, so the join alone reproduces it — appending another would drift. const out = [c.prefix, ...a.body, c.suffix].join("\n"); writeIfChanged(path, out, "dprint.json", r.written); } // scripts/demo (05-demo s6): verbatim + executable for a repo that has a demo // (tests/demo.spec.ts) or already carries the shim; a repo without a demo is // not given one. The package.json "demo" script is structural and stays the // product's to add — the gate names it. function syncDemoShim(repoRoot: string, canonical: string, r: SyncReport): void { const rel = "scripts/demo"; const path = join(repoRoot, rel); if (!existsSync(join(repoRoot, "tests/demo.spec.ts")) && !existsSync(path)) return; // A product's own tooling may already live at scripts/demo/ (playout did): // never write over it; the gate names it and says where the shim goes. if (existsSync(path) && statSync(path).isDirectory()) { r.skipped.push(`${rel} (a directory; move it, the shim goes here)`); return; } mkdirSync(dirname(path), { recursive: true }); writeIfChanged(path, canonical, rel, r.written); chmodSync(path, 0o755); } // The server kit's scripts: only a copy (it names its source) is rewritten; a // product's own install.sh at that path is the product's. export function syncServerKit(repoRoot: string, canonical: CanonicalBytes, r: SyncReport): void { for (const { rel, source, key } of SERVER_KIT_COPIES) { const expected = canonical[key]; const path = join(repoRoot, rel); if (expected === undefined || !existsSync(path) || !isServerKitCopy(readFileSync(path, "utf8"), source)) continue; writeIfChanged(path, expected, rel, r.written); chmodSync(path, 0o755); } } // Both docs workflows run `bash scripts/with-display.sh ...`. It stays a // repo-local executable (the package.json guide scripts invoke it by that path), // so a repo that carries a docs workflow gets the canonical copy written here // rather than hand-copied — which is what neither turnkey ever did. function syncWithDisplay(repoRoot: string, canonical: string, r: SyncReport): void { const carriesDocs = existsSync(join(repoRoot, ".github/workflows/publish-docs.yml")) || existsSync(join(repoRoot, ".github/workflows/build-docs.yml")); if (!carriesDocs) return; const rel = "scripts/with-display.sh"; const path = join(repoRoot, rel); mkdirSync(dirname(path), { recursive: true }); writeIfChanged(path, canonical, rel, r.written); chmodSync(path, 0o755); } // publish-docs-site.yml: the docs SITE's publish. Verbatim (the project name is // a repo variable, the credentials are secrets), so an existing copy is always // re-derived whole -- the gate, not this writer, judges whether the repo may // carry one. It is CREATED only where the gate would accept it: a repo with a // site (there is something to publish), named, and not a turnkey (a turnkey has // no public site, ADR-0012). That is how a product that grows a site acquires // its publish: the next nightly bump installs it, green until the Pages // project exists. A name this writer cannot determine creates nothing, since // the gate could only refuse the result. const TURNKEY_REPO = /^norsk-ctl-turnkey-/; function syncPublishDocsSite(repoRoot: string, canonical: string, repoName: string | undefined, r: SyncReport): void { const path = join(repoRoot, DOCS_SITE_WORKFLOW); if (existsSync(path)) { writeIfChanged(path, canonical, DOCS_SITE_WORKFLOW, r.written); return; } if (!hasDocsSite(repoRoot)) { r.skipped.push(`${DOCS_SITE_WORKFLOW} (no docs site to publish)`); return; } if (repoName === undefined) { r.skipped.push(`${DOCS_SITE_WORKFLOW} (cannot tell which repo this is; a turnkey may not carry it)`); return; } if (TURNKEY_REPO.test(repoName)) { r.skipped.push(`${DOCS_SITE_WORKFLOW} (${repoName} is a turnkey: no public site)`); return; } mkdirSync(dirname(path), { recursive: true }); writeIfChanged(path, canonical, DOCS_SITE_WORKFLOW, r.written); } // The docs site's theme copies — the writer behind check-drift's docsThemeProblems. // Only for a repo that HAS a site: sync never creates one (a site is a nested // standalone package with its own install, not a file this writer can conjure), // it only keeps an existing one's copies in step. That is what makes a theme // change roll out through bump.yml like any other convention. function syncDocsTheme(repoRoot: string, r: SyncReport): void { if (!hasDocsSite(repoRoot)) { r.skipped.push(`${DOCS_SITE_DIR}/ docs theme (no docs site)`); return; } const site = join(repoRoot, DOCS_SITE_DIR); // The report names what CHANGED, and syncTheme rewrites every copy // unconditionally — so ask the gate first which ones are actually adrift. Its // problems are `: `. const adrift = checkTheme(site).problems.map((p) => p.slice(0, p.indexOf(":"))); syncTheme(site); for (const rel of adrift) r.written.push(`${DOCS_SITE_DIR}/${rel}`); } // package.json "packageManager": the Bun the product's image runs, set to the // one the shell pins (build/ctl-shell.nix), so a Bun move reaches every // product through its sync. Only that field's value is rewritten, so the // file's own formatting stays. A package.json with no field is left for the // gate to flag. function syncPackageManager(repoRoot: string, ctlShell: string, r: SyncReport): void { const want = pinnedBunVersion(ctlShell); const path = join(repoRoot, "package.json"); if (want === undefined || !existsSync(path)) return; const current = readFileSync(path, "utf8"); const field = /("packageManager"\s*:\s*")[^"]*(")/; if (!field.test(current)) { r.skipped.push("package.json (no packageManager to set)"); return; } writeIfChanged( path, current.replace(field, (_m, a: string, b: string) => `${a}bun@${want}${b}`), "package.json", r.written, ); } export function syncDrift( repoRoot: string, canonical: CanonicalBytes, shape: ProductShape = "studio", opts: SyncDriftOptions = {}, ): SyncReport { const r: SyncReport = { written: [], skipped: [] }; // Shape-selected canonicals: an sdk-app's flake + checks are its own variants // (the gate holds it to those). Everything else is shared across shapes. const checksCanon = shape === "sdk-app" ? canonical.checksSdkApp : canonical.checks; const flakeCanon = shape === "sdk-app" ? canonical.flakeSdkApp : canonical.flake; if (checksCanon === undefined || flakeCanon === undefined) { throw new Error(`syncDrift: shape "${shape}" requires canonical.flakeSdkApp and canonical.checksSdkApp`); } writeIfChanged(join(repoRoot, "biome.json"), canonical.biome, "biome.json", r.written); writeIfChanged(join(repoRoot, "tsconfig.base.json"), canonical.tsconfigBase, "tsconfig.base.json", r.written); syncWorkflow(repoRoot, ".github/workflows/checks.yml", checksCanon, r); // bump.yml: an sdk-app has no Studio pair, so it has its own variant. const bumpCanon = shape === "sdk-app" ? canonical.bumpSdkApp : canonical.bump; if (bumpCanon === undefined) throw new Error(`syncDrift: shape "${shape}" requires canonical.bumpSdkApp`); syncBump(repoRoot, bumpCanon, r); // bot-prs.yml: no per-repo line to preserve, so it is written whole -- and // CREATED where absent. That is how a repo acquires it: its next sync installs it. writeIfChanged( join(repoRoot, ".github/workflows/bot-prs.yml"), canonical.botPrs, ".github/workflows/bot-prs.yml", r.written, ); // build-image.yml is the Studio bundle-image workflow; an sdk-app ships its // own container, so it is not synced for that shape. if (shape === "studio") syncBuildImage(repoRoot, canonical.buildImage, r); syncSmoke(repoRoot, canonical.smoke, r); syncIntegration(repoRoot, canonical.integration, r); syncPublishDocs(repoRoot, canonical.publishDocs, r); // build-docs.yml, the embedded variant, the same way: carrying it is the // repo's declaration (ADR-0012); sync only keeps the copy verbatim. const buildDocsRel = ".github/workflows/build-docs.yml"; if (existsSync(join(repoRoot, buildDocsRel))) { writeIfChanged(join(repoRoot, buildDocsRel), canonical.buildDocs, buildDocsRel, r.written); } mkdirSync(dirname(join(repoRoot, CTL_SHELL_PATH)), { recursive: true }); writeIfChanged(join(repoRoot, CTL_SHELL_PATH), canonical.ctlShell, CTL_SHELL_PATH, r.written); syncPackageManager(repoRoot, canonical.ctlShell, r); syncFlake(repoRoot, flakeCanon, r); syncClaude(repoRoot, canonical.core, r); syncGitignore(repoRoot, canonical.gitignoreCore, r); syncDprint(repoRoot, canonical.dprint, r); syncDemoShim(repoRoot, canonical.demoShim, r); syncServerKit(repoRoot, canonical, r); syncWithDisplay(repoRoot, canonical.withDisplay, r); syncDocsTheme(repoRoot, r); syncPublishDocsSite(repoRoot, canonical.publishDocsSite, opts.repoName, r); return r; } if (import.meta.main) { const repoRoot = process.argv[2] ?? process.cwd(); const dir = import.meta.dir; const canonical: CanonicalBytes = { core: readFileSync(join(dir, "CLAUDE.core.md"), "utf8"), flake: readFileSync(join(dir, "..", "build", "flake.nix"), "utf8"), ctlShell: readFileSync(join(dir, "..", "build", "ctl-shell.nix"), "utf8"), bump: readFileSync(join(dir, "bump.yml"), "utf8"), botPrs: readFileSync(join(dir, "bot-prs.yml"), "utf8"), publishDocs: readFileSync(join(dir, "publish-docs.yml"), "utf8"), buildDocs: readFileSync(join(dir, "build-docs.yml"), "utf8"), publishDocsSite: readFileSync(join(dir, "publish-docs-site.yml"), "utf8"), withDisplay: readFileSync(join(dir, "..", "doc-guide", "with-display.sh"), "utf8"), checks: readFileSync(join(dir, "checks.yml"), "utf8"), biome: readFileSync(join(dir, "biome.base.json"), "utf8"), tsconfigBase: readFileSync(join(dir, "tsconfig.base.json"), "utf8"), dprint: readFileSync(join(dir, "dprint.base.jsonc"), "utf8"), buildImageBootstrap: readFileSync(join(dir, "..", "build", "build-image.bootstrap.sh"), "utf8"), gitignoreCore: readFileSync(join(dir, "gitignore.core"), "utf8"), buildImage: readFileSync(join(dir, "build-image.yml"), "utf8"), smoke: readFileSync(join(dir, "smoke.yml"), "utf8"), integration: readFileSync(join(dir, "integration.yml"), "utf8"), demoShim: readFileSync(join(dir, "demo.sh"), "utf8"), serverInstall: readFileSync(join(dir, "server-install.sh"), "utf8"), serverUninstall: readFileSync(join(dir, "server-uninstall.sh"), "utf8"), flakeSdkApp: readFileSync(join(dir, "..", "build", "flake.sdk-app.nix"), "utf8"), checksSdkApp: readFileSync(join(dir, "checks.sdk-app.yml"), "utf8"), bumpSdkApp: readFileSync(join(dir, "bump.sdk-app.yml"), "utf8"), }; const report = syncDrift(repoRoot, canonical, resolveShape(repoRoot), { repoName: resolveRepoName(repoRoot) }); if (report.written.length === 0) { console.log("sync-drift: all shared-convention copies already match @norskvideo/ctl-dev-kit."); } else { console.log(`sync-drift: rewrote ${report.written.length} copy(ies) from @norskvideo/ctl-dev-kit:`); for (const w of report.written) console.log(` - ${w}`); } if (report.skipped.length > 0) { console.log("left for check-drift / a human:"); for (const s of report.skipped) console.log(` - ${s}`); } }