reachy-mini-vibe-coding-apps / backend /scripts /sync-daemon-docs.mjs
tfrere's picture
tfrere HF Staff
feat(skill): sync canonical daemon docs at build + modernize app template
761afbd
#!/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@<version>` 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);
});