From fc13dbc0f2ee5ae0a9a5cfd1ccf54e5530cc68b5 Mon Sep 17 00:00:00 2001 From: todortodorovic <95745674+todortodorovic@users.noreply.github.com> Date: Tue, 26 May 2026 18:14:59 +0200 Subject: [PATCH] quests path --- package.json | 1 + pnpm-lock.yaml | 3 + src/commands/mod/QuestPicker.tsx | 198 +++++++++++++++++++++++++++++++ src/commands/mod/index.ts | 74 ++++++++++++ src/utils/mod/quests.test.ts | 164 +++++++++++++++++++++++++ src/utils/mod/quests.ts | 148 +++++++++++++++++++++++ 6 files changed, 588 insertions(+) create mode 100644 src/commands/mod/QuestPicker.tsx create mode 100644 src/utils/mod/quests.test.ts create mode 100644 src/utils/mod/quests.ts diff --git a/package.json b/package.json index 9eec17f..0f07a78 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@parity/product-sdk-terminal": "^0.2.1", "@parity/product-sdk-tx": "^0.2.4", "@parity/product-sdk-utils": "^0.1.1", + "@polkadot-api/json-rpc-provider": "^0.2.0", "@polkadot-api/sdk-ink": "^0.7.0", "@scure/sr25519": "^2.2.0", "@sentry/node": "^9.47.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 299a3f3..3c67846 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@parity/product-sdk-utils': specifier: ^0.1.1 version: 0.1.1 + '@polkadot-api/json-rpc-provider': + specifier: ^0.2.0 + version: 0.2.0 '@polkadot-api/sdk-ink': specifier: ^0.7.0 version: 0.7.0(@polkadot-api/ink-contracts@0.6.2)(polkadot-api@2.1.3(esbuild@0.27.7)(rxjs@7.8.2))(rxjs@7.8.2)(typescript@5.9.3) diff --git a/src/commands/mod/QuestPicker.tsx b/src/commands/mod/QuestPicker.tsx new file mode 100644 index 0000000..1fe60a8 --- /dev/null +++ b/src/commands/mod/QuestPicker.tsx @@ -0,0 +1,198 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useCallback, useEffect, useState } from "react"; +import { Box, Text, useInput, useStdout } from "ink"; +import { COLOR, Hint, Mark } from "../../utils/ui/theme/index.js"; +import type { GitHubRepoRef } from "../../utils/mod/source.js"; +import { + fetchQuestsManifest, + type QuestEntry, + type QuestsManifest, +} from "../../utils/mod/quests.js"; + +interface Props { + repoRef: GitHubRepoRef; + /** Resolves once the user is done browsing (Start tutorial or skip). */ + onDone: () => void; + /** User cancelled the whole flow. */ + onCancel: () => void; +} + +const COL = { num: 5, id: 16, title: 32, difficulty: 12 }; + +function pad(s: string, w: number): string { + return s.length > w ? `${s.slice(0, w - 1)}…` : s.padEnd(w); +} + +function formatDifficulty(d: number | undefined): string { + if (typeof d !== "number" || d <= 0) return "—"; + return "★".repeat(Math.min(d, 5)); +} + +export function QuestPicker({ repoRef, onDone, onCancel }: Props) { + const { stdout } = useStdout(); + const viewH = Math.max((stdout?.rows ?? 24) - 10, 5); + + const [manifest, setManifest] = useState(null); + const [fetching, setFetching] = useState(true); + const [cursor, setCursor] = useState(0); + const [scroll, setScroll] = useState(0); + + const load = useCallback(async () => { + try { + const m = await fetchQuestsManifest(repoRef); + if (!m) { + // No quests.json — not a quest track. Skip the picker silently + // and let the existing download flow run. + onDone(); + return; + } + setManifest(m); + } catch { + // Malformed manifest or transient error — same fall-through. + onDone(); + } finally { + setFetching(false); + } + }, [repoRef, onDone]); + + useEffect(() => { + load(); + }, [load]); + + const quests: QuestEntry[] = manifest?.quests ?? []; + + // Cursor moves through quests AND a trailing "Start tutorial" button. + // Quest rows are read-only browsing — only the button has an Enter action. + const buttonIndex = quests.length; + const itemCount = quests.length + 1; + + useInput((input, key) => { + if (fetching || !manifest) { + if (input === "q") onCancel(); + return; + } + if (key.upArrow && cursor > 0) { + const next = cursor - 1; + setCursor(next); + if (next < scroll) setScroll(next); + } + if (key.downArrow && cursor < itemCount - 1) { + const next = cursor + 1; + setCursor(next); + if (next >= scroll + viewH) setScroll(next - viewH + 1); + } + if (key.return && cursor === buttonIndex && quests.length > 0) { + onDone(); + } + if (input === "q") onCancel(); + }); + + if (fetching) { + return ( + + + + fetching quests.json from github.com/{repoRef.owner}/{repoRef.repo} (main)… + + + ); + } + + if (!manifest || quests.length === 0) { + return ( + + This track has no quests defined. + + q quit + + + ); + } + + const visible = quests.slice(scroll, scroll + viewH); + const notesCol = Math.max( + (stdout?.columns ?? 80) - COL.num - COL.id - COL.title - COL.difficulty - 12, + 10, + ); + const focusedQuest = cursor < quests.length ? quests[cursor] : null; + const buttonFocused = cursor === buttonIndex; + + return ( + + + + Quest track:{" "} + + {manifest.title ?? manifest.track_id} + + + + + + {`${pad(" #", COL.num)} ${pad("id", COL.id)} ${pad("title", COL.title)} ${pad( + "difficulty", + COL.difficulty, + )} notes`} + + + + + {"─".repeat(COL.num + COL.id + COL.title + COL.difficulty + notesCol + 12)} + + + + {visible.map((q, i) => { + const idx = scroll + i; + const sel = idx === cursor; + const num = sel + ? `›${String(idx + 1).padStart(COL.num - 1)}` + : ` ${String(idx + 1).padStart(COL.num - 1)}`; + const diff = formatDifficulty(q.difficulty); + const notes = + q.depends_on && q.depends_on.length > 0 + ? `needs: ${q.depends_on.join(", ")}` + : ""; + return ( + + + {`${num} ${pad(q.id, COL.id)} ${pad(q.title, COL.title)} ${pad( + diff, + COL.difficulty, + )} ${pad(notes, notesCol)}`} + + + ); + })} + + {focusedQuest?.summary && ( + + ↳ {focusedQuest.summary} + + )} + + + + {buttonFocused ? "› [ Start tutorial → ]" : " [ Start tutorial → ]"} + + + + + ↑↓ navigate · ⏎ start tutorial · q quit + + + ); +} diff --git a/src/commands/mod/index.ts b/src/commands/mod/index.ts index b0abf1b..137ead0 100644 --- a/src/commands/mod/index.ts +++ b/src/commands/mod/index.ts @@ -22,9 +22,20 @@ import { getConnection, destroyConnection } from "../../utils/connection.js"; import { getReadOnlyRegistryContract } from "../../utils/registry.js"; import { AppBrowser, type AppEntry } from "./AppBrowser.js"; import { SetupScreen } from "./SetupScreen.js"; +import { QuestPicker } from "./QuestPicker.js"; import { defaultRepoName } from "../../utils/git/repoName.js"; import { runCliCommand } from "../../cli-runtime.js"; import { assertPublicGitHubRepo, ModdablePreflightError } from "../../utils/deploy/moddable.js"; +import { parseGitHubRepoUrl, type GitHubRepoRef } from "../../utils/mod/source.js"; +import { fetchBulletinJson, getBulletinGateway } from "../../utils/bulletinGateway.js"; + +interface FetchedAppMetadata { + name?: string; + description?: string; + repository?: string; + branch?: string; + tag?: string; +} export const modCommand = new Command("mod") .description("Mod a playground app — clone the source as a fresh project to customise") @@ -99,6 +110,39 @@ async function runModCommand(rawDomain: string | undefined): Promise { } } + // QuestPicker is a read-only display of `quests.json` from the track + // repo's main. It runs BEFORE the existing setup flow without + // changing any of it — when the user presses "Start tutorial" we just + // continue into the normal clone-main path; when there's no + // `quests.json` the picker auto-skips silently. The picker needs a + // GitHub ref, so we lift the metadata fetch up here for the + // direct-domain path (the interactive picker already pre-fetched). + let repoRef: GitHubRepoRef | null = null; + if (metadata?.repository) { + repoRef = parseGitHubRepoUrl(metadata.repository); + } else { + try { + const fetched = await withSpan( + "cli.mod.fetch-metadata", + "fetch app metadata for quest probe", + () => fetchAppMetadata(registry, domain), + ); + repoRef = fetched.repository ? parseGitHubRepoUrl(fetched.repository) : null; + } catch { + // Fall through with `repoRef = null` — picker is skipped and + // the existing SetupScreen step will surface the same error. + } + } + if (repoRef) { + const continued = await withSpan("cli.mod.quest-picker", "browse quests", () => + pickQuest(repoRef), + ); + if (!continued) { + process.exitCode = 0; + return; + } + } + const targetDir = await withSpan("cli.mod.resolve-target", "resolve target directory", () => resolveTargetDir({ domain }), ); @@ -150,6 +194,36 @@ async function resolveTargetDir(args: { domain: string }): Promise { + const metaRes = await registry.getMetadataUri.query(domain); + if (!metaRes.success) { + throw new Error( + `Registry lookup for "${domain}" failed at dry-run (chain rejected the call)`, + ); + } + const cid = metaRes.value.isSome ? metaRes.value.value : null; + if (!cid) throw new Error(`App "${domain}" not found in registry`); + return await fetchBulletinJson(cid, getBulletinGateway()); +} + +function pickQuest(repoRef: GitHubRepoRef): Promise { + return new Promise((resolve) => { + const app = render( + React.createElement(QuestPicker, { + repoRef, + onDone: () => { + app.unmount(); + resolve(true); + }, + onCancel: () => { + app.unmount(); + resolve(false); + }, + }), + ); + }); +} + function browseAndPick(registry: any): Promise { return new Promise((resolve) => { const app = render( diff --git a/src/utils/mod/quests.test.ts b/src/utils/mod/quests.test.ts new file mode 100644 index 0000000..e858aba --- /dev/null +++ b/src/utils/mod/quests.test.ts @@ -0,0 +1,164 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from "vitest"; +import { + fetchQuestsManifest, + findQuest, + parseQuestsManifest, + QuestNotFoundError, +} from "./quests.js"; + +const validManifest = { + schema_version: 1, + track_id: "rock-paper-scissors", + title: "Rock Paper Scissors", + description: "Progress from local to on-chain.", + quests: [ + { + id: "level-1", + title: "Local Challenger", + difficulty: 1, + estimated_minutes: 15, + required_tools: ["dot-cli"], + ai_skill_hints: [".claude/skills/level-1.md"], + teaches: ["Bulletin hosting"], + summary: "Play vs. the computer.", + acceptance: ["App deployed via dot deploy"], + }, + { + id: "level-2", + title: "On-Chain Record", + difficulty: 2, + depends_on: ["level-1"], + }, + ], +}; + +describe("parseQuestsManifest", () => { + it("parses a valid manifest", () => { + const m = parseQuestsManifest(validManifest); + expect(m.track_id).toBe("rock-paper-scissors"); + expect(m.title).toBe("Rock Paper Scissors"); + expect(m.quests).toHaveLength(2); + expect(m.quests[0].id).toBe("level-1"); + expect(m.quests[0].difficulty).toBe(1); + expect(m.quests[1].depends_on).toEqual(["level-1"]); + }); + + it("defaults schema_version to 1 when missing", () => { + const m = parseQuestsManifest({ ...validManifest, schema_version: undefined }); + expect(m.schema_version).toBe(1); + }); + + it("rejects a non-object body", () => { + expect(() => parseQuestsManifest(null)).toThrow(/expected an object/i); + expect(() => parseQuestsManifest("nope")).toThrow(/expected an object/i); + }); + + it("rejects missing track_id", () => { + expect(() => parseQuestsManifest({ quests: [] })).toThrow(/track_id/); + }); + + it("rejects missing quests array", () => { + expect(() => parseQuestsManifest({ track_id: "x" })).toThrow(/quests/); + }); + + it("rejects a quest without id", () => { + expect(() => + parseQuestsManifest({ + track_id: "x", + quests: [{ title: "a" }], + }), + ).toThrow(/quests\[0\]\.id/); + }); + + it("rejects a quest without title", () => { + expect(() => + parseQuestsManifest({ + track_id: "x", + quests: [{ id: "a" }], + }), + ).toThrow(/quests\[0\]\.title/); + }); + + it("ignores ai_skill_hints when not an array of strings", () => { + const m = parseQuestsManifest({ + track_id: "x", + quests: [{ id: "a", title: "A", ai_skill_hints: [1, 2] }], + }); + expect(m.quests[0].ai_skill_hints).toBeUndefined(); + }); +}); + +describe("findQuest", () => { + const m = parseQuestsManifest(validManifest); + + it("returns the matching quest", () => { + expect(findQuest(m, "level-2").title).toBe("On-Chain Record"); + }); + + it("throws QuestNotFoundError listing available ids", () => { + try { + findQuest(m, "level-99"); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(QuestNotFoundError); + expect((err as Error).message).toMatch(/level-1, level-2/); + } + }); +}); + +describe("fetchQuestsManifest", () => { + it("hits raw.githubusercontent.com on main", async () => { + let seen: string | null = null; + const fetchImpl: typeof fetch = async (url) => { + seen = String(url); + return new Response(JSON.stringify(validManifest), { status: 200 }); + }; + const m = await fetchQuestsManifest( + { owner: "paritytech", repo: "Rock-Paper-Scissors" }, + { fetch: fetchImpl }, + ); + expect(seen).toBe( + "https://raw.githubusercontent.com/paritytech/Rock-Paper-Scissors/main/quests.json", + ); + expect(m?.track_id).toBe("rock-paper-scissors"); + }); + + it("returns null for 404", async () => { + const fetchImpl: typeof fetch = async () => new Response("not found", { status: 404 }); + const m = await fetchQuestsManifest({ owner: "x", repo: "y" }, { fetch: fetchImpl }); + expect(m).toBeNull(); + }); + + it("throws on non-2xx, non-404", async () => { + const fetchImpl: typeof fetch = async () => new Response("nope", { status: 500 }); + await expect( + fetchQuestsManifest({ owner: "x", repo: "y" }, { fetch: fetchImpl }), + ).rejects.toThrow(/500/); + }); + + it("throws when the response is not JSON", async () => { + const fetchImpl: typeof fetch = async () => + new Response("not-json", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + await expect( + fetchQuestsManifest({ owner: "x", repo: "y" }, { fetch: fetchImpl }), + ).rejects.toThrow(/not valid JSON/); + }); +}); diff --git a/src/utils/mod/quests.ts b/src/utils/mod/quests.ts new file mode 100644 index 0000000..b283f6e --- /dev/null +++ b/src/utils/mod/quests.ts @@ -0,0 +1,148 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Quest manifest loader for the `dot mod` quest picker. + * + * Quest tracks ship a `quests.json` at the repo root describing the tutorial + * steps a learner can follow on `main`. The manifest is purely informational + * from the CLI's point of view: the picker displays it before the existing + * download flow runs untouched. We fetch the manifest via + * `raw.githubusercontent.com` (CDN, no `api.github.com` rate-limit cost) so + * the lookup stays cheap on hackathon WiFi where the anonymous 60/hr quota + * is shared across the venue. + * + * RevX-importable — no React/Ink imports. + */ + +import type { GitHubRepoRef } from "./source.js"; + +export interface QuestEntry { + id: string; + title: string; + difficulty?: number; + estimated_minutes?: number; + depends_on?: string[]; + required_tools?: string[]; + ai_skill_hints?: string[]; + teaches?: string[]; + summary?: string; + acceptance?: string[]; +} + +export interface QuestsManifest { + schema_version: number; + track_id: string; + title?: string; + description?: string; + quests: QuestEntry[]; +} + +interface FetchOpts { + fetch?: typeof fetch; +} + +const RAW_HOST = "https://raw.githubusercontent.com"; + +export class QuestNotFoundError extends Error {} + +/** + * Fetch and parse `quests.json` from a public GitHub repo's main branch. + * Returns `null` when the file is absent (404) so callers can distinguish + * "not a quest track" from a transport/parse failure. + */ +export async function fetchQuestsManifest( + ref: GitHubRepoRef, + opts: FetchOpts = {}, +): Promise { + const f = opts.fetch ?? fetch; + const url = `${RAW_HOST}/${ref.owner}/${ref.repo}/main/quests.json`; + const res = await f(url); + if (res.status === 404) return null; + if (!res.ok) { + throw new Error(`Failed to fetch quests.json from ${url}: ${res.status} ${res.statusText}`); + } + let body: unknown; + try { + body = await res.json(); + } catch { + throw new Error(`quests.json at ${url} is not valid JSON`); + } + return parseQuestsManifest(body); +} + +export function parseQuestsManifest(body: unknown): QuestsManifest { + if (!body || typeof body !== "object") { + throw new Error("quests.json: expected an object at the top level"); + } + const obj = body as Record; + if (typeof obj.track_id !== "string" || obj.track_id.length === 0) { + throw new Error("quests.json: missing or invalid `track_id`"); + } + if (!Array.isArray(obj.quests)) { + throw new Error("quests.json: missing or invalid `quests` array"); + } + const quests: QuestEntry[] = obj.quests.map((q, i) => parseQuest(q, i)); + return { + schema_version: typeof obj.schema_version === "number" ? obj.schema_version : 1, + track_id: obj.track_id, + title: typeof obj.title === "string" ? obj.title : undefined, + description: typeof obj.description === "string" ? obj.description : undefined, + quests, + }; +} + +function parseQuest(raw: unknown, index: number): QuestEntry { + if (!raw || typeof raw !== "object") { + throw new Error(`quests.json: quests[${index}] must be an object`); + } + const q = raw as Record; + if (typeof q.id !== "string" || q.id.length === 0) { + throw new Error(`quests.json: quests[${index}].id must be a non-empty string`); + } + if (typeof q.title !== "string" || q.title.length === 0) { + throw new Error(`quests.json: quests[${index}].title must be a non-empty string`); + } + return { + id: q.id, + title: q.title, + difficulty: typeof q.difficulty === "number" ? q.difficulty : undefined, + estimated_minutes: + typeof q.estimated_minutes === "number" ? q.estimated_minutes : undefined, + depends_on: stringArray(q.depends_on), + required_tools: stringArray(q.required_tools), + ai_skill_hints: stringArray(q.ai_skill_hints), + teaches: stringArray(q.teaches), + summary: typeof q.summary === "string" ? q.summary : undefined, + acceptance: stringArray(q.acceptance), + }; +} + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + if (!value.every((v) => typeof v === "string")) return undefined; + return value as string[]; +} + +export function findQuest(manifest: QuestsManifest, questId: string): QuestEntry { + const quest = manifest.quests.find((q) => q.id === questId); + if (!quest) { + const available = manifest.quests.map((q) => q.id).join(", ") || "(none)"; + throw new QuestNotFoundError( + `Quest "${questId}" not found in track "${manifest.track_id}". Available: ${available}`, + ); + } + return quest; +}