Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/inline-readme-in-playground-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions src/commands/deploy/DeployScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
resolveSignerSetup,
checkDomainAvailability,
formatAvailability,
readReadme,
README_CAP_BYTES,
type AvailabilityResult,
type DeployEvent,
type DeployOutcome,
Expand Down Expand Up @@ -238,6 +240,7 @@ export function DeployScreen({

{stage.kind === "confirm" && resolved && (
<ConfirmStage
projectDir={projectDir}
inputs={resolved}
userSigner={userSigner}
plan={plan}
Expand Down Expand Up @@ -378,12 +381,14 @@ function ValidateDomainStage({
// ── Confirm stage ────────────────────────────────────────────────────────────

function ConfirmStage({
projectDir,
inputs,
userSigner,
plan,
onProceed,
onCancel,
}: {
projectDir: string;
inputs: Resolved;
userSigner: ResolvedSigner | null;
plan: DeployPlan | null;
Expand All @@ -406,6 +411,15 @@ function ConfirmStage({
}
}, [inputs, userSigner, plan]);

// Only warn on the oversized branch — silent when README is absent or
// within the cap, per the product decision to inline tacitly and speak
// up only when we're dropping content the user expected to ship.
const oversizedReadme = useMemo(() => {
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",
Expand Down Expand Up @@ -452,6 +466,16 @@ function ConfirmStage({
)}
</Section>

{oversizedReadme && (
<Callout tone="warning" title="readme will not be uploaded">
<Text>
README.md is {formatKbCeil(oversizedReadme.size)} — over the{" "}
{README_CAP_BYTES / 1024} KB limit. the rest of the deploy will continue
without it.
</Text>
</Callout>
)}

<Hint>{"enter to deploy · esc to cancel"}</Hint>

{"error" in setup && setup.error && (
Expand Down Expand Up @@ -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`;
}
3 changes: 3 additions & 0 deletions src/utils/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ export {
normalizeDomain,
normalizeGitRemote,
readGitRemote,
readReadme,
README_CAP_BYTES,
type PublishToPlaygroundOptions,
type PublishToPlaygroundResult,
type ReadmeStatus,
} from "./playground.js";
export {
resolveSignerSetup,
Expand Down
157 changes: 147 additions & 10 deletions src/utils/deploy/playground.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand All @@ -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 () => {
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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);
Expand Down
64 changes: 63 additions & 1 deletion src/utils/deploy/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -63,6 +69,54 @@ export interface PublishToPlaygroundResult {
metadata: Record<string, string>;
}

/**
* 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, "");
Expand Down Expand Up @@ -106,6 +160,14 @@ export async function publishToPlayground(
const metadata: Record<string, string> = {};
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…" });
Expand Down
Loading