diff --git a/src/commands/mod.ts b/src/commands/mod.ts deleted file mode 100644 index 2df5311..0000000 --- a/src/commands/mod.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Command } from "commander"; - -export const modCommand = new Command("mod") - .description("Clone a playground app template") - .argument("[domain]", "App domain to clone (interactive picker if omitted)") - .action(async (domain, opts) => { - console.log("TODO: mod", { domain, ...opts }); - }); diff --git a/src/commands/mod/AppBrowser.tsx b/src/commands/mod/AppBrowser.tsx new file mode 100644 index 0000000..69ed318 --- /dev/null +++ b/src/commands/mod/AppBrowser.tsx @@ -0,0 +1,169 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { Box, Text, useInput, useStdout } from "ink"; +import { getGateway, fetchJson } from "@polkadot-apps/bulletin"; +import { Spinner } from "../../utils/ui/index.js"; + +export interface AppEntry { + domain: string; + name: string | null; + description: string | null; + repository: string | null; +} + +interface Props { + registry: any; + onSelect: (app: AppEntry) => void; +} + +const BATCH = 10; +const COL = { num: 5, domain: 33, name: 37 }; + +function pad(s: string, w: number): string { + return s.length > w ? s.slice(0, w - 1) + "…" : s.padEnd(w); +} + +export function AppBrowser({ registry, onSelect }: Props) { + const { stdout } = useStdout(); + const viewH = Math.max((stdout?.rows ?? 24) - 6, 5); + + const [apps, setApps] = useState([]); + const [total, setTotal] = useState(0); + const [cursor, setCursor] = useState(0); + const [scroll, setScroll] = useState(0); + const [fetching, setFetching] = useState(true); + const nextIdx = useRef(null); + + const gateway = getGateway("paseo"); + + const loadBatch = useCallback( + async (startIdx: number) => { + setFetching(true); + const indices = []; + for (let i = startIdx; i > startIdx - BATCH && i >= 0; i--) indices.push(i); + + // Track where next batch should start + const lowestQueried = Math.min(...indices); + nextIdx.current = lowestQueried > 0 ? lowestQueried - 1 : null; + + // Fetch domains in parallel + const results = await Promise.all( + indices.map(async (i) => { + const res = await registry.getDomainAt.query(i); + return res.value.isSome ? (res.value.value as string) : null; + }), + ); + + const entries: AppEntry[] = results + .filter((d): d is string => d !== null) + .map((domain) => ({ domain, name: null, description: null, repository: null })); + + setApps((prev) => [...prev, ...entries]); + setFetching(false); + + // Fetch metadata in background, update each entry as it arrives + await Promise.allSettled( + entries.map(async (entry) => { + const metaRes = await registry.getMetadataUri.query(entry.domain); + const cid = metaRes.value.isSome ? (metaRes.value.value as string) : null; + if (!cid) return; + const meta = await fetchJson>(cid, gateway); + setApps((prev) => + prev.map((a) => + a === entry + ? { + ...a, + name: meta.name ?? null, + description: meta.description ?? null, + repository: meta.repository ?? null, + } + : a, + ), + ); + }), + ); + }, + [registry, gateway], + ); + + // Initial load + useEffect(() => { + (async () => { + const res = await registry.getAppCount.query(); + const count = res.value as number; + setTotal(count); + if (count > 0) await loadBatch(count - 1); + })(); + }, []); + + // Auto-load when cursor nears end + useEffect(() => { + if (cursor >= apps.length - 3 && nextIdx.current !== null && !fetching) { + loadBatch(nextIdx.current); + } + }, [cursor, apps.length, fetching]); + + // Keyboard + useInput((input, key) => { + if (key.upArrow && cursor > 0) { + const next = cursor - 1; + setCursor(next); + if (next < scroll) setScroll(next); + } + if (key.downArrow && cursor < apps.length - 1) { + const next = cursor + 1; + setCursor(next); + if (next >= scroll + viewH) setScroll(next - viewH + 1); + } + if (key.return && apps.length > 0) onSelect(apps[cursor]); + if (input === "q") process.exit(0); + }); + + const visible = apps.slice(scroll, scroll + viewH); + const descW = Math.max((stdout?.columns ?? 80) - COL.num - COL.domain - COL.name - 10, 10); + + return ( + + + + {pad(" #", COL.num)}│ {pad("Domain", COL.domain)}│ {pad("Name", COL.name)}│{" "} + Description + + + + + {"─".repeat(COL.num)}┼{"─".repeat(COL.domain + 1)}┼{"─".repeat(COL.name + 1)}┼ + {"─".repeat(descW + 1)} + + + + {visible.map((app, 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)}`; + return ( + + + {num}│ {pad(app.domain, COL.domain)}│{" "} + {pad(app.name ?? (app.name === null ? "…" : "—"), COL.name)}│{" "} + {pad(app.description ?? "", descW)} + + + ); + })} + + {fetching && ( + + + Loading apps... + + )} + + + ↑↓ navigate ⏎ select q quit ({apps.length}/{total}) + + + + ); +} diff --git a/src/commands/mod/SetupScreen.tsx b/src/commands/mod/SetupScreen.tsx new file mode 100644 index 0000000..51c14a5 --- /dev/null +++ b/src/commands/mod/SetupScreen.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { Box, Text } from "ink"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { getGateway, fetchJson } from "@polkadot-apps/bulletin"; +import { StepRunner, type Step, type StepRunnerResult } from "../../utils/ui/index.js"; +import { isGhAuthenticated, forkAndClone, cloneRepo, runCommand } from "../../utils/git.js"; + +interface AppMetadata { + name?: string; + description?: string; + repository?: string; + branch?: string; + tag?: string; +} + +interface Props { + domain: string; + /** Pre-fetched metadata (interactive path) or null (direct path — will fetch). */ + metadata: AppMetadata | null; + registry: any; + targetDir: string; + forceClone: boolean; + onDone: (ok: boolean) => void; +} + +export function SetupScreen({ + domain, + metadata: initial, + registry, + targetDir, + forceClone, + onDone, +}: Props) { + const canFork = !forceClone && isGhAuthenticated(); + + // Metadata is fetched in step 1 and shared with later steps via this ref + let meta: AppMetadata = initial ?? {}; + + const steps: Step[] = [ + { + name: "Fetch app metadata", + run: async (log) => { + if (initial) { + log("Using cached metadata"); + return; + } + log(`Querying registry for ${domain}...`); + const metaRes = await registry.getMetadataUri.query(domain); + const cid = metaRes.value.isSome ? metaRes.value.value : null; + if (!cid) throw new Error(`App "${domain}" not found in registry`); + + log(`Fetching metadata from IPFS (${cid.slice(0, 16)}...)...`); + meta = await fetchJson(cid, getGateway("paseo")); + if (!meta.repository) throw new Error("App has no repository URL"); + }, + }, + { + name: canFork ? "Fork & clone" : "Clone", + run: async (log) => { + const repo = meta.repository!; + if (canFork) { + await forkAndClone(repo, targetDir, { branch: meta.branch, log }); + } else { + await cloneRepo(repo, targetDir, { branch: meta.branch, log }); + } + stripPostinstall(targetDir); + writeDotJson(targetDir, meta.name ?? domain.replace(/\.dot$/, ""), meta); + }, + }, + { + name: "Run setup.sh", + run: async (log) => { + if (!existsSync(resolve(targetDir, "setup.sh"))) { + throw new StepWarning("No setup.sh found"); + } + await runCommand("bash setup.sh", { cwd: targetDir, log }); + }, + }, + ]; + + const [error, setError] = useState(null); + + return ( + + { + if (result.error) setError(result.error); + onDone(result.ok); + }} + /> + + → {targetDir} + + {error && ( + + {error} + + )} + + ); +} + +class StepWarning extends Error { + isWarning = true; + constructor(message: string) { + super(message); + } +} + +function stripPostinstall(dir: string) { + const pkgPath = resolve(dir, "package.json"); + if (!existsSync(pkgPath)) return; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.scripts?.postinstall) { + delete pkg.scripts.postinstall; + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + } + } catch {} +} + +function writeDotJson(dir: string, name: string, meta: AppMetadata) { + const dotJsonPath = resolve(dir, "dot.json"); + let dotJson: Record = {}; + if (existsSync(dotJsonPath)) { + try { + dotJson = JSON.parse(readFileSync(dotJsonPath, "utf-8")); + } catch {} + } + dotJson.domain = dir; + dotJson.name = name; + if (!dotJson.description && meta.description) dotJson.description = meta.description; + if (!dotJson.tag && meta.tag) dotJson.tag = meta.tag; + writeFileSync(dotJsonPath, JSON.stringify(dotJson, null, 2) + "\n"); +} diff --git a/src/commands/mod/index.ts b/src/commands/mod/index.ts new file mode 100644 index 0000000..c9ec3aa --- /dev/null +++ b/src/commands/mod/index.ts @@ -0,0 +1,109 @@ +import React from "react"; +import { render } from "ink"; +import { Command } from "commander"; +import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import { resolveSigner } from "../../utils/signer.js"; +import { getConnection, destroyConnection } from "../../utils/connection.js"; +import { getRegistryContract } from "../../utils/registry.js"; +import { AppBrowser, type AppEntry } from "./AppBrowser.js"; +import { SetupScreen } from "./SetupScreen.js"; + +export const modCommand = new Command("mod") + .description("Fork a playground app to customize") + .argument("[domain]", "App domain (interactive picker if omitted)") + .option("--suri ", "Signer secret URI (e.g. //Alice for dev)") + .option("--clone", "Clone instead of forking (no GitHub fork created)") + .option("--no-install", "Skip dependency installation") + .action(async (rawDomain: string | undefined, opts) => { + const resolved = await resolveSigner({ suri: opts.suri }); + const client = await getConnection(); + const registry = await getRegistryContract(client.raw.assetHub, resolved); + + try { + // Interactive: browse and pick. Direct: use domain as-is. + let domain: string; + let metadata: AppEntry | null = null; + + if (rawDomain) { + domain = rawDomain.endsWith(".dot") ? rawDomain : `${rawDomain}.dot`; + } else { + const picked = await browseAndPick(registry); + domain = picked.domain; + metadata = picked; + } + + const targetDir = + slugify(domain.replace(/\.dot$/, "")) + "-" + randomBytes(3).toString("hex"); + if (existsSync(targetDir)) { + console.error(` Directory "${targetDir}" already exists.`); + process.exit(1); + } + + const ok = await runSetup({ + domain, + metadata: metadata + ? { + name: metadata.name ?? undefined, + description: metadata.description ?? undefined, + repository: metadata.repository ?? undefined, + } + : null, + registry, + targetDir, + forceClone: !!opts.clone, + }); + + console.log(); + if (ok) { + console.log(" Next steps:"); + console.log(` 1. cd ${targetDir}`); + console.log(" 2. edit with claude"); + console.log(" 3. dot deploy --playground"); + } + } finally { + resolved.destroy(); + destroyConnection(); + } + }); + +function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function browseAndPick(registry: any): Promise { + return new Promise((resolve) => { + const app = render( + React.createElement(AppBrowser, { + registry, + onSelect: (selected: AppEntry) => { + app.unmount(); + resolve(selected); + }, + }), + ); + }); +} + +function runSetup(props: { + domain: string; + metadata: Record | null; + registry: any; + targetDir: string; + forceClone: boolean; +}): Promise { + return new Promise((resolve) => { + const app = render( + React.createElement(SetupScreen, { + ...props, + onDone: (ok: boolean) => { + app.unmount(); + resolve(ok); + }, + }), + ); + }); +} diff --git a/src/index.ts b/src/index.ts index 9bc4811..717a7c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { Command } from "commander"; import pkg from "../package.json" with { type: "json" }; import { initCommand } from "./commands/init/index.js"; -import { modCommand } from "./commands/mod.js"; +import { modCommand } from "./commands/mod/index.js"; import { buildCommand } from "./commands/build.js"; import { deployCommand } from "./commands/deploy.js"; import { updateCommand } from "./commands/update.js"; diff --git a/src/utils/git.test.ts b/src/utils/git.test.ts new file mode 100644 index 0000000..8b727a7 --- /dev/null +++ b/src/utils/git.test.ts @@ -0,0 +1,76 @@ +/** + * Tests for git.ts — focused on sanitize() since it handles tricky + * ANSI/cursor output from child processes (pnpm, cdm, Ink programs). + * + * The exec wrappers (forkAndClone, cloneRepo) are thin and tested + * more effectively via integration. Testing arg construction via + * mocked child_process is brittle and low-value. + */ + +import { describe, it, expect } from "vitest"; + +// sanitize is not exported, so we test it indirectly by importing the module +// and calling a function that uses it. Instead, let's extract the regex and +// test the logic directly. + +// Re-implement the same logic for testing — if the regex in git.ts changes, +// this test must be updated to match (or we export sanitize). +// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI stripping +const ANSI_RE = /\x1B(?:\[[0-9;]*[A-Za-z]|\].*?\x07|[^[])/g; +function sanitize(s: string): string { + return s.replace(ANSI_RE, "").replace(/\r/g, ""); +} + +describe("sanitize", () => { + it("passes through clean text unchanged", () => { + expect(sanitize("hello world")).toBe("hello world"); + }); + + it("strips basic color codes", () => { + expect(sanitize("\x1B[32mgreen\x1B[0m")).toBe("green"); + }); + + it("strips bold/dim/reset sequences", () => { + expect(sanitize("\x1B[1mbold\x1B[22m normal\x1B[0m")).toBe("bold normal"); + }); + + it("strips cursor movement (Ink uses these)", () => { + // [2K = clear line, [1A = move up, [G = move to column 0 + expect(sanitize("\x1B[2K\x1B[1A\x1B[G")).toBe(""); + }); + + it("strips pnpm box-drawing output with embedded ANSI", () => { + const pnpmLine = + "\x1B[33m╭ Warning ──────╮\x1B[0m\r\n\x1B[33m│\x1B[0m text \x1B[33m│\x1B[0m"; + const result = sanitize(pnpmLine); + expect(result).not.toContain("\x1B"); + expect(result).not.toContain("\r"); + expect(result).toContain("Warning"); + expect(result).toContain("text"); + }); + + it("strips OSC sequences (terminal title, etc.)", () => { + expect(sanitize("\x1B]0;my title\x07rest")).toBe("rest"); + }); + + it("removes carriage returns", () => { + expect(sanitize("progress\r50%\r100%\ndone")).toBe("progress50%100%\ndone"); + }); + + it("handles empty string", () => { + expect(sanitize("")).toBe(""); + }); + + it("handles string with only ANSI codes", () => { + expect(sanitize("\x1B[2K\x1B[1A\x1B[0m\r")).toBe(""); + }); + + it("preserves unicode text (box-drawing chars, emojis)", () => { + expect(sanitize("✔ done │ 100%")).toBe("✔ done │ 100%"); + }); + + it("strips compound SGR parameters", () => { + // [38;5;196m = 256-color red + expect(sanitize("\x1B[38;5;196mred\x1B[0m")).toBe("red"); + }); +}); diff --git a/src/utils/git.ts b/src/utils/git.ts new file mode 100644 index 0000000..fc62074 --- /dev/null +++ b/src/utils/git.ts @@ -0,0 +1,100 @@ +/** + * Git and GitHub CLI utilities. + */ + +import { execFile, exec } from "node:child_process"; +import { rmSync } from "node:fs"; +import { resolve } from "node:path"; + +type Log = (line: string) => void; + +// Strip ANSI escape codes, cursor movements, and carriage returns so child +// process output (including Ink programs like cdm) doesn't corrupt our UI. +// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI stripping +const ANSI_RE = /\x1B(?:\[[0-9;]*[A-Za-z]|\].*?\x07|[^[])/g; +function sanitize(s: string): string { + return s.replace(ANSI_RE, "").replace(/\r/g, ""); +} + +/** Env vars that tell child processes to skip interactive/color output. */ +const PLAIN_ENV = { ...process.env, TERM: "dumb", NO_COLOR: "1", CI: "1" }; + +/** Run a command, streaming stdout+stderr to a log callback. */ +function spawn(cmd: string, args: string[], options?: { cwd?: string; log?: Log }): Promise { + return new Promise((resolve, reject) => { + const proc = execFile(cmd, args, { cwd: options?.cwd, env: PLAIN_ENV }); + const stderr: string[] = []; + const forward = (data: Buffer | string) => { + for (const line of sanitize(String(data)).split("\n").filter(Boolean)) { + options?.log?.(line); + } + }; + proc.stdout?.on("data", forward); + proc.stderr?.on("data", (data: Buffer | string) => { + forward(data); + stderr.push(String(data)); + }); + proc.on("close", (code) => { + if (code === 0) resolve(); + else { + const detail = stderr.join("").trim().split("\n").pop() ?? ""; + reject(new Error(detail || `${cmd} failed (exit ${code})`)); + } + }); + }); +} + +/** Check if the GitHub CLI is authenticated. */ +export function isGhAuthenticated(): boolean { + try { + require("node:child_process").execSync("gh auth status", { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +/** Fork a repo on GitHub and clone it locally (SSH). Streams git output to log. */ +export async function forkAndClone( + repo: string, + targetDir: string, + options?: { branch?: string; log?: Log }, +): Promise { + const args = ["repo", "fork", repo, "--clone", "--fork-name", targetDir]; + if (options?.branch) args.push("--", "--branch", options.branch); + await spawn("gh", args, { log: options?.log }); +} + +/** Clone a repo with fresh git history. Streams git output to log. */ +export async function cloneRepo( + repo: string, + targetDir: string, + options?: { branch?: string; log?: Log }, +): Promise { + const args = ["clone"]; + if (options?.branch) args.push("--branch", options.branch); + args.push(repo, targetDir); + await spawn("git", args, { log: options?.log }); + rmSync(resolve(targetDir, ".git"), { recursive: true, force: true }); + options?.log?.("Initializing fresh git history..."); + await spawn("git", ["init"], { cwd: targetDir, log: options?.log }); +} + +/** Run a shell command, streaming output to log. */ +export async function runCommand(cmd: string, options: { cwd?: string; log?: Log }): Promise { + const { cwd, log } = options; + return new Promise((resolve, reject) => { + const proc = exec(cmd, { cwd, env: PLAIN_ENV }); + const forward = (data: Buffer | string) => { + for (const line of sanitize(String(data)).split("\n").filter(Boolean)) { + log?.(line); + } + }; + proc.stdout?.on("data", forward); + proc.stderr?.on("data", forward); + proc.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Command failed (exit ${code})`)); + }); + }); +} diff --git a/src/utils/signer.test.ts b/src/utils/signer.test.ts new file mode 100644 index 0000000..db12ca8 --- /dev/null +++ b/src/utils/signer.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for unified signer resolution. + * + * Mock boundaries: + * - `@polkadot-apps/tx` (createDevSigner, getDevPublicKey) + * - `@polkadot-apps/address` (ss58Encode) + * - `./auth.js` (getSessionSigner) + * + * We exercise the resolution priority (suri → session → error), + * SURI parsing edge cases, and the ResolvedSigner contract. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockCreateDevSigner = vi.fn().mockReturnValue({ __signer: "dev" }); +const mockGetDevPublicKey = vi.fn().mockReturnValue(new Uint8Array(32)); +const mockSs58Encode = vi.fn().mockReturnValue("5GrwvaEF..."); +const mockGetSessionSigner = vi.fn<() => Promise>(); + +vi.mock("@polkadot-apps/tx", () => ({ + createDevSigner: (...args: unknown[]) => mockCreateDevSigner(...args), + getDevPublicKey: (...args: unknown[]) => mockGetDevPublicKey(...args), +})); + +vi.mock("@polkadot-apps/address", () => ({ + ss58Encode: (...args: unknown[]) => mockSs58Encode(...args), +})); + +vi.mock("./auth.js", () => ({ + getSessionSigner: () => mockGetSessionSigner(), +})); + +const { resolveSigner, parseDevAccountName, SignerNotAvailableError } = await import("./signer.js"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ── parseDevAccountName ───────────────────────────────────────────────────── + +describe("parseDevAccountName", () => { + it("parses //Alice → Alice", () => { + expect(parseDevAccountName("//Alice")).toBe("Alice"); + }); + + it("parses //Bob without the prefix", () => { + expect(parseDevAccountName("Bob")).toBe("Bob"); + }); + + it("is case-insensitive", () => { + expect(parseDevAccountName("//alice")).toBe("Alice"); + expect(parseDevAccountName("//FERDIE")).toBe("Ferdie"); + }); + + it("returns null for unknown names", () => { + expect(parseDevAccountName("//Mallory")).toBeNull(); + expect(parseDevAccountName("")).toBeNull(); + }); + + it("recognizes all six dev accounts", () => { + for (const name of ["Alice", "Bob", "Charlie", "Dave", "Eve", "Ferdie"]) { + expect(parseDevAccountName(`//${name}`)).toBe(name); + } + }); +}); + +// ── resolveSigner ─────────────────────────────────────────────────────────── + +describe("resolveSigner", () => { + it("resolves dev signer from --suri //Alice", async () => { + const result = await resolveSigner({ suri: "//Alice" }); + + expect(result.source).toBe("dev"); + expect(result.address).toBe("5GrwvaEF..."); + expect(mockCreateDevSigner).toHaveBeenCalledWith("Alice"); + expect(mockGetDevPublicKey).toHaveBeenCalledWith("Alice"); + }); + + it("dev signer destroy() is a no-op", async () => { + const result = await resolveSigner({ suri: "//Alice" }); + expect(() => result.destroy()).not.toThrow(); + }); + + it("is case-insensitive for SURI names", async () => { + await resolveSigner({ suri: "//bob" }); + expect(mockCreateDevSigner).toHaveBeenCalledWith("Bob"); + }); + + it("throws for unrecognized SURI", async () => { + await expect(resolveSigner({ suri: "//Mallory" })).rejects.toThrow(/Unrecognized SURI/); + }); + + it("lists supported names in error message", async () => { + await expect(resolveSigner({ suri: "//Bad" })).rejects.toThrow(/Alice.*Ferdie/); + }); + + it("falls back to session signer when no SURI", async () => { + const fakeSession = { + address: "5Session...", + signer: { __signer: "session" }, + destroy: vi.fn(), + }; + mockGetSessionSigner.mockResolvedValue(fakeSession); + + const result = await resolveSigner(); + + expect(result.source).toBe("session"); + expect(result.address).toBe("5Session..."); + expect(mockCreateDevSigner).not.toHaveBeenCalled(); + }); + + it("throws SignerNotAvailableError when no SURI and no session", async () => { + mockGetSessionSigner.mockResolvedValue(null); + + await expect(resolveSigner()).rejects.toThrow(SignerNotAvailableError); + await expect(resolveSigner()).rejects.toThrow(/dot init/); + }); + + it("prefers SURI over session even when session exists", async () => { + mockGetSessionSigner.mockResolvedValue({ + address: "5Session...", + signer: {}, + destroy: () => {}, + }); + + const result = await resolveSigner({ suri: "//Alice" }); + + expect(result.source).toBe("dev"); + expect(mockGetSessionSigner).not.toHaveBeenCalled(); + }); + + it("passes session destroy through", async () => { + const destroyFn = vi.fn(); + mockGetSessionSigner.mockResolvedValue({ + address: "5Session...", + signer: {}, + destroy: destroyFn, + }); + + const result = await resolveSigner(); + result.destroy(); + + expect(destroyFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/signer.ts b/src/utils/signer.ts index e6a4584..770f90b 100644 --- a/src/utils/signer.ts +++ b/src/utils/signer.ts @@ -11,8 +11,6 @@ import { createDevSigner, getDevPublicKey, type DevAccountName } from "@polkadot import type { PolkadotSigner } from "polkadot-api"; import { getSessionSigner, type SessionHandle } from "./auth.js"; -// ── Types ──────────────────────────────────────────────────────────────────── - export type SignerSource = "dev" | "session"; export interface ResolvedSigner { diff --git a/src/utils/ui/components/StepRunner.tsx b/src/utils/ui/components/StepRunner.tsx new file mode 100644 index 0000000..76668fc --- /dev/null +++ b/src/utils/ui/components/StepRunner.tsx @@ -0,0 +1,141 @@ +/** + * Reusable step runner — displays a list of sequential steps with + * spinner → ✔/✖/! transitions and a fixed-height log box for output. + * + * Errors are passed to onDone for the parent to display below the UI. + * Warnings (isWarning = true) show inline and don't stop execution. + */ + +import { useState, useEffect } from "react"; +import { Box, Text } from "ink"; +import { Spinner, Done, Failed, Warning } from "./loading.js"; + +export interface Step { + name: string; + run: (log: (line: string) => void) => Promise; +} + +type StepStatus = "pending" | "running" | "ok" | "failed" | "warning"; + +interface StepState { + name: string; + status: StepStatus; + message?: string; +} + +const LOG_LINES = 5; + +function StatusIcon({ status }: { status: StepStatus }) { + // ✔ and ✖ render wide (2 cols). Pad spinner and · to match. + switch (status) { + case "running": + return ( + + {" "} + + ); + case "ok": + return ; + case "failed": + return ; + case "warning": + return ; + default: + return · ; + } +} + +export interface StepRunnerResult { + ok: boolean; + error?: string; +} + +interface Props { + title: string; + steps: Step[]; + onDone: (result: StepRunnerResult) => void; +} + +export function StepRunner({ title, steps, onDone }: Props) { + const [states, setStates] = useState( + steps.map((s) => ({ name: s.name, status: "pending" })), + ); + const [output, setOutput] = useState([]); + + useEffect(() => { + let cancelled = false; + + (async () => { + let error: string | undefined; + + for (let i = 0; i < steps.length; i++) { + if (cancelled) break; + + setStates((prev) => + prev.map((s, j) => (j === i ? { ...s, status: "running" } : s)), + ); + setOutput([]); + + try { + await steps[i].run((line) => { + setOutput((prev) => [...prev.slice(-(LOG_LINES - 1)), line]); + }); + setStates((prev) => prev.map((s, j) => (j === i ? { ...s, status: "ok" } : s))); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const isWarning = err instanceof Error && (err as any).isWarning === true; + + if (isWarning) { + setStates((prev) => + prev.map((s, j) => + j === i ? { ...s, status: "warning", message: msg } : s, + ), + ); + } else { + error = msg; + setStates((prev) => + prev.map((s, j) => (j === i ? { ...s, status: "failed" } : s)), + ); + break; + } + } + } + + if (!cancelled) onDone({ ok: !error, error }); + })(); + + return () => { + cancelled = true; + }; + }, []); + + const running = states.some((s) => s.status === "running"); + + return ( + + + {title} + + + {states.map((step) => ( + + + + {step.name} + {step.message ? — {step.message} : ""} + + + ))} + + {running && output.length > 0 && ( + + {Array.from({ length: LOG_LINES }, (_, i) => ( + + {output[i] ?? " "} + + ))} + + )} + + ); +} diff --git a/src/utils/ui/components/loading.tsx b/src/utils/ui/components/loading.tsx index 7911301..5f4fb07 100644 --- a/src/utils/ui/components/loading.tsx +++ b/src/utils/ui/components/loading.tsx @@ -26,5 +26,7 @@ export function Failed() { } export function Warning() { - return !; + // ✔ & ✖ render with a space to the right + // Add space manually here + return ! ; } diff --git a/src/utils/ui/index.ts b/src/utils/ui/index.ts index e44814a..d8222a5 100644 --- a/src/utils/ui/index.ts +++ b/src/utils/ui/index.ts @@ -1 +1,2 @@ export { Spinner, Done, Failed, Warning } from "./components/loading.js"; +export { StepRunner, type Step, type StepRunnerResult } from "./components/StepRunner.js";