Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

198 changes: 198 additions & 0 deletions src/commands/mod/QuestPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<QuestsManifest | null>(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 (
<Box gap={1} paddingLeft={2}>
<Mark kind="run" />
<Text dimColor>
fetching quests.json from github.com/{repoRef.owner}/{repoRef.repo} (main)…
</Text>
</Box>
);
}

if (!manifest || quests.length === 0) {
return (
<Box flexDirection="column" paddingLeft={2}>
<Text dimColor>This track has no quests defined.</Text>
<Box marginTop={1}>
<Hint>q quit</Hint>
</Box>
</Box>
);
}

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 (
<Box flexDirection="column" paddingLeft={2}>
<Box marginBottom={1}>
<Text>
Quest track:{" "}
<Text bold color={COLOR.accent}>
{manifest.title ?? manifest.track_id}
</Text>
</Text>
</Box>
<Box>
<Text dimColor>
{`${pad(" #", COL.num)} ${pad("id", COL.id)} ${pad("title", COL.title)} ${pad(
"difficulty",
COL.difficulty,
)} notes`}
</Text>
</Box>
<Box>
<Text dimColor>
{"─".repeat(COL.num + COL.id + COL.title + COL.difficulty + notesCol + 12)}
</Text>
</Box>

{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 (
<Box key={q.id}>
<Text bold={sel} color={sel ? COLOR.accent : undefined}>
{`${num} ${pad(q.id, COL.id)} ${pad(q.title, COL.title)} ${pad(
diff,
COL.difficulty,
)} ${pad(notes, notesCol)}`}
</Text>
</Box>
);
})}

{focusedQuest?.summary && (
<Box marginTop={1} paddingLeft={0}>
<Text dimColor>↳ {focusedQuest.summary}</Text>
</Box>
)}

<Box marginTop={1}>
<Text bold={buttonFocused} color={buttonFocused ? COLOR.accent : undefined}>
{buttonFocused ? "› [ Start tutorial → ]" : " [ Start tutorial → ]"}
</Text>
</Box>

<Box marginTop={1}>
<Hint>↑↓ navigate · ⏎ start tutorial · q quit</Hint>
</Box>
</Box>
);
}
74 changes: 74 additions & 0 deletions src/commands/mod/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -99,6 +110,39 @@ async function runModCommand(rawDomain: string | undefined): Promise<void> {
}
}

// 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 }),
);
Expand Down Expand Up @@ -150,6 +194,36 @@ async function resolveTargetDir(args: { domain: string }): Promise<string | null
return fallback;
}

async function fetchAppMetadata(registry: any, domain: string): Promise<FetchedAppMetadata> {
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<FetchedAppMetadata>(cid, getBulletinGateway());
}

function pickQuest(repoRef: GitHubRepoRef): Promise<boolean> {
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<AppEntry | null> {
return new Promise((resolve) => {
const app = render(
Expand Down
Loading
Loading