diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b3c8fd6..554e8ca 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,9 +2,6 @@ ## Checklist -- [ ] `pnpm validate` passes -- [ ] `pnpm test` passes -- [ ] `pnpm compile` was run and `public/registry.json` is committed +- [ ] `pnpm check` passes - [ ] The source, license, compatibility, and verification label are accurate - [ ] Community work is not presented as official Pollen Robotics work -- [ ] `pnpm build` passes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38b00e2..ba7e782 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: validate: - name: Validate Registry & Artifacts + name: Validate Registry & Build runs-on: ubuntu-latest steps: - name: Checkout Code @@ -40,10 +40,10 @@ jobs: - name: Compile Public Registry Index run: pnpm compile - - name: Snapshot-diff registry.json + - name: Snapshot-diff generated catalog run: | - if ! git diff --exit-code public/registry.json; then - echo "::error::public/registry.json is stale — run 'pnpm compile' and commit the result." + if ! git diff --exit-code -- public/registry.json README.md; then + echo "::error::Generated catalog is stale — run 'pnpm compile' and commit the result." exit 1 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d269022..b5a14ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,23 +12,36 @@ uDuck Registry is a directory of Microduck behavior policies. A contribution is pnpm install ``` -2. Add `registry/behaviors/.json`. The filename must match the lowercase kebab-case `id`. + First-time fork contributions may show a pending workflow until a maintainer approves it. -3. Use `community_experimental` unless the upstream project or a pull request provides physical-run evidence. Use `claimed_hardware` when the author reports a physical run that the registry has not reproduced. Use `verified_hardware` only for an upstream-supported behavior or a submission with physical evidence. +2. Generate a descriptor scaffold so the fixed Microduck contract does not need to be written by hand: + + ```bash + pnpm --silent new-behavior id=my-cool-trick name="My Cool Duck Trick" category=agility-tricks author="Your Name" > registry/behaviors/my-cool-trick.json + ``` + + Replace the TODO values and update the source, artifact, compatibility, deployment, and media fields. The command only writes JSON to stdout; it does not contact GitHub or open a pull request. + +3. The filename must match the lowercase kebab-case `id`. Use `community_experimental` unless the upstream project or a pull request provides physical-run evidence. Use `claimed_hardware` when the author reports a physical run that the registry has not reproduced. Use `verified_hardware` only for an upstream-supported behavior or a submission with physical evidence. 4. Point `artifacts.onnx.url` at the canonical ONNX file. The registry links to that file and does not copy policy weights into the repository. 5. Run the checks and rebuild the catalog: ```bash - pnpm validate - pnpm test - pnpm compile - pnpm build + pnpm check ``` 6. Open a pull request with the upstream source, license, hardware requirements, and any evidence supporting the verification label. +### Preview media + +- `video_url` is the main demonstration for the behavior page. It can show the full context of a move and has player controls. +- `loop_url` is the muted, autoplaying preview used on explorer cards. Keep it short and focused on the movement. If it is omitted, cards fall back to `video_url`. +- `thumbnail_url` is an optional static poster or fallback image. + +For a local media path such as `/media/my-move/loop.mp4`, include the matching file at `public/media/my-move/loop.mp4` in the pull request. + ## Descriptor shape ```json diff --git a/README.md b/README.md index ae0e7b5..b6a460e 100644 --- a/README.md +++ b/README.md @@ -31,27 +31,33 @@ The descriptor schema is [`registry/schema/behavior.schema.json`](registry/schem ## Catalog -The registry currently includes 19 indexed behaviors: - -1. `alpha-walking` — **Alpha Dynamic Walk** *(Pollen Robotics · Hardware verified)* -2. `fall-recovery` — **Dynamic Fall Recovery** *(Pollen Robotics · Hardware verified)* -3. `ground-pick` — **Autonomous Ground Pick** *(Pollen Robotics · Hardware verified)* -4. `sit-stand` — **Smooth Sit ↔ Stand** *(Pollen Robotics · Hardware verified)* -5. `roulade` — **Acrobatic Roulade (Forward Roll)** *(Pollen Robotics · Hardware verified)* -6. `ball-kick-left` — **Impulse Ball Kick (Left Foot)** *(Pollen Robotics · Hardware verified)* -7. `ball-kick-right` — **Impulse Ball Kick (Right Foot)** *(Pollen Robotics · Hardware verified)* -8. `roller-drive` — **Roller Skate Velocity Drive** *(Pollen Robotics · Hardware verified)* -9. `roller-crouch` — **Roller Blade Crouch Glide** *(Pollen Robotics · Hardware verified)* -10. `genesis-velocity` — **Genesis Flat Walk** *(Macmachi · Experimental)* -11. `genesis-rough` — **Genesis Rough-Terrain Walk** *(Macmachi · Experimental)* -12. `genesis-backlash` — **Genesis Backlash Walk** *(Macmachi · Experimental)* -13. `jump` — **Vertical Jump** *(Liyucheng1997 · Experimental)* -14. `courier` — **Microduck Courier** *(selinayfilizp · Experimental)* -15. `running` — **Microduck Running** *(HannesVonEssen · Experimental)* -16. `flamingo-cycle` — **Flamingo Cycle** *(RemiFabre · Experimental)* -17. `rough-walk-e` — **Rough Walk E** *(RemiFabre · Experimental)* -18. `rough-walk-g` — **Rough Walk G** *(RemiFabre · Experimental)* -19. `max-height-jump` — **Maximum-Height Jump** *(Thomas Burgess · Experimental)* +The table below is generated from the descriptors in `registry/behaviors/`. + + + +| Behavior | ID | Category | Status | Publisher | Setup | Preview | +| --- | --- | --- | --- | --- | --- | --- | +| [Acrobatic Roulade (Forward Roll)](https://uduckmoves.com/behaviors/roulade) | `roulade` | agility tricks | Hardware verified | Pollen Robotics | none | — | +| [Alpha Dynamic Walk](https://uduckmoves.com/behaviors/alpha-walking) | `alpha-walking` | locomotion | Hardware verified | Pollen Robotics | none | video + poster | +| [Autonomous Ground Pick](https://uduckmoves.com/behaviors/ground-pick) | `ground-pick` | manipulation | Hardware verified | Pollen Robotics | none | video + poster | +| [Dynamic Fall Recovery](https://uduckmoves.com/behaviors/fall-recovery) | `fall-recovery` | recovery | Hardware verified | Pollen Robotics | none | video + poster | +| [Impulse Ball Kick (Left Foot)](https://uduckmoves.com/behaviors/ball-kick-left) | `ball-kick-left` | manipulation | Hardware verified | Pollen Robotics | 70mm practice ball | video + poster | +| [Impulse Ball Kick (Right Foot)](https://uduckmoves.com/behaviors/ball-kick-right) | `ball-kick-right` | manipulation | Hardware verified | Pollen Robotics | 70mm practice ball | — | +| [Roller Blade Crouch Glide](https://uduckmoves.com/behaviors/roller-crouch) | `roller-crouch` | roller skate | Hardware verified | Pollen Robotics | roller skate blades | — | +| [Roller Skate Velocity Drive](https://uduckmoves.com/behaviors/roller-drive) | `roller-drive` | roller skate | Hardware verified | Pollen Robotics | roller skate blades | video + poster | +| [Smooth Sit ↔ Stand](https://uduckmoves.com/behaviors/sit-stand) | `sit-stand` | locomotion | Hardware verified | Pollen Robotics | none | video + poster | +| [Flamingo Cycle](https://uduckmoves.com/behaviors/flamingo-cycle) | `flamingo-cycle` | agility tricks | Experimental | RemiFabre | none | video | +| [Genesis Backlash Walk](https://uduckmoves.com/behaviors/genesis-backlash) | `genesis-backlash` | locomotion | Experimental | Macmachi | none | — | +| [Genesis Flat Walk](https://uduckmoves.com/behaviors/genesis-velocity) | `genesis-velocity` | locomotion | Experimental | Macmachi | none | — | +| [Genesis Rough-Terrain Walk](https://uduckmoves.com/behaviors/genesis-rough) | `genesis-rough` | locomotion | Experimental | Macmachi | none | — | +| [Maximum-Height Jump](https://uduckmoves.com/behaviors/max-height-jump) | `max-height-jump` | agility tricks | Experimental | Thomas Burgess | none | loop + video | +| [Microduck Courier](https://uduckmoves.com/behaviors/courier) | `courier` | manipulation | Experimental | selinayfilizp | none | video + poster | +| [Microduck Running](https://uduckmoves.com/behaviors/running) | `running` | locomotion | Experimental | HannesVonEssen | none | video | +| [Rough Walk E](https://uduckmoves.com/behaviors/rough-walk-e) | `rough-walk-e` | locomotion | Experimental | RemiFabre | none | video | +| [Rough Walk G](https://uduckmoves.com/behaviors/rough-walk-g) | `rough-walk-g` | locomotion | Experimental | RemiFabre | none | video | +| [Vertical Jump](https://uduckmoves.com/behaviors/jump) | `jump` | agility tricks | Experimental | Liyucheng1997 | none | — | + + ## Machine-readable access @@ -63,23 +69,9 @@ curl -s https://uduckmoves.com/registry.json | jq . The same snapshot is in [`public/registry.json`](public/registry.json). The static site also exposes one JSON endpoint per behavior at `/api/behaviors/`. -## Quickstart - -```bash -git clone https://github.com/ob1-s/uduck-registry.git -cd uduck-registry -pnpm install - -pnpm cli list -pnpm cli info alpha-walking -pnpm cli pull alpha-walking ./policies -``` - -Use `pnpm cli info ` or `pnpm cli toml ` for the exact `robotd` configuration for a behavior. Check the compatibility and accessory requirements before running a policy on hardware. - ## Contributing -Add one descriptor at `registry/behaviors/.json`, run `pnpm validate`, `pnpm test`, and `pnpm compile`, then open a pull request. See [CONTRIBUTING.md](CONTRIBUTING.md) for the descriptor shape and review expectations. +Generate a starting descriptor with `pnpm --silent new-behavior id=my-move name="My Move" category=locomotion author="Your Name"`, run `pnpm check`, then open a pull request. See [CONTRIBUTING.md](CONTRIBUTING.md) for the descriptor shape and review expectations. ## Attribution and license diff --git a/package.json b/package.json index 65110f0..4541e5c 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,10 @@ "build": "next build --webpack", "start": "python3 -m http.server 3000 --directory out", "test": "vitest run", + "check": "pnpm validate && pnpm test && pnpm compile && pnpm build", + "new-behavior": "tsx scripts/new-behavior.ts", "validate": "tsx scripts/validate-registry.ts", - "compile": "tsx scripts/generate-registry-index.ts", - "cli": "tsx scripts/cli.ts" + "compile": "tsx scripts/generate-registry-index.ts" }, "keywords": [], "author": "", diff --git a/registry/schema/allowlist.ts b/registry/schema/allowlist.ts index 75ef817..365da21 100644 --- a/registry/schema/allowlist.ts +++ b/registry/schema/allowlist.ts @@ -1,4 +1,4 @@ -/** Shared registry validation constants used by the CLI and validator. */ +/** Shared registry validation constants used by the descriptor schema and validator. */ /** Only these hosts may serve canonical ONNX artifacts (HTTPS only). */ export const HOST_ALLOWLIST = ["huggingface.co", "raw.githubusercontent.com"] as const; diff --git a/scripts/cli.ts b/scripts/cli.ts deleted file mode 100644 index 922bf44..0000000 --- a/scripts/cli.ts +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env tsx -import { main as submit } from "./submit"; -import { validateAllBehaviors } from "./validate-registry"; -import { pullArtifact } from "./lib/pull-artifact"; -import { type Behavior, type VerificationStatus } from "../registry/schema/behavior"; - -const args = process.argv.slice(2); - -const STATUS_BADGES: Record = { - verified_hardware: { label: "[HARDWARE]", color: "\x1b[32m" }, - claimed_hardware: { label: "[CLAIMED]", color: "\x1b[33m" }, - community_experimental: { label: "[EXPERIMENTAL]", color: "\x1b[36m" }, -}; - -export function formatStatusBadge(status: VerificationStatus): string { - const badge = STATUS_BADGES[status]; - return `${badge.color}${badge.label}\x1b[0m`; -} - -function printHelp() { - console.log(` -\x1b[1m\x1b[33muDuck CLI\x1b[0m — Community behaviors for Microduck 🦆 - -Usage: - pnpm cli list List all behaviors in the registry - pnpm cli info Show metadata, contract, and deployment for a behavior - pnpm cli toml Print /etc/robot/robotd.toml snippet for a behavior - pnpm cli pull [dest] Download the ONNX policy - pnpm cli submit Submit a behavior JSON to the registry via GitHub PR - pnpm cli validate Run registry schema and descriptor checks - -Options: - --help, -h Show this help message -`); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function findBehavior(behaviors: Behavior[], id: string | undefined): Behavior | null { - return id ? behaviors.find((item) => item.id === id) ?? null : null; -} - -export interface PullArgs { - id: string | undefined; - destDir: string; - error?: string; -} - -export function parsePullArgs(values: string[]): PullArgs { - const positional: string[] = []; - - for (const value of values) { - if (value.startsWith("--")) { - return { - id: undefined, - destDir: "./policies", - error: `Unknown pull option '${value}'.`, - }; - } else { - positional.push(value); - } - } - - if (positional.length > 2) { - return { - id: undefined, - destDir: "./policies", - error: "Usage: pnpm cli pull [dest]", - }; - } - - return { - id: positional[0], - destDir: positional[1] || "./policies", - }; -} - -export async function run(argv: string[] = args): Promise { - const command = argv[0]; - - if (!command || command === "--help" || command === "-h") { - printHelp(); - return 0; - } - - // submit performs this same preflight itself so it can be used directly. - // Avoid validating twice when invoked through the CLI. - if (command === "submit") { - return submit(argv.slice(1)); - } - - let validation: ReturnType; - try { - validation = validateAllBehaviors(); - } catch (error) { - console.error(`\x1b[31mRegistry validation could not run: ${errorMessage(error)}\x1b[0m`); - return 1; - } - - const { valid, behaviors: allBehaviors, errors } = validation; - if (!valid) { - console.error("Registry validation error:\n" + errors.join("\n")); - return 1; - } - const behaviors = allBehaviors; - - switch (command) { - case "list": { - console.log(`\n\x1b[1muDuck Registry (${behaviors.length} behaviors available):\x1b[0m\n`); - for (const b of behaviors) { - const badge = formatStatusBadge(b.verification.status); - const badgeLabel = STATUS_BADGES[b.verification.status].label; - console.log(` ${badge}${" ".repeat(Math.max(1, 20 - badgeLabel.length))}\x1b[1m${b.id.padEnd(24)}\x1b[0m ${b.name}`); - console.log(` ${"".padEnd(10)} └─ ${b.description.slice(0, 80)}...`); - } - console.log("\nRun `pnpm cli info ` for full contract and deployment specs.\n"); - break; - } - - case "info": { - const id = argv[1]; - if (!id) { - console.error("Error: Please provide a behavior ID. e.g. `pnpm cli info alpha-walking`"); - return 1; - } - const b = findBehavior(behaviors, id); - if (!b) { - console.error(`Error: Behavior '${id}' not found in registry.`); - return 1; - } - - console.log(`\n\x1b[1m\x1b[33m${b.name}\x1b[0m (\x1b[2m${b.id}\x1b[0m v${b.version})`); - console.log(`Category: ${b.category}`); - console.log(`Verification: ${b.verification.status} (${b.verification.summary})`); - console.log(`Authors: ${b.authors.map((a) => a.name).join(", ")}`); - console.log(`License: ${b.license}`); - console.log(`Hardware: ${b.verification.hardware_target}`); - console.log(`Slot: robotd [policy] slot '${b.compatibility.robotd_slot}'`); - console.log(`\n\x1b[1mContract:\x1b[0m`); - console.log(` Observation: ${b.contract.observation_dim}-D [proprio: 48, twist: 3, head: 4, body: 6]`); - console.log(` Actions: ${b.contract.action_dim} joints (Left Leg: 5, Neck/Head: 4, Right Leg: 5)`); - console.log(` Rate: ${b.contract.control_frequency_hz} Hz (${b.contract.decimation}x decimation)`); - console.log(` Actuator: ${b.contract.actuator_model}`); - console.log(`\n\x1b[1mArtifact:\x1b[0m`); - console.log(` ONNX: ${b.artifacts.onnx.filename}`); - console.log(` URL: ${b.artifacts.onnx.url}`); - console.log(`\n\x1b[1mDeployment Snippet (/etc/robot/robotd.toml):\x1b[0m`); - console.log(b.deployment.robotd_toml); - console.log(); - break; - } - - case "toml": { - const id = argv[1]; - if (!id) { - console.error("Error: Please provide a behavior ID."); - return 1; - } - const b = findBehavior(behaviors, id); - if (!b) { - console.error(`Error: Behavior '${id}' not found.`); - return 1; - } - console.log(b.deployment.robotd_toml); - break; - } - - case "pull": { - const pullArgs = parsePullArgs(argv.slice(1)); - if (pullArgs.error) { - console.error(`Error: ${pullArgs.error}`); - return 1; - } - if (!pullArgs.id) { - console.error("Error: Please provide a behavior ID. e.g. `pnpm cli pull alpha-walking`"); - return 1; - } - const b = findBehavior(behaviors, pullArgs.id); - if (!b) { - console.error(`Error: Behavior '${pullArgs.id}' not found.`); - return 1; - } - console.log(`Pulling ${b.artifacts.onnx.filename} for ${b.name}...`); - try { - const result = await pullArtifact(b, pullArgs.destDir); - console.log(` downloaded -> ${result.destPath}`); - } catch (error) { - console.error(`\x1b[31m${errorMessage(error)}\x1b[0m`); - return 1; - } - break; - } - - case "validate": { - console.log(`Registry valid! ${behaviors.length} behaviors passed validation.`); - break; - } - - default: - console.error(`Unknown command: ${command}`); - printHelp(); - return 1; - } - - return 0; -} - -if (process.argv[1]?.endsWith("cli.ts")) { - void run().then((code) => { - process.exitCode = code; - }).catch((error: unknown) => { - console.error(`\x1b[31m${errorMessage(error)}\x1b[0m`); - process.exitCode = 1; - }); -} diff --git a/scripts/generate-registry-index.ts b/scripts/generate-registry-index.ts index 3d9c9b0..5edf73a 100644 --- a/scripts/generate-registry-index.ts +++ b/scripts/generate-registry-index.ts @@ -1,11 +1,71 @@ import fs from "node:fs"; import path from "node:path"; import { validateAllBehaviors } from "./validate-registry"; -import { type RegistryIndex } from "../registry/schema/behavior"; +import { type Behavior, type RegistryIndex } from "../registry/schema/behavior"; const PUBLIC_DIR = path.resolve(process.cwd(), "public"); const REGISTRY_OUT = path.join(PUBLIC_DIR, "registry.json"); +const README_PATH = path.resolve(process.cwd(), "README.md"); const FALLBACK_UPDATED_AT = "1970-01-01T00:00:00.000Z"; +const README_TABLE_START = ""; +const README_TABLE_END = ""; + +const verificationLabels: Record = { + verified_hardware: "Hardware verified", + claimed_hardware: "Hardware claimed", + community_experimental: "Experimental", +}; + +function escapeTableCell(value: string): string { + return value.replaceAll("|", "\\|").replace(/\r?\n/g, " "); +} + +function formatLabel(value: string): string { + return value.replaceAll("_", " ").replaceAll("-", " "); +} + +function mediaLabel(behavior: Behavior): string { + const labels = [ + behavior.media.loop_url && "loop", + behavior.media.video_url && "video", + behavior.media.thumbnail_url && "poster", + ].filter(Boolean); + return labels.length > 0 ? labels.join(" + ") : "—"; +} + +export function renderReadmeCatalog(behaviors: Behavior[]): string { + const rows = behaviors.map((behavior) => { + const authors = behavior.authors.map((author) => author.name).join(", "); + const accessories = behavior.compatibility.accessories_required.length > 0 + ? behavior.compatibility.accessories_required.map(formatLabel).join(", ") + : "none"; + + return `| [${escapeTableCell(behavior.name)}](https://uduckmoves.com/behaviors/${behavior.id}) | \`${behavior.id}\` | ${escapeTableCell(formatLabel(behavior.category))} | ${verificationLabels[behavior.verification.status]} | ${escapeTableCell(authors)} | ${escapeTableCell(accessories)} | ${mediaLabel(behavior)} |`; + }); + + return [ + README_TABLE_START, + "", + "| Behavior | ID | Category | Status | Publisher | Setup | Preview |", + "| --- | --- | --- | --- | --- | --- | --- |", + ...rows, + "", + README_TABLE_END, + ].join("\n"); +} + +export function updateReadmeCatalog(behaviors: Behavior[], readmePath = README_PATH): void { + const readme = fs.readFileSync(readmePath, "utf-8"); + const start = readme.indexOf(README_TABLE_START); + const end = readme.indexOf(README_TABLE_END); + if (start === -1 || end === -1 || end < start) { + throw new Error(`README is missing the generated catalog markers: ${README_TABLE_START} / ${README_TABLE_END}`); + } + + const before = readme.slice(0, start); + const after = readme.slice(end + README_TABLE_END.length); + fs.writeFileSync(readmePath, `${before}${renderReadmeCatalog(behaviors)}${after}`, "utf-8"); +} /** * Keep snapshot generation byte-for-byte stable. Release automation may set @@ -76,7 +136,8 @@ export function generateRegistryIndex(): RegistryIndex { } fs.writeFileSync(REGISTRY_OUT, JSON.stringify(index, null, 2), "utf-8"); - console.log(`\x1b[32mSuccessfully compiled ${behaviors.length} behaviors into public/registry.json\x1b[0m`); + updateReadmeCatalog(behaviors); + console.log(`\x1b[32mSuccessfully compiled ${behaviors.length} behaviors and refreshed README.md\x1b[0m`); return index; } diff --git a/scripts/lib/pull-artifact.ts b/scripts/lib/pull-artifact.ts deleted file mode 100644 index b43934b..0000000 --- a/scripts/lib/pull-artifact.ts +++ /dev/null @@ -1,45 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { Behavior } from "../../registry/schema/behavior"; -import { ID_PATTERN, isAllowedArtifactUrl } from "../../registry/schema/allowlist"; - -export interface PullResult { - destPath: string; -} - -export function assertSafeArtifactFilename(filename: string): void { - if ( - !filename || - path.isAbsolute(filename) || - path.basename(filename) !== filename || - path.posix.basename(filename) !== filename || - path.win32.basename(filename) !== filename || - filename.includes("/") || - filename.includes("\\") || - !filename.endsWith(".onnx") - ) { - throw new Error(`Refusing artifact filename '${filename}': expected a single .onnx filename`); - } -} - -export async function pullArtifact(behavior: Behavior, destDir: string): Promise { - if (!ID_PATTERN.test(behavior.id)) { - throw new Error(`Refusing to pull: behavior id '${behavior.id}' contains unsafe characters`); - } - - const { filename, url } = behavior.artifacts.onnx; - assertSafeArtifactFilename(filename); - if (!isAllowedArtifactUrl(url)) { - throw new Error(`Refusing to pull: artifact URL is not on the allowlist '${url}'`); - } - - fs.mkdirSync(destDir, { recursive: true }); - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Download failed: HTTP ${response.status} for ${url}`); - } - - const destPath = path.join(destDir, filename); - fs.writeFileSync(destPath, new Uint8Array(await response.arrayBuffer())); - return { destPath }; -} diff --git a/scripts/new-behavior.ts b/scripts/new-behavior.ts new file mode 100644 index 0000000..9b6d2fc --- /dev/null +++ b/scripts/new-behavior.ts @@ -0,0 +1,148 @@ +#!/usr/bin/env tsx +import { ID_PATTERN } from "../registry/schema/allowlist"; +import { BehaviorCategorySchema, type Behavior, type BehaviorCategory } from "../registry/schema/behavior"; + +const SUPPORTED_KEYS = new Set(["id", "name", "category", "author", "description", "license"]); + +export interface ScaffoldOptions { + id: string; + name: string; + category: BehaviorCategory; + author: string; + description: string; + license: string; +} + +function titleFromId(id: string): string { + return id + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function usage(): string { + return [ + "Usage: pnpm --silent new-behavior id= [name=] [category=] [author=] [description=] [license=]", + "", + "Writes a descriptor scaffold to stdout. Redirect it to registry/behaviors/.json,", + "then replace the TODO values before running pnpm check.", + ].join("\n"); +} + +export function parseScaffoldArgs(values: string[]): ScaffoldOptions { + const parsed = new Map(); + + for (const value of values) { + const separator = value.indexOf("="); + if (separator <= 0) { + throw new Error(`Expected key=value, got '${value}'.\n\n${usage()}`); + } + + const key = value.slice(0, separator); + const rawValue = value.slice(separator + 1).trim(); + if (!SUPPORTED_KEYS.has(key)) { + throw new Error(`Unknown scaffold field '${key}'. Supported fields: ${[...SUPPORTED_KEYS].join(", ")}.`); + } + if (!rawValue) throw new Error(`Scaffold field '${key}' cannot be empty.`); + if (parsed.has(key)) throw new Error(`Scaffold field '${key}' was provided more than once.`); + parsed.set(key, rawValue); + } + + const id = parsed.get("id"); + if (!id) throw new Error(`Missing required field 'id'.\n\n${usage()}`); + if (!ID_PATTERN.test(id)) throw new Error(`Invalid id '${id}': use lowercase kebab-case.`); + + const category = parsed.get("category") ?? "experimental"; + const categoryResult = BehaviorCategorySchema.safeParse(category); + if (!categoryResult.success) { + throw new Error(`Invalid category '${category}'. Use one of: locomotion, agility-tricks, manipulation, recovery, roller-skate, experimental.`); + } + + return { + id, + name: parsed.get("name") ?? titleFromId(id), + category: categoryResult.data, + author: parsed.get("author") ?? "Your Name", + description: parsed.get("description") ?? "TODO: describe what this behavior does.", + license: parsed.get("license") ?? "TODO: confirm the policy license.", + }; +} + +export function createBehaviorScaffold(options: ScaffoldOptions): Behavior { + return { + id: options.id, + name: options.name, + version: "0.1.0", + description: options.description, + category: options.category, + tags: [options.category], + authors: [{ name: options.author }], + license: options.license, + verification: { + status: "community_experimental", + summary: "Community behavior; physical deployment evidence has not been reviewed by the registry.", + hardware_target: "Microduck standard setup", + notes: "TODO: describe evidence, limitations, and simulation or hardware status.", + }, + contract: { + observation_dim: 61, + observation_breakdown: { + proprioception: 48, + twist: 3, + head_pose: 4, + body_pose: 6, + }, + action_dim: 14, + action_breakdown: { + left_leg: 5, + neck_head: 4, + right_leg: 5, + }, + control_frequency_hz: 50, + decimation: 4, + actuator_model: "Dynamixel XL330 (BAM M6 actuator physics)", + action_scale: 1, + }, + compatibility: { + robot_model: "microduck-standard", + accessories_required: [], + terrain: ["flat"], + robotd_slot: "walk", + }, + artifacts: { + onnx: { + filename: `${options.id}.onnx`, + url: `https://huggingface.co/your-org/your-policy/resolve/main/${options.id}.onnx`, + baked_normalizer: false, + }, + }, + media: { + hero_type: "badge", + }, + sources: { + upstream_repo: "https://github.com/your-org/your-policy", + }, + deployment: { + robotd_toml: `[policy]\nwalk = "/opt/robot/policies/${options.id}.onnx"`, + }, + }; +} + +export function main(argv = process.argv.slice(2)): number { + if (argv.includes("--help") || argv.includes("-h")) { + console.log(usage()); + return 0; + } + + try { + console.log(JSON.stringify(createBehaviorScaffold(parseScaffoldArgs(argv)), null, 2)); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +if (process.argv[1]?.endsWith("new-behavior.ts")) { + process.exitCode = main(); +} diff --git a/scripts/submit.ts b/scripts/submit.ts deleted file mode 100644 index bf9d3b7..0000000 --- a/scripts/submit.ts +++ /dev/null @@ -1,469 +0,0 @@ -#!/usr/bin/env tsx -/** - * `pnpm cli submit ` — submit a behavior to the registry via GitHub PR. - * - * The command validates the candidate and the current registry before it talks - * to GitHub. Authentication/API failures print a manual path, but still return - * a failure status so callers cannot mistake guidance for a created PR. - */ -import crypto from "node:crypto"; -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { BehaviorSchema, type Behavior } from "../registry/schema/behavior"; -import { validateAllBehaviors } from "./validate-registry"; - -const GITHUB_API = "https://api.github.com"; -const DEFAULT_HTTP_TIMEOUT_MS = 15_000; -const FORK_READY_TIMEOUT_MS = 30_000; -const FORK_POLL_INTERVAL_MS = 2_000; -const BRANCH_ATTEMPTS = 5; -const REPO_PART = /^[A-Za-z0-9_.-]+$/; - -type FetchImplementation = typeof fetch; - -interface DeviceCodeResponse { - device_code?: string; - user_code?: string; - verification_uri?: string; - interval?: number; - expires_in?: number; -} - -interface DeviceTokenResponse { - access_token?: string; - error?: string; - error_description?: string; -} - -export class GitHubRequestError extends Error { - constructor(public readonly status: number, body: string) { - super(`GitHub API ${status}${body ? `: ${body.slice(0, 300)}` : ""}`); - this.name = "GitHubRequestError"; - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function positiveEnv(name: string, fallback: number): number { - const value = Number(process.env[name]); - return Number.isInteger(value) && value > 0 ? value : fallback; -} - -function httpTimeoutMs(): number { - return positiveEnv("UDUCK_HTTP_TIMEOUT_MS", DEFAULT_HTTP_TIMEOUT_MS); -} - -export async function fetchWithTimeout( - url: string, - init: RequestInit = {}, - timeoutMs = httpTimeoutMs(), - fetchImpl: FetchImplementation = fetch, -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal; - - try { - return await fetchImpl(url, { ...init, signal }); - } catch (error) { - if (controller.signal.aborted) { - throw new Error(`Request timed out after ${timeoutMs} ms: ${url}`); - } - throw new Error(`Request failed for ${url}: ${errorMessage(error)}`); - } finally { - clearTimeout(timer); - } -} - -async function responseBody(response: Response): Promise { - try { - return await response.text(); - } catch { - return ""; - } -} - -async function parseResponseJson(response: Response, context: string): Promise { - const body = await responseBody(response); - if (!response.ok) { - throw new GitHubRequestError(response.status, body); - } - if (!body.trim()) { - return null as T; - } - try { - return JSON.parse(body) as T; - } catch { - throw new Error(`${context} returned invalid JSON (HTTP ${response.status}).`); - } -} - -export async function githubFetch( - url: string, - token: string, - init: RequestInit = {}, - fetchImpl: FetchImplementation = fetch, -): Promise { - const response = await fetchWithTimeout( - url, - { - ...init, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - "Content-Type": "application/json", - ...(init.headers ?? {}), - }, - }, - httpTimeoutMs(), - fetchImpl, - ); - return parseResponseJson(response, "GitHub API"); -} - -/** Device flow: request a code, poll for the token (public_repo scope only). */ -export async function deviceFlowAuth(clientId: string, fetchImpl: FetchImplementation = fetch): Promise { - const start = await fetchWithTimeout( - "https://github.com/login/device/code", - { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ client_id: clientId, scope: "public_repo" }), - }, - httpTimeoutMs(), - fetchImpl, - ); - const startData = await parseResponseJson(start, "GitHub device-code response"); - - if (!startData?.device_code || !startData.user_code || !startData.verification_uri) { - throw new Error("GitHub device-code response was missing required fields."); - } - - console.log(`\n\x1b[1mOpen:\x1b[0m ${startData.verification_uri}`); - console.log(`\x1b[1mEnter:\x1b[0m ${startData.user_code}\n`); - - const intervalMs = Math.max(1_000, (startData.interval ?? 5) * 1_000); - const expiresIn = startData.expires_in && startData.expires_in > 0 ? startData.expires_in : 900; - const deadline = Date.now() + expiresIn * 1_000; - let pollIntervalMs = intervalMs; - - while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, deadline - Date.now()))); - if (Date.now() >= deadline) break; - - const poll = await fetchWithTimeout( - "https://github.com/login/oauth/access_token", - { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ - client_id: clientId, - device_code: startData.device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }, - httpTimeoutMs(), - fetchImpl, - ); - const data = await parseResponseJson(poll, "GitHub device-token response"); - if (data?.access_token) return data.access_token; - if (data?.error === "authorization_pending") continue; - if (data?.error === "slow_down") { - pollIntervalMs += 5_000; - continue; - } - throw new Error(`Device flow failed: ${data?.error_description || data?.error || "unknown response"}`); - } - - throw new Error("Device flow timed out"); -} - -export function parseGithubRepo(value: string): string | null { - const cleaned = value.trim().replace(/\.git$/i, ""); - const parts = cleaned.split("/"); - if (parts.length !== 2 || !REPO_PART.test(parts[0]) || !REPO_PART.test(parts[1])) return null; - return `${parts[0]}/${parts[1]}`; -} - -export function resolveRepo(): string | null { - if (process.env.UDUCK_REPO !== undefined) { - return parseGithubRepo(process.env.UDUCK_REPO); - } - - try { - const origin = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }).trim(); - const match = origin.match(/github\.com(?::|\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i); - return match ? parseGithubRepo(`${match[1]}/${match[2]}`) : null; - } catch { - return null; - } -} - -export function makeSubmissionBranch(behaviorId: string): string { - return `uduck-submit-${behaviorId}-${crypto.randomBytes(4).toString("hex")}`; -} - -export function manualFallback( - raw: string, - behaviorId: string, - repo: string | null, - reason: string, - existingBranch?: string, -): number { - const branch = existingBranch ?? makeSubmissionBranch(behaviorId); - console.error(`\n\x1b[33mAutomatic submission unavailable (${reason}).\x1b[0m`); - console.log("Manual fallback — no PR was created, so this command exits with status 1.\n"); - - if (repo) { - const [, name] = repo.split("/"); - console.log(` 1. Fork https://github.com/${repo}`); - console.log(` 2. Create a unique branch named ${branch} in your fork.`); - console.log(` 3. Add your behavior file to registry/behaviors/${behaviorId}.json.`); - console.log(" 4. Open a PR from that branch:"); - console.log(` https://github.com/${repo}/compare/main...:${name}:${branch}?expand=1`); - } else { - console.log(" Set UDUCK_REPO=owner/name, fork the registry, and open a PR from a unique branch."); - } - - console.log("\n Validated behavior JSON follows — attach it to the PR:\n"); - console.log(raw); - return 1; -} - -export interface SubmissionValidation { - valid: boolean; - errors: string[]; -} - -export function validateSubmissionCandidate(behavior: Behavior): SubmissionValidation { - const registry = validateAllBehaviors(); - const errors = [...registry.errors]; - if (registry.behaviors.some((item) => item.id === behavior.id)) { - errors.push(`Behavior ID '${behavior.id}' already exists in registry/behaviors/.`); - } - return { valid: errors.length === 0, errors }; -} - -export async function createSubmissionBranch( - fork: string, - defaultBranch: string, - behaviorId: string, - token: string, - fetchImpl: FetchImplementation = fetch, -): Promise { - const base = await githubFetch<{ object?: { sha?: string } }>( - `${GITHUB_API}/repos/${fork}/git/ref/heads/${encodeURIComponent(defaultBranch)}`, - token, - {}, - fetchImpl, - ); - const baseSha = base?.object?.sha; - if (!baseSha) throw new Error(`GitHub returned no base commit for ${defaultBranch}.`); - - let lastCollision = "unknown collision"; - for (let attempt = 0; attempt < BRANCH_ATTEMPTS; attempt++) { - const branch = makeSubmissionBranch(behaviorId); - const branchRef = `${GITHUB_API}/repos/${fork}/git/ref/heads/${encodeURIComponent(branch)}`; - - try { - await githubFetch(branchRef, token, {}, fetchImpl); - lastCollision = `${branch} already exists`; - continue; - } catch (error) { - if (!(error instanceof GitHubRequestError) || error.status !== 404) throw error; - } - - try { - await githubFetch(`${GITHUB_API}/repos/${fork}/git/refs`, token, { - method: "POST", - body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: baseSha }), - }, fetchImpl); - return branch; - } catch (error) { - if (error instanceof GitHubRequestError && error.status === 422) { - lastCollision = `${branch} was created concurrently`; - continue; - } - throw error; - } - } - - throw new Error(`Could not create a collision-free submission branch after ${BRANCH_ATTEMPTS} attempts (${lastCollision}).`); -} - -interface LoadedBehavior { - raw: string; - behavior: Behavior; -} - -function loadBehavior(sourcePath: string): LoadedBehavior | { error: string } { - let raw: string; - try { - raw = fs.readFileSync(sourcePath, "utf-8"); - } catch (error) { - return { error: `Could not read behavior file '${sourcePath}': ${errorMessage(error)}` }; - } - - let value: unknown; - try { - value = JSON.parse(raw); - } catch (error) { - return { error: `Behavior file '${sourcePath}' contains invalid JSON: ${errorMessage(error)}` }; - } - - const parsed = BehaviorSchema.safeParse(value); - if (!parsed.success) { - return { - error: `Behavior JSON failed validation — fix these before submitting:\n${JSON.stringify(parsed.error.format(), null, 2)}`, - }; - } - return { raw, behavior: parsed.data }; -} - -async function waitForFork(fork: string, token: string, fetchImpl: FetchImplementation): Promise { - const deadline = Date.now() + FORK_READY_TIMEOUT_MS; - let lastError: unknown; - - while (Date.now() < deadline) { - try { - const result = await githubFetch<{ default_branch?: string }>(`${GITHUB_API}/repos/${fork}`, token, {}, fetchImpl); - if (!result?.default_branch) throw new Error("GitHub fork response was missing default_branch."); - return result.default_branch; - } catch (error) { - lastError = error; - if (!(error instanceof GitHubRequestError) || error.status !== 404) throw error; - } - await new Promise((resolve) => setTimeout(resolve, Math.min(FORK_POLL_INTERVAL_MS, deadline - Date.now()))); - } - - throw new Error(`Timed out waiting for fork ${fork} to become available: ${errorMessage(lastError)}`); -} - -function printSubmitHelp() { - console.log(`Usage: pnpm cli submit - -The candidate and the complete current registry are validated before GitHub authentication. -Authentication/API failures print manual instructions and return status 1.`); -} - -export async function main(argv: string[] = process.argv.slice(2)): Promise { - const sourcePath = argv[0]; - if (sourcePath === "--help" || sourcePath === "-h") { - printSubmitHelp(); - return 0; - } - if (!sourcePath) { - console.error("Error: Usage: pnpm cli submit "); - return 1; - } - - const absPath = path.resolve(sourcePath); - const loaded = loadBehavior(absPath); - if ("error" in loaded) { - console.error(`\x1b[31mError: ${loaded.error}\x1b[0m`); - return 1; - } - - let validation: SubmissionValidation; - try { - validation = validateSubmissionCandidate(loaded.behavior); - } catch (error) { - console.error(`\x1b[31mError: full registry validation could not run: ${errorMessage(error)}\x1b[0m`); - return 1; - } - if (!validation.valid) { - console.error("\x1b[31mError: full registry validation failed; submission was not attempted.\x1b[0m"); - console.error(validation.errors.join("\n")); - return 1; - } - const { raw, behavior } = loaded; - console.log(`\x1b[32m✓ ${behavior.id} passes candidate and full-registry validation.\x1b[0m`); - - const repo = resolveRepo(); - if (!repo) { - return manualFallback(raw, behavior.id, null, "could not determine a valid GitHub repo; set UDUCK_REPO=owner/name"); - } - - let token = process.env.GITHUB_TOKEN?.trim() || null; - if (!token) { - const clientId = process.env.UDUCK_CLIENT_ID?.trim(); - if (!clientId) { - return manualFallback(raw, behavior.id, repo, "no OAuth client id configured (set UDUCK_CLIENT_ID) or GITHUB_TOKEN"); - } - try { - token = await deviceFlowAuth(clientId); - console.log("\x1b[32m✓ Authenticated (public_repo scope only).\x1b[0m"); - } catch (error) { - return manualFallback(raw, behavior.id, repo, errorMessage(error)); - } - } - - let branch: string | undefined; - try { - const [owner, name] = repo.split("/"); - const me = await githubFetch<{ login?: string }>(`${GITHUB_API}/user`, token); - if (!me?.login) throw new Error("GitHub user response was missing login."); - console.log(`✓ Signed in as ${me.login}`); - - await githubFetch(`${GITHUB_API}/repos/${owner}/${name}/forks`, token, { method: "POST" }); - const fork = parseGithubRepo(`${me.login}/${name}`); - if (!fork) throw new Error("GitHub returned an unsafe login/repository name."); - console.log(`✓ Fork ready: ${fork}`); - - const defaultBranch = await waitForFork(fork, token, fetch); - branch = await createSubmissionBranch(fork, defaultBranch, behavior.id, token); - console.log(`✓ Unique branch ${branch} created`); - - await githubFetch( - `${GITHUB_API}/repos/${fork}/contents/registry/behaviors/${encodeURIComponent(behavior.id)}.json`, - token, - { - method: "PUT", - body: JSON.stringify({ - message: `behavior: add ${behavior.id} v${behavior.version}`, - content: Buffer.from(raw, "utf-8").toString("base64"), - branch, - }), - }, - ); - console.log(`✓ registry/behaviors/${behavior.id}.json committed`); - - const pr = await githubFetch<{ html_url?: string }>(`${GITHUB_API}/repos/${owner}/${name}/pulls`, token, { - method: "POST", - body: JSON.stringify({ - title: `behavior: ${behavior.name} (${behavior.id} v${behavior.version})`, - head: `${me.login}:${branch}`, - base: defaultBranch, - body: PR_BODY, - }), - }); - if (!pr?.html_url) throw new Error("GitHub PR response was missing html_url."); - console.log(`\n\x1b[32m🦆 PR opened: ${pr.html_url}\x1b[0m`); - console.log("CI will validate the descriptor and rebuild the catalog on this PR."); - return 0; - } catch (error) { - return manualFallback(raw, behavior.id, repo, errorMessage(error), branch); - } -} - -const PR_BODY = [ - "## Behavior submission checklist", - "", - "- [x] Candidate and full registry: pass `pnpm validate` (61-D obs, 14 joints, 50 Hz contract)", - "- [ ] Artifact: canonical ONNX URL and metadata are correct", - "- [ ] Verification label and hardware requirements are accurate", - "- [ ] License: stated; upstream assets are linked, not hosted", -].join("\n"); - -if (process.argv[1]?.endsWith("submit.ts")) { - void main().then((code) => { - process.exitCode = code; - }).catch((error: unknown) => { - console.error(`\x1b[31m${errorMessage(error)}\x1b[0m`); - process.exitCode = 1; - }); -} diff --git a/src/app/api/behaviors/[id]/route.ts b/src/app/api/behaviors/[id]/route.ts index eaf2c79..c0ac296 100644 --- a/src/app/api/behaviors/[id]/route.ts +++ b/src/app/api/behaviors/[id]/route.ts @@ -12,7 +12,7 @@ interface Props { params: Promise<{ id: string }>; } -export async function GET(request: Request, { params }: Props) { +export async function GET(_request: Request, { params }: Props) { const { id } = await params; const behavior = getBehaviorById(id); diff --git a/src/app/globals.css b/src/app/globals.css index 9452c0c..40af9ff 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -26,7 +26,6 @@ --magenta: #ff2fa8; --purple: #9d87e8; --teal: #7ecfd8; - --cream: #f2ecdd; --halftone: radial-gradient(rgba(250, 248, 242, 0.055) 1px, transparent 1.4px); --font-sans: "DM Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Hiragino Kaku Gothic ProN", "Hiragino Sans", "Yu Gothic", "Noto Sans JP", "Noto Sans CJK JP", "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; --font-display: "Arial Narrow", "Roboto Condensed", ui-sans-serif, system-ui, "Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", "Noto Sans CJK JP", "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; @@ -58,7 +57,7 @@ button, select { cursor: pointer; } ::selection { color: #1c0d03; background: var(--orange); } :focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; border-radius: 2px; } -code, pre, .mono { font-family: var(--font-mono); } +code, pre { font-family: var(--font-mono); } code { border-radius: 0.35rem; padding: 0.1rem 0.35rem; @@ -721,9 +720,6 @@ code { } .behavior-accessory { color: var(--cyan); } -.behavior-specs { color: var(--quiet); font-family: var(--font-mono); font-size: 0.6rem; } -.behavior-version { color: var(--quiet); font-family: var(--font-mono); font-size: 0.6rem; } - .behavior-footer { min-width: 7.5rem; align-self: center; display: flex; justify-content: flex-end; border: 0; padding: 0; background: transparent; } .inspect-link { diff --git a/src/components/DuckMark.tsx b/src/components/DuckMark.tsx index 4631886..ec8661e 100644 --- a/src/components/DuckMark.tsx +++ b/src/components/DuckMark.tsx @@ -132,7 +132,6 @@ interface DuckMarkProps { className?: string; accent?: string; mouth?: DuckMouth; - mouthOpen?: boolean; agitated?: boolean; overheated?: boolean; } @@ -142,12 +141,11 @@ export function DuckMark({ className, accent = "#FFD23F", mouth, - mouthOpen = false, agitated = false, overheated = false, }: DuckMarkProps) { const heatGradientId = `duck-foot-heat-${useId().replace(/:/g, "")}`; - const mouthState = mouth ?? (mouthOpen ? "open" : "closed"); + const mouthState = mouth ?? "closed"; const stateClassName = [ className, mouthState === "slightly-open" && "duck-mark-mouth-slightly-open", diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index a836d08..b9910cb 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -59,9 +59,11 @@ export function MediaPreview({ media, title, variant }: MediaPreviewProps) { // on ref attach (complete/readyState) makes the reveal stateless against // that race. Safe to call repeatedly; it only ever converges. const syncImageState = useCallback((node: HTMLImageElement) => { + if (!node.complete) return; + const { naturalWidth, naturalHeight } = node; - if (node.complete && naturalWidth === 0) { + if (naturalWidth === 0) { setImageFailed(true); if (variant === "card") setThumbnailReady(true); return; diff --git a/src/lib/registry.ts b/src/lib/registry.ts index bd2139f..3dfe9f5 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -49,18 +49,11 @@ export function getBehaviorById(id: string): Behavior | null { export function getRegistryStats() { const all = getAllBehaviors(); const hardware = all.filter((b) => b.verification.status === "verified_hardware").length; - const claimed = all.filter((b) => b.verification.status === "claimed_hardware").length; const community = all.filter((b) => b.verification.status === "community_experimental").length; - const categories = Array.from(new Set(all.map((b) => b.category))); - const allTags = Array.from(new Set(all.flatMap((b) => b.tags))); - return { total: all.length, hardware, - claimed, community, - categories, - allTags, }; } diff --git a/tests/cli-submit.test.ts b/tests/cli-submit.test.ts deleted file mode 100644 index b9d515b..0000000 --- a/tests/cli-submit.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, describe, expect, it, vi } from "vitest"; -import { formatStatusBadge, parsePullArgs } from "../scripts/cli"; -import { - createSubmissionBranch, - fetchWithTimeout, - githubFetch, - main as submit, - validateSubmissionCandidate, -} from "../scripts/submit"; -import { BehaviorSchema } from "../registry/schema/behavior"; - -const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "uduck-cli-submit-")); - -afterAll(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); -}); - -function stripAnsi(value: string): string { - return value.replace(/\x1b\[[0-9;]*m/g, ""); -} - -function jsonResponse(value: unknown, status = 200): Response { - return new Response(JSON.stringify(value), { - status, - headers: { "content-type": "application/json" }, - }); -} - -describe("CLI status and artifact UX", () => { - it("uses a truthful badge for every verification status", () => { - expect(stripAnsi(formatStatusBadge("verified_hardware"))).toBe("[HARDWARE]"); - expect(stripAnsi(formatStatusBadge("claimed_hardware"))).toBe("[CLAIMED]"); - expect(stripAnsi(formatStatusBadge("community_experimental"))).toBe("[EXPERIMENTAL]"); - }); - - it("parses an optional destination for pull", () => { - expect(parsePullArgs(["genesis-velocity"])).toMatchObject({ - id: "genesis-velocity", - destDir: "./policies", - }); - expect(parsePullArgs(["genesis-velocity", "./tmp"])).toMatchObject({ - id: "genesis-velocity", - destDir: "./tmp", - }); - }); -}); - -describe("submission remediation", () => { - it("reports malformed JSON as a clean nonzero failure", async () => { - const file = path.join(tempDir, "malformed.json"); - fs.writeFileSync(file, "{\"id\":"); - - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); - try { - await expect(submit([file])).resolves.toBe(1); - expect(error.mock.calls.flat().join(" ")).toMatch(/invalid JSON/i); - expect(error.mock.calls.flat().join(" ")).not.toMatch(/SyntaxError/); - } finally { - error.mockRestore(); - } - }); - - it("rejects a candidate whose id is already in the full registry", () => { - const raw = fs.readFileSync("registry/behaviors/alpha-walking.json", "utf8"); - const parsed = BehaviorSchema.parse(JSON.parse(raw)); - const result = validateSubmissionCandidate(parsed); - expect(result.valid).toBe(false); - expect(result.errors.join("\n")).toMatch(/alpha-walking.*already exists/i); - }); - - it("returns status 1 when manual instructions are printed instead of a PR", async () => { - const candidate = JSON.parse(fs.readFileSync("registry/behaviors/genesis-velocity.json", "utf8")); - candidate.id = "manual-submit-test"; - candidate.name = "Manual Submit Test"; - const file = path.join(tempDir, "manual-submit-test.json"); - fs.writeFileSync(file, JSON.stringify(candidate)); - - const oldRepo = process.env.UDUCK_REPO; - const oldToken = process.env.GITHUB_TOKEN; - const oldClientId = process.env.UDUCK_CLIENT_ID; - process.env.UDUCK_REPO = "owner/registry"; - delete process.env.GITHUB_TOKEN; - delete process.env.UDUCK_CLIENT_ID; - const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - const error = vi.spyOn(console, "error").mockImplementation(() => undefined); - try { - await expect(submit([file])).resolves.toBe(1); - const output = [...log.mock.calls, ...error.mock.calls].flat().join(" "); - expect(output).toMatch(/manual fallback/i); - expect(output).toMatch(/no PR was created/i); - expect(output).toMatch(/status 1/i); - } finally { - if (oldRepo === undefined) delete process.env.UDUCK_REPO; - else process.env.UDUCK_REPO = oldRepo; - if (oldToken === undefined) delete process.env.GITHUB_TOKEN; - else process.env.GITHUB_TOKEN = oldToken; - if (oldClientId === undefined) delete process.env.UDUCK_CLIENT_ID; - else process.env.UDUCK_CLIENT_ID = oldClientId; - log.mockRestore(); - error.mockRestore(); - } - }); - - it("times out a stalled GitHub request", async () => { - const stalledFetch: typeof fetch = async (_input, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); - }); - - await expect(fetchWithTimeout("https://api.github.com/user", {}, 5, stalledFetch)).rejects.toThrow(/timed out/i); - }); - - it("turns a successful HTTP response with invalid JSON into a readable error", async () => { - const badJsonFetch: typeof fetch = async () => new Response("not-json", { status: 200 }); - await expect(githubFetch("https://api.github.com/user", "token", {}, badJsonFetch)).rejects.toThrow(/invalid JSON/i); - }); - - it("retries branch creation after an existing/racing branch collision", async () => { - const createdBranches: string[] = []; - const existingBranches = new Set(); - const collisionFetch: typeof fetch = async (input, init) => { - const url = String(input); - if (url.endsWith("/git/ref/heads/main")) { - return jsonResponse({ object: { sha: "base-sha" } }); - } - - const branchMatch = url.match(/\/git\/ref\/heads\/([^/?]+)$/); - if (branchMatch) { - const branch = decodeURIComponent(branchMatch[1]); - return existingBranches.has(branch) - ? jsonResponse({ ref: `refs/heads/${branch}` }) - : jsonResponse({ message: "Not Found" }, 404); - } - - if (url.endsWith("/git/refs") && init?.method === "POST") { - const body = JSON.parse(String(init.body)) as { ref: string }; - const branch = body.ref.replace(/^refs\/heads\//, ""); - createdBranches.push(branch); - if (createdBranches.length === 1) return jsonResponse({ message: "already exists" }, 422); - existingBranches.add(branch); - return jsonResponse({ ref: body.ref }, 201); - } - - throw new Error(`unexpected URL: ${url}`); - }; - - const branch = await createSubmissionBranch("alice/registry", "main", "demo", "token", collisionFetch); - expect(createdBranches).toHaveLength(2); - expect(new Set(createdBranches).size).toBe(2); - expect(branch).toBe(createdBranches[1]); - }); -}); diff --git a/tests/contributor-tools.test.ts b/tests/contributor-tools.test.ts new file mode 100644 index 0000000..726c548 --- /dev/null +++ b/tests/contributor-tools.test.ts @@ -0,0 +1,70 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import alphaWalkingJson from "../registry/behaviors/alpha-walking.json"; +import { BehaviorSchema } from "../registry/schema/behavior"; +import { renderReadmeCatalog, updateReadmeCatalog } from "../scripts/generate-registry-index"; +import { createBehaviorScaffold, parseScaffoldArgs } from "../scripts/new-behavior"; + +const README_TABLE_START = ""; +const README_TABLE_END = ""; + +describe("contributor tooling", () => { + it("creates a schema-valid scaffold from semantic arguments", () => { + const options = parseScaffoldArgs([ + "id=moon-walk", + "category=locomotion", + "author=Ada Lovelace", + "description=A small test behavior.", + "license=Apache-2.0", + ]); + const scaffold = createBehaviorScaffold(options); + + expect(options.name).toBe("Moon Walk"); + expect(scaffold).toMatchObject({ + id: "moon-walk", + name: "Moon Walk", + category: "locomotion", + authors: [{ name: "Ada Lovelace" }], + license: "Apache-2.0", + contract: { + observation_dim: 61, + action_dim: 14, + control_frequency_hz: 50, + }, + }); + expect(BehaviorSchema.safeParse(scaffold).success).toBe(true); + }); + + it("rejects malformed scaffold arguments", () => { + expect(() => parseScaffoldArgs([])).toThrow(/Missing required field 'id'/); + expect(() => parseScaffoldArgs(["id=bad_id"])).toThrow(/lowercase kebab-case/); + expect(() => parseScaffoldArgs(["id=good-id", "category=unknown"])).toThrow(/Invalid category/); + }); + + it("replaces only the generated README section", () => { + const behavior = BehaviorSchema.parse(alphaWalkingJson); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "uduck-readme-")); + const readmePath = path.join(tempDir, "README.md"); + + try { + fs.writeFileSync( + readmePath, + `Intro\n${README_TABLE_START}\n| old row |\n${README_TABLE_END}\nFooter\n`, + "utf-8", + ); + + updateReadmeCatalog([behavior], readmePath); + + const updated = fs.readFileSync(readmePath, "utf-8"); + expect(updated).toContain("Intro\n"); + expect(updated).toContain("Footer\n"); + expect(updated).toContain("| Behavior | ID | Category | Status | Publisher | Setup | Preview |"); + expect(updated).toContain(`[${behavior.name}](https://uduckmoves.com/behaviors/${behavior.id})`); + expect(updated).not.toContain("old row"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +});