Spaces:
Sleeping
Sleeping
File size: 9,165 Bytes
761afbd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | #!/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);
});
|