#!/usr/bin/env node // Sync canonical Reachy Mini docs from the daemon repo at build time. // // Why: the vibe-coder ships an agent skill that teaches an LLM how to build a // Reachy Mini browser app. The robot knowledge and the JS SDK surface live in // `pollen-robotics/reachy_mini`. Hand-copying them here drifts. Instead we pull // the canonical markdown from the daemon `main` branch on every build and we // resolve the canonical SDK pin from the npm dist-tags, then substitute it into // the generated app template (SKILL.md). // // This runs inside the Docker `backend-builder` stage (via `npm run build`) and // locally (`npm run sync-docs`). It is network-tolerant: if the registry or // raw.githubusercontent is unreachable, it keeps the committed snapshot and // exits 0 so the build never breaks. import { mkdir, writeFile, readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const SKILL_DIR = path.resolve( __dirname, "../src/agent/skills/reachy-mini-app", ); const DAEMON_DIR = path.join(SKILL_DIR, "daemon"); const SKILL_HUB = path.join(SKILL_DIR, "SKILL.md"); const DAEMON_REPO = "pollen-robotics/reachy_mini"; // Defaults to `main` (the durable canonical ref). Override with // DAEMON_DOCS_REF to track a branch/tag/SHA - e.g. an unmerged docs PR that // already carries the modern bare-HTML + CDN guidance. const DAEMON_REF = process.env.DAEMON_DOCS_REF || "main"; const RAW_BASE = `https://raw.githubusercontent.com/${DAEMON_REPO}/${DAEMON_REF}`; const SDK_PKG = "@pollen-robotics/reachy-mini-sdk"; const NPM_REGISTRY = `https://registry.npmjs.org/${SDK_PKG}`; const JSDELIVR_META = `https://data.jsdelivr.com/v1/packages/npm/${SDK_PKG}`; // Canonical daemon docs to mirror. `remote` is relative to the repo root, // `local` is the filename written under `daemon/` (auto-discovered by the // skill-loader, exposed to the agent via `read_skill_doc`). const DOCS = [ // The single source of truth for building a JS app. Its bare-HTML + CDN // section is the pattern this vibe-coder uses; its robotics best-practices // section covers safe teardown. { remote: "ts/APP_CREATION_GUIDE.md", local: "APP_CREATION_GUIDE.md" }, // JS SDK runtime reference - the authoritative method/event names. { remote: "docs/source/SDK/javascript-sdk.md", local: "javascript-sdk.md" }, // Top-level orientation hub. { remote: "AGENTS.md", local: "AGENTS.md" }, // Robot-knowledge skills (behaviour, motion, interaction, low-level access). { remote: "skills/motion-philosophy.md", local: "motion-philosophy.md" }, { remote: "skills/interaction-patterns.md", local: "interaction-patterns.md" }, { remote: "skills/control-loops.md", local: "control-loops.md" }, { remote: "skills/rest-api.md", local: "rest-api.md" }, { remote: "skills/safe-torque.md", local: "safe-torque.md" }, ]; const FETCH_TIMEOUT_MS = 15_000; async function fetchText(url) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); try { const res = await fetch(url, { signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.text(); } finally { clearTimeout(timer); } } async function fetchJson(url) { return JSON.parse(await fetchText(url)); } // ── semver (prerelease-aware) ─────────────────────────────────────────────── // Minimal comparator: numeric core dominates; a prerelease ranks below its own // release but we only ever compare tags against each other, so the standard // semver precedence rules are enough. function parseSemver(v) { const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v).trim()); if (!m) return null; return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), prerelease: m[4] ?? null, }; } function comparePrerelease(a, b) { // No prerelease ranks higher than a prerelease (1.8.0 > 1.8.0-rc1). if (a === null && b === null) return 0; if (a === null) return 1; if (b === null) return -1; const as = a.split("."); const bs = b.split("."); for (let i = 0; i < Math.max(as.length, bs.length); i++) { const x = as[i]; const y = bs[i]; if (x === undefined) return -1; if (y === undefined) return 1; const xn = /^\d+$/.test(x); const yn = /^\d+$/.test(y); if (xn && yn) { const d = Number(x) - Number(y); if (d !== 0) return d < 0 ? -1 : 1; } else if (xn !== yn) { return xn ? -1 : 1; // numeric identifiers rank lower than alphanumeric } else if (x !== y) { return x < y ? -1 : 1; } } return 0; } function compareSemver(a, b) { const pa = parseSemver(a); const pb = parseSemver(b); if (!pa && !pb) return 0; if (!pa) return -1; if (!pb) return 1; for (const key of ["major", "minor", "patch"]) { if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1; } return comparePrerelease(pa.prerelease, pb.prerelease); } function semverMax(...versions) { return versions .filter(Boolean) .reduce((best, v) => (best && compareSemver(best, v) >= 0 ? best : v), null); } // ── canonical pin resolution ──────────────────────────────────────────────── // Canonical = max(latest, rc): always the newest published release, including // a release candidate when one is ahead of the stable `latest`. async function resolveCanonicalPin() { let tags = null; try { const meta = await fetchJson(NPM_REGISTRY); tags = meta?.["dist-tags"] ?? null; } catch (err) { console.warn(`[sync-docs] npm registry unreachable (${err.message}), trying jsDelivr`); } if (!tags) { try { const meta = await fetchJson(JSDELIVR_META); tags = meta?.tags ?? null; } catch (err) { console.warn(`[sync-docs] jsDelivr unreachable (${err.message})`); } } if (!tags) return null; const pin = semverMax(tags.latest, tags.rc); if (pin) { console.log( `[sync-docs] dist-tags latest=${tags.latest} rc=${tags.rc} -> canonical pin ${pin}`, ); } return pin; } // ── doc sync ──────────────────────────────────────────────────────────────── function buildHeader(remote, pin) { const stamp = new Date().toISOString().slice(0, 10); const pinLine = pin ? ` · canonical SDK pin: \`${pin}\`` : ""; return ( `> **Auto-fetched** from [\`${DAEMON_REPO}@${DAEMON_REF}\`](https://github.com/${DAEMON_REPO}/blob/${DAEMON_REF}/${remote}) ` + `on ${stamp}${pinLine}.\n` + `> Do not edit by hand - run \`npm run sync-docs\` to refresh.\n\n` ); } async function syncDocs(pin) { await mkdir(DAEMON_DIR, { recursive: true }); let fetched = 0; let kept = 0; for (const { remote, local } of DOCS) { const dest = path.join(DAEMON_DIR, local); try { const body = await fetchText(`${RAW_BASE}/${remote}`); await writeFile(dest, buildHeader(remote, pin) + body, "utf-8"); fetched++; console.log(`[sync-docs] ✓ ${remote} -> daemon/${local}`); } catch (err) { // Keep whatever snapshot is already committed; never break the build. console.warn( `[sync-docs] ✗ ${remote} (${err.message}) - keeping committed snapshot`, ); kept++; } } return { fetched, kept }; } // ── pin substitution in SKILL.md ──────────────────────────────────────────── // Rewrites every `@pollen-robotics/reachy-mini-sdk@` occurrence (the // CDN import and the API reference) to the resolved canonical pin. async function substitutePin(pin) { if (!pin) { console.warn("[sync-docs] no canonical pin resolved - skipping SKILL.md pin update"); return; } let hub; try { hub = await readFile(SKILL_HUB, "utf-8"); } catch (err) { console.warn(`[sync-docs] cannot read SKILL.md (${err.message}) - skipping pin update`); return; } const pinRe = /(@pollen-robotics\/reachy-mini-sdk@)[^/"'\s]+/g; const before = hub; hub = hub.replace(pinRe, `$1${pin}`); if (hub === before) { console.log("[sync-docs] SKILL.md already pinned to the canonical version"); return; } await writeFile(SKILL_HUB, hub, "utf-8"); console.log(`[sync-docs] SKILL.md SDK pin updated to ${pin}`); } async function main() { console.log("[sync-docs] syncing canonical daemon docs + SDK pin…"); const pin = await resolveCanonicalPin(); const { fetched, kept } = await syncDocs(pin); await substitutePin(pin); console.log(`[sync-docs] done (fetched ${fetched}, kept ${kept}/${DOCS.length}).`); } main().catch((err) => { // Last-resort guard: still exit 0 so a transient failure never breaks build. console.warn(`[sync-docs] unexpected error: ${err?.stack || err}`); process.exit(0); });