Spaces:
Sleeping
Sleeping
| // 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); | |
| }); | |