From faae2eda3d8578ffbd751a8956bcfdd09057ac03 Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Mon, 20 Apr 2026 23:25:31 +0100 Subject: [PATCH] feat(deploy): inline README into playground metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dot deploy --playground` now reads README.md from the project root and inlines it into the metadata JSON so published apps render a readme on their playground detail page. Previously only { repository } was written, so every app showed "No readme provided." - Adds readReadme() helper with a 20 KB cap and tagged-union return ({ ok, oversized, missing }). - Uses readdirSync + case-insensitive regex for discovery so casing variations (README.md / readme.md / Readme.md) resolve uniformly on case-sensitive filesystems (Linux CI). - Oversized READMEs are dropped silently from the payload; the confirm stage surfaces a warning Callout with actual vs. allowed size before the user presses Enter. Missing READMEs are a silent no-op. - Exposes readReadme / README_CAP_BYTES / ReadmeStatus on the deploy SDK surface so the TUI layer can prompt without duplicating IO. No contract change needed — the registry already stores a metadata_uri pointer, and the reader side (AppDetailPanel) already renders metadata.readme via marked + DOMPurify. --- .../inline-readme-in-playground-metadata.md | 5 + src/commands/deploy/DeployScreen.tsx | 33 ++++ src/utils/deploy/index.ts | 3 + src/utils/deploy/playground.test.ts | 157 ++++++++++++++++-- src/utils/deploy/playground.ts | 64 ++++++- 5 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 .changeset/inline-readme-in-playground-metadata.md diff --git a/.changeset/inline-readme-in-playground-metadata.md b/.changeset/inline-readme-in-playground-metadata.md new file mode 100644 index 0000000..9fd77a0 --- /dev/null +++ b/.changeset/inline-readme-in-playground-metadata.md @@ -0,0 +1,5 @@ +--- +"playground-cli": minor +--- + +`dot deploy --playground` now inlines the project's `README.md` into the playground metadata so published apps show a rendered readme on their detail page. Readmes up to 20 KB are included automatically; if the file is larger the confirm screen shows a warning ("readme will not be uploaded") and the deploy proceeds without it. No action required — this works for any repo that already has a `README.md` at its root. diff --git a/src/commands/deploy/DeployScreen.tsx b/src/commands/deploy/DeployScreen.tsx index f126570..9b19c8d 100644 --- a/src/commands/deploy/DeployScreen.tsx +++ b/src/commands/deploy/DeployScreen.tsx @@ -17,6 +17,8 @@ import { resolveSignerSetup, checkDomainAvailability, formatAvailability, + readReadme, + README_CAP_BYTES, type AvailabilityResult, type DeployEvent, type DeployOutcome, @@ -238,6 +240,7 @@ export function DeployScreen({ {stage.kind === "confirm" && resolved && ( { + if (!inputs.publishToPlayground) return null; + const status = readReadme(projectDir); + return status.kind === "oversized" ? status : null; + }, [projectDir, inputs.publishToPlayground]); + const view = buildSummaryView({ mode: inputs.mode, domain: inputs.domain.replace(/\.dot$/, "") + ".dot", @@ -452,6 +466,16 @@ function ConfirmStage({ )} + {oversizedReadme && ( + + + README.md is {formatKbCeil(oversizedReadme.size)} — over the{" "} + {README_CAP_BYTES / 1024} KB limit. the rest of the deploy will continue + without it. + + + )} + {"enter to deploy · esc to cancel"} {"error" in setup && setup.error && ( @@ -718,3 +742,12 @@ function average(xs: number[]): number { if (xs.length === 0) return 0; return xs.reduce((a, b) => a + b, 0) / xs.length; } + +// Round UP to the nearest 0.1 KB when displaying an oversized file, so a +// file 1 byte over a round-number cap never reads as "20.0 KB — over the +// 20 KB limit". Worst case we overstate by ~100 bytes, which is fine in a +// warning already saying the file is being dropped. +function formatKbCeil(bytes: number): string { + const tenths = Math.ceil((bytes / 1024) * 10) / 10; + return `${tenths.toFixed(1)} KB`; +} diff --git a/src/utils/deploy/index.ts b/src/utils/deploy/index.ts index d34f9c6..9f1610f 100644 --- a/src/utils/deploy/index.ts +++ b/src/utils/deploy/index.ts @@ -18,8 +18,11 @@ export { normalizeDomain, normalizeGitRemote, readGitRemote, + readReadme, + README_CAP_BYTES, type PublishToPlaygroundOptions, type PublishToPlaygroundResult, + type ReadmeStatus, } from "./playground.js"; export { resolveSignerSetup, diff --git a/src/utils/deploy/playground.test.ts b/src/utils/deploy/playground.test.ts index beb3263..14e477d 100644 --- a/src/utils/deploy/playground.test.ts +++ b/src/utils/deploy/playground.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; // Mock the metadata upload path so we never actually touch the network. // The mock returns a fake CID that publish() treats as the metadata CID. @@ -18,9 +21,17 @@ vi.mock("../registry.js", () => ({ })), })); -import { publishToPlayground, normalizeDomain, normalizeGitRemote } from "./playground.js"; +import { + publishToPlayground, + normalizeDomain, + normalizeGitRemote, + readReadme, + README_CAP_BYTES, +} from "./playground.js"; import type { ResolvedSigner } from "../signer.js"; +const makeTmpDir = () => mkdtempSync(join(tmpdir(), "dot-playground-test-")); + const fakeSigner: ResolvedSigner = { signer: {} as any, address: "5Fake", @@ -50,6 +61,84 @@ describe("normalizeDomain", () => { }); }); +describe("readReadme", () => { + it("returns content when README.md exists and fits under the cap", () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "README.md"), "# My App\n\nHello there."); + const status = readReadme(dir); + expect(status.kind).toBe("ok"); + if (status.kind === "ok") { + expect(status.content).toBe("# My App\n\nHello there."); + expect(status.size).toBe(Buffer.byteLength(status.content, "utf8")); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reports oversized when README.md exceeds the cap", () => { + const dir = makeTmpDir(); + try { + // One byte over the default 20 KB cap. + const bigContent = "x".repeat(README_CAP_BYTES + 1); + writeFileSync(join(dir, "README.md"), bigContent); + const status = readReadme(dir); + expect(status.kind).toBe("oversized"); + if (status.kind === "oversized") { + expect(status.size).toBe(README_CAP_BYTES + 1); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns missing when the file is not present", () => { + const dir = makeTmpDir(); + try { + const status = readReadme(dir); + expect(status.kind).toBe("missing"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("falls back to lowercase readme.md on case-sensitive filesystems", () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "readme.md"), "# lower"); + const status = readReadme(dir); + expect(status.kind).toBe("ok"); + if (status.kind === "ok") expect(status.content).toBe("# lower"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("falls back to title-cased Readme.md", () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "Readme.md"), "# title"); + const status = readReadme(dir); + expect(status.kind).toBe("ok"); + if (status.kind === "ok") expect(status.content).toBe("# title"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("respects a custom cap", () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "README.md"), "abcdefghij"); // 10 bytes + expect(readReadme(dir, 5).kind).toBe("oversized"); + expect(readReadme(dir, 10).kind).toBe("ok"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("normalizeGitRemote", () => { it("converts SSH URLs to HTTPS and strips .git", () => { expect(normalizeGitRemote("git@github.com:paritytech/playground-cli.git\n")).toBe( @@ -69,17 +158,28 @@ describe("normalizeGitRemote", () => { }); describe("publishToPlayground", () => { + // Every test needs a cwd that doesn't accidentally pick up the repo's own + // README.md (the CLI's real README is ~10 KB and would be inlined if we + // defaulted to `process.cwd()`). Each test opts into a tmpdir explicitly. it("uploads metadata JSON and calls registry.publish with the phone signer", async () => { - const result = await publishToPlayground({ - domain: "my-app", - publishSigner: fakeSigner, - repositoryUrl: "https://github.com/paritytech/example", - }); + const dir = makeTmpDir(); + try { + const result = await publishToPlayground({ + domain: "my-app", + publishSigner: fakeSigner, + repositoryUrl: "https://github.com/paritytech/example", + cwd: dir, + }); - expect(result.fullDomain).toBe("my-app.dot"); - expect(result.metadata).toEqual({ repository: "https://github.com/paritytech/example" }); - expect(result.metadataCid).toBe("bafymeta"); - expect(publishTx).toHaveBeenCalledWith("my-app.dot", "bafymeta"); + expect(result.fullDomain).toBe("my-app.dot"); + expect(result.metadata).toEqual({ + repository: "https://github.com/paritytech/example", + }); + expect(result.metadataCid).toBe("bafymeta"); + expect(publishTx).toHaveBeenCalledWith("my-app.dot", "bafymeta"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); it("omits the repository field when no git remote is available", async () => { @@ -93,6 +193,41 @@ describe("publishToPlayground", () => { expect(result.metadata).toEqual({}); }); + it("inlines README.md when it is present and within the cap", async () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "README.md"), "# Hello\n\nA short readme."); + const result = await publishToPlayground({ + domain: "readme-app", + publishSigner: fakeSigner, + repositoryUrl: "https://example.com/r", + cwd: dir, + }); + expect(result.metadata).toEqual({ + repository: "https://example.com/r", + readme: "# Hello\n\nA short readme.", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("omits readme when README.md exceeds the cap", async () => { + const dir = makeTmpDir(); + try { + writeFileSync(join(dir, "README.md"), "x".repeat(README_CAP_BYTES + 1)); + const result = await publishToPlayground({ + domain: "big-readme", + publishSigner: fakeSigner, + repositoryUrl: "https://example.com/r", + cwd: dir, + }); + expect(result.metadata).toEqual({ repository: "https://example.com/r" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("retries up to 3 times on registry publish failure", async () => { publishTx.mockImplementationOnce(async () => { throw new Error("nonce race"); @@ -106,6 +241,7 @@ describe("publishToPlayground", () => { domain: "flaky", publishSigner: fakeSigner, repositoryUrl: "https://example.com/x", + cwd: "/definitely/not/a/repo", }); expect(publishTx).toHaveBeenCalledTimes(3); expect(result.fullDomain).toBe("flaky.dot"); @@ -121,6 +257,7 @@ describe("publishToPlayground", () => { domain: "doomed", publishSigner: fakeSigner, repositoryUrl: "https://example.com/x", + cwd: "/definitely/not/a/repo", }), ).rejects.toThrow(/unauthorized/); }, 30_000); diff --git a/src/utils/deploy/playground.ts b/src/utils/deploy/playground.ts index 3c3f7ab..a9cb3a3 100644 --- a/src/utils/deploy/playground.ts +++ b/src/utils/deploy/playground.ts @@ -16,6 +16,8 @@ */ import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; import { createClient } from "polkadot-api"; import { getWsProvider } from "polkadot-api/ws-provider/web"; import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat"; @@ -46,7 +48,11 @@ export interface PublishToPlaygroundOptions { publishSigner: ResolvedSigner; /** Explicit repository URL. If omitted we probe `git remote get-url origin`. */ repositoryUrl?: string; - /** Working dir used to probe the git remote when `repositoryUrl` is absent. */ + /** + * Project root. Used both to probe the git remote (when `repositoryUrl` + * is absent) and to look for a `README.md` to inline into the metadata. + * If omitted, both probes are skipped. + */ cwd?: string; /** Progress sink for the metadata-upload sub-step. */ onLogEvent?: (event: DeployLogEvent) => void; @@ -63,6 +69,54 @@ export interface PublishToPlaygroundResult { metadata: Record; } +/** + * Cap on inlined README bytes. The metadata JSON is fetched once per listed + * app in the playground feed; an unbounded README from any single publisher + * would bloat every other user's feed load. 20 KB comfortably covers typical + * repo READMEs and keeps a 20-app feed batch under ~400 KB. + */ +export const README_CAP_BYTES = 20 * 1024; + +export type ReadmeStatus = + | { kind: "ok"; content: string; size: number } + | { kind: "oversized"; size: number } + | { kind: "missing" }; + +/** + * Look for a README at the project root. Returns a tagged union so callers + * can both decide whether to inline and surface an oversize warning before + * the user commits to deploy. + * + * We enumerate the directory rather than trying `README.md` verbatim so the + * match is case-insensitive on every filesystem — macOS/Windows resolve + * mismatched case implicitly, but Linux CI does not, and a repo with + * `readme.md` on GitHub would otherwise be silently skipped. + */ +export function readReadme(cwd: string, capBytes = README_CAP_BYTES): ReadmeStatus { + let entries: string[]; + try { + entries = readdirSync(cwd); + } catch { + return { kind: "missing" }; + } + const match = entries.find((name) => /^readme\.md$/i.test(name)); + if (!match) return { kind: "missing" }; + const path = join(cwd, match); + let size: number; + try { + size = statSync(path).size; + } catch { + return { kind: "missing" }; + } + if (size > capBytes) return { kind: "oversized", size }; + try { + const content = readFileSync(path, "utf8"); + return { kind: "ok", content, size }; + } catch { + return { kind: "missing" }; + } +} + /** Strip `.dot` suffix if present so we can normalize to a canonical `label.dot`. */ export function normalizeDomain(domain: string): { label: string; fullDomain: string } { const label = domain.replace(/\.dot$/i, ""); @@ -106,6 +160,14 @@ export async function publishToPlayground( const metadata: Record = {}; if (repoUrl) metadata.repository = repoUrl; + // Inline README.md from the project root when present and within the cap. + // Oversized READMEs are deliberately dropped — the UI surfaces a warning + // in the confirm stage so the user can bail before we get this far. + if (options.cwd) { + const readme = readReadme(options.cwd); + if (readme.kind === "ok") metadata.readme = readme.content; + } + const metadataBytes = new Uint8Array(Buffer.from(JSON.stringify(metadata), "utf8")); options.onLogEvent?.({ kind: "info", message: "Uploading playground metadata to Bulletin…" });