doxa-maps/internal/sync-dropin.mjs
doxa-maps-88655c70 79bd6d2ac3
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
public/ is one folder: the output tree plus a README, with the Plugin Studio as its front page
Driver decisions (cards BUILD-OUTPUT-IS-ONE-FOLDER, BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE).

- public/ holds only <tree>/ and a generated README.md. The build no longer writes public/index.html,
  public/assets/ or public/__gallery/; `rm -rf public && bun run build` recreates exactly these two.
- The Plugin Studio is <tree>/index.html (tree-base "."), replacing the staging-forge chooser; nothing
  is written above the tree. check:staging-lang, which tested that chooser, is retired with it.
- Assets are gathered in a temp directory and the fetched allowlist staged into <tree>/assets/ as
  before. fonts/, images/, brand/ and legacy/ existed ONLY in public/assets: they are sources and move
  to data/ (git mv). Poster previews keep their tracked sources in plugin-designs/*/assets/previews
  (all 378 verified present).
- __gallery/ is archived out of public/ (a copy kept in the umbrella's _qa-cycle/archive) and out of
  the build; generate-gallery renders only on request, into a directory you name, never public/.
- Native shells load the output folder: capacitor webDir public/<tree> (cap copy needs an index.html
  there), Tauri frontendDist + window url relative to it. internal/sync-app-shells.mjs keeps both in
  step with the tree's name on every build, so the coming rename stays one config line; the Capacitor
  start page resolves its leaf tree-relative.
- dropin.include now lists only extras (README.md): the tree is always shipped and never named twice;
  excludes are tree-relative ({tree}/...). clean.mjs removes the old root entries from existing
  checkouts; the build lock and public/.gitignore's rules move out of public/.
- check:wp-embed read the BUILT manifest from gates:pre, before the build, so a from-scratch build
  could never pass; it runs in gates:post and reads the tree name from config.
- Stale bundles refreshed by the clean build (key-cities, template-bundle, wall-of-people-groups);
  the CLI doc's quoted scripts re-synced; capability exemplars regenerated; the '#' note leaves
  check:stale-sweep-claim's command (it was already in //scripts).

Proof: rm -rf public && bun run build -> public/ = README.md + doxa-maps/; check:sri, check:drop,
check:parse, check:public-hygiene, check:quoted-scripts, check:capability-exemplars PASS; all 57 pages
(the front page + 56 leaves) mount under /doxa-maps/ on this tree. check:facet-cascade is red as on main.
2026-09-25 17:41:56 +00:00

186 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* sync-dropin.mjs — mirror the built web-root (public/) into a HOST checkout as a "drop-in".
*
* WHY: the website (doxa-life) consumes this bundler's output as generated files under
* public/embed/doxa-maps-build/. Hand-editing there is forbidden — the ONLY way a map changes
* on the site is: change the bundler → `bun run build` → sync. This is that one honest step,
* so it is a script and not a remembered rsync incantation.
*
* WHAT IT DOES: mirrors <from> (default public/) → <dest>: adds/updates every file, deletes
* files that no longer exist in the source. EXCEPT:
* · credentials are NEVER copied — api/maps/token.json and token.local.js (the site serves
* its own /api/maps/token; the leaf pages' token ladder falls through to it);
* · documentation is NEVER copied — *.md / *.markdown / *.txt: the served folder is product,
* the README and docs stay private with the bundler (a stray doc already in <dest> is
* removed like any other stale file);
* · staging/preview pages are NEVER copied — <tree>/index.html and <tree>/<bundle>/index.html
* are internal surfaces; partners embed the LEAF pages one level deeper (--keep-staging to
* opt back in);
* · host-owned files can be PROTECTED with --protect (none by default) — never overwritten,
* never deleted;
* · built data that no page fetches is NOT shipped — "dropin.exclude" in the build-config (path
* prefixes relative to public/, inside the allowlist; internal scripts still read them here).
* Honoured with --from-config; counted as "excluded (not shipped)"; a copy already in <dest>
* is removed like any other stale file.
*
* THE DROP-IN CONTRACT (allowlist): a host does not need the whole web-root. The website takes
* ONLY the bundles it embeds, declared ONCE in vite.doxa-maps-build-config.json under
* "dropin.include" — path prefixes relative to public/ (a folder, or a single file such as
* doxa-maps/manifest.json) — and nothing at the drop-in root.
* --from-config take the allowlist from that build-config (fails if it declares none);
* --only a,b pass an allowlist explicitly (wins over --from-config);
* --prune also delete destination files OUTSIDE the allowlist (an older full mirror:
* the shell, assets/, headers …) so the drop-in ends up exactly the contract.
* --protect is still honoured and nothing outside <dest> is ever touched.
* Without an allowlist the tool is the plain full mirror it always was; --prune then has no
* effect. Files in <dest> outside the allowlist are left alone unless --prune is given.
* Refuses to CREATE <dest>: the drop-in folder is the host's decision, not this script's.
* Gate: `node internal/public-hygiene-check.mjs <dest> --strict` must pass before a public push.
*
* Usage: node internal/sync-dropin.mjs <dest> [--from public] [--tree doxa-maps] [--keep-staging]
* [--only a,b | --from-config] [--prune] [--protect a.txt,other] [--dry-run]
* e.g. node internal/sync-dropin.mjs ../doxa-life/public/embed/doxa-maps-build --from-config --prune
* Prints: copied · unchanged · deleted (stale inside the allowlist) · pruned (outside it) ·
* excluded (credentials/docs/staging) · excluded (not shipped: dropin.exclude), then the
* destination's top-level tree after the sync.
* Exit: 0 synced (or dry-run printed) · 1 bad arguments / dest missing / no allowlist in config
* Review the result with `git -C <host> status` — the host repo is the restore point.
*/
import { readdirSync, statSync, lstatSync, mkdirSync, copyFileSync, rmSync, existsSync, readFileSync, realpathSync } from 'node:fs'
import { join, relative, resolve, dirname, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CONFIG = join(ROOT, 'vite.doxa-maps-build-config.json')
const norm = p => p.split(sep).join('/')
const args = process.argv.slice(2)
const VALUE_FLAGS = new Set(['--from', '--tree', '--protect', '--only'])
const argOf = (k, d) => { const i = args.indexOf(k); const v = args[i + 1]; return i >= 0 && v !== undefined && !v.startsWith('--') ? v : d }
// Destination: positional <dest> · else DOXA_DROPIN_DEST · else "dropin.dest" in the build-config
// (the ONE place the host folder is declared; moving the drop-in = changing that line).
const configDest = (() => { try { const d = JSON.parse(readFileSync(CONFIG, 'utf8')).dropin?.dest; return typeof d === 'string' && d.trim() ? resolve(ROOT, d.trim()) : null } catch { return null } })()
const DEST = args.find((a, i) => !a.startsWith('--') && !VALUE_FLAGS.has(args[i - 1])) || process.env.DOXA_DROPIN_DEST || configDest
const FROM = resolve(ROOT, argOf('--from', 'public'))
const DRY = args.includes('--dry-run')
const PRUNE = args.includes('--prune')
const PROTECT = new Set(String(argOf('--protect', '')).split(',').map(s => s.trim()).filter(Boolean))
// Never ship a credential into a host repo — these are gitignored here for the same reason.
const EXCLUDE = new Set(['api/maps/token.json', 'token.local.js'])
// Never ship documentation into a served folder: the README/story stays private with the bundler.
const isDoc = (f) => /\.(md|markdown|txt)$/i.test(f)
// Staging/preview pages are INTERNAL surfaces: <tree>/index.html and <tree>/<bundle>/index.html
// (the leaf pages partners embed live one level deeper). A public mirror carries bundles, leaf
// pages and the data they fetch — not previews. --keep-staging opts back in.
const TREE = String(argOf('--tree', TREE_NAME)).replace(/\/+$/, '')
const KEEP_STAGING = args.includes('--keep-staging')
const isStaging = (f) => !KEEP_STAGING && (f === `${TREE}/index.html` || new RegExp(`^${TREE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/]+/index\\.html$`).test(f))
// Built data nothing shipped fetches: "dropin.exclude" in the build-config (read with --from-config,
// below) — path prefixes relative to public/. Internal scripts still read them in this repo.
let NOT_SHIPPED = []
const isNotShipped = (f) => NOT_SHIPPED.some(p => f === p || f.startsWith(p + '/'))
const excludedInternal = (f) => EXCLUDE.has(f) || isDoc(f) || isStaging(f)
const excluded = (f) => excludedInternal(f) || isNotShipped(f)
const usage = 'usage: node internal/sync-dropin.mjs [<dest>] [--from public] [--only a,b | --from-config] [--prune] [--protect a,b] [--dry-run] (<dest> defaults to $DOXA_DROPIN_DEST, then dropin.dest in vite.doxa-maps-build-config.json)'
if (!DEST) { console.error(`[sync-dropin] ${usage}`); process.exit(1) }
const dest = resolve(DEST)
if (!existsSync(dest) || !statSync(dest).isDirectory()) { console.error(`[sync-dropin] dest does not exist or is not a directory: ${dest} — create the drop-in folder in the host first`); process.exit(1) }
if (!existsSync(FROM)) { console.error(`[sync-dropin] source missing: ${FROM} — run \`bun run build\` first`); process.exit(1) }
// The allowlist: path prefixes relative to <from>. A prefix names a folder (everything under it)
// or one file. --only wins; --from-config reads "dropin.include" from the build-config.
const normPrefix = p => String(p).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '')
let ALLOW = null, ALLOW_FROM = ''
if (args.includes('--only')) {
ALLOW = String(argOf('--only', '')).split(',').map(normPrefix).filter(Boolean)
ALLOW_FROM = '--only'
} else if (args.includes('--from-config')) {
let cfg
try { cfg = JSON.parse(readFileSync(CONFIG, 'utf8')) } catch (e) { console.error(`[sync-dropin] cannot read ${CONFIG}: ${e.message}`); process.exit(1) }
const inc = cfg?.dropin?.include
if (!Array.isArray(inc) || !inc.length || !inc.every(s => typeof s === 'string')) { console.error(`[sync-dropin] --from-config: ${norm(relative(ROOT, CONFIG))} declares no "dropin": { "include": [ …path prefixes relative to public/… ] }`); process.exit(1) }
// The OUTPUT FOLDER is always shipped: it is the product, and naming it here would put the tree's
// name in a second place in the config (a rename must stay one line). include lists the EXTRAS.
ALLOW = [TREE, ...inc.map((s) => s.replaceAll('{tree}', TREE))].map(normPrefix).filter(Boolean)
ALLOW_FROM = `${norm(relative(ROOT, CONFIG))} · dropin.include`
const exc = cfg?.dropin?.exclude
if (exc !== undefined && (!Array.isArray(exc) || !exc.every(s => typeof s === 'string'))) { console.error(`[sync-dropin] --from-config: "dropin.exclude" must be an array of path prefixes relative to public/`); process.exit(1) }
NOT_SHIPPED = (exc || []).map((s) => s.replaceAll('{tree}', TREE)).map(normPrefix).filter(Boolean)
if (NOT_SHIPPED.some(p => p.split('/').includes('..'))) { console.error(`[sync-dropin] dropin.exclude climbs out of ${norm(relative(ROOT, FROM))}/: ${JSON.stringify(NOT_SHIPPED)}`); process.exit(1) }
}
if (ALLOW && (!ALLOW.length || ALLOW.some(p => p.split('/').includes('..')))) { console.error(`[sync-dropin] allowlist is empty or climbs out of ${norm(relative(ROOT, FROM))}/: ${JSON.stringify(ALLOW)}`); process.exit(1) }
const allowed = f => !ALLOW || ALLOW.some(p => f === p || f.startsWith(p + '/'))
function walk(dir, base = dir, out = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name), st = statSync(p)
if (st.isDirectory()) walk(p, base, out); else out.push(norm(relative(base, p)))
}
return out
}
const sha = p => createHash('sha1').update(readFileSync(p)).digest('hex')
// A deletion only ever happens to a path that really lives under <dest> (a symlink that points
// elsewhere is refused, not followed).
const destReal = realpathSync(dest)
const insideDest = f => { try { return realpathSync(join(dest, f)).startsWith(destReal + sep) } catch { return false } }
const allSrc = walk(FROM)
const excludedCount = allSrc.filter(excludedInternal).length
const notShippedCount = allSrc.filter(f => !excludedInternal(f) && isNotShipped(f)).length
const src = allSrc.filter(f => !excluded(f) && allowed(f))
const outsideSrc = allSrc.length - excludedCount - notShippedCount - src.length // built, but not part of the contract
const have = new Set(walk(dest))
const result = new Set() // what <dest> holds after the sync
let copied = 0, unchanged = 0, deleted = 0, pruned = 0, kept = 0, protectedSkips = 0, refused = 0
for (const f of src) {
if (PROTECT.has(f)) { protectedSkips++; if (have.has(f)) result.add(f); continue }
const s = join(FROM, f), d = join(dest, f)
result.add(f)
if (have.has(f) && statSync(s).size === statSync(d).size && sha(s) === sha(d)) { unchanged++; continue }
if (!DRY) { mkdirSync(dirname(d), { recursive: true }); copyFileSync(s, d) }
copied++
}
const srcSet = new Set(src)
for (const f of have) {
if (srcSet.has(f)) continue
// stale = not in the source, not protected. Credentials and docs found in <dest> are stale too:
// they must not sit in a served folder (a stray README, a token file).
if (PROTECT.has(f)) { result.add(f); continue }
if (!allowed(f) && !PRUNE) { kept++; result.add(f); continue }
if (!insideDest(f)) { refused++; result.add(f); continue }
if (!DRY) rmSync(join(dest, f))
if (allowed(f)) deleted++; else pruned++
}
// prune directories emptied by deletions (deepest first; symlinked folders are never entered)
if (!DRY) {
const dirs = []; (function collect(d) { for (const n of readdirSync(d)) { const p = join(d, n); if (lstatSync(p).isDirectory()) { collect(p); dirs.push(p) } } })(dest)
for (const d of dirs) if (readdirSync(d).length === 0) rmSync(d, { recursive: true })
}
// Top-level tree of the destination (two levels; deeper folders show a file count).
function renderTree(paths, depth = 2) {
const root = {}
for (const p of paths) { let n = root; for (const s of p.split('/')) n = (n[s] ??= {}) }
const isDir = n => Object.keys(n).length > 0
const count = n => Object.values(n).reduce((a, c) => a + (isDir(c) ? count(c) : 1), 0)
const show = (n, indent, level) => {
const names = Object.keys(n).sort((a, b) => (isDir(n[b]) ? 1 : 0) - (isDir(n[a]) ? 1 : 0) || a.localeCompare(b))
for (const name of names) {
if (!isDir(n[name])) { console.log(`${indent}${name}`); continue }
console.log(`${indent}${name}/${level >= depth ? ` (${count(n[name])} files)` : ''}`)
if (level < depth) show(n[name], indent + ' ', level + 1)
}
}
if (!paths.length) console.log(' (empty)'); else show(root, ' ', 1)
}
console.log(`[sync-dropin] ${DRY ? 'DRY RUN — ' : ''}${norm(relative(ROOT, FROM))}/ → ${dest}`)
if (ALLOW) console.log(`[sync-dropin] allowlist (${ALLOW_FROM}): ${ALLOW.join(', ')} — ${PRUNE ? '--prune: everything else in the destination is removed' : 'no --prune: destination files outside it are left alone'}`)
else if (PRUNE) console.log('[sync-dropin] --prune has no effect without an allowlist (--only or --from-config) — full mirror as usual')
if (NOT_SHIPPED.length) console.log(`[sync-dropin] not shipped (dropin.exclude): ${NOT_SHIPPED.join(', ')}`)
console.log(`[sync-dropin] copied ${copied} · unchanged ${unchanged} · deleted ${deleted} · pruned ${pruned} · protected ${protectedSkips}${PROTECT.size ? ` (${[...PROTECT].join(', ')})` : ''} · excluded ${excludedCount} (credentials/docs/staging)${NOT_SHIPPED.length ? ` · excluded (not shipped) ${notShippedCount} (dropin.exclude)` : ''}${ALLOW ? ` · outside allowlist: ${outsideSrc} built file(s) not shipped${kept ? `, ${kept} destination file(s) left` : ''}` : ''}${refused ? ` · refused ${refused} (resolve outside the destination)` : ''}`)
console.log(`[sync-dropin] destination${DRY ? ' (as it would be)' : ''}: ${result.size} file(s)`)
renderTree([...result])