From 440bd12b9ce3e4e877b366ab334d6b23cd4ff1bf Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Sun, 19 Apr 2026 09:04:17 -0400 Subject: [PATCH 1/2] add prompt & flags to select repo name in `dot mod` command --- .changeset/mod-repo-name-prompt.md | 5 + src/commands/mod/SetupScreen.tsx | 8 +- src/commands/mod/index.ts | 162 ++++++++++++++++++++--------- src/commands/mod/repoName.test.ts | 73 +++++++++++++ src/commands/mod/repoName.ts | 38 +++++++ 5 files changed, 230 insertions(+), 56 deletions(-) create mode 100644 .changeset/mod-repo-name-prompt.md create mode 100644 src/commands/mod/repoName.test.ts create mode 100644 src/commands/mod/repoName.ts diff --git a/.changeset/mod-repo-name-prompt.md b/.changeset/mod-repo-name-prompt.md new file mode 100644 index 0000000..c7e19d5 --- /dev/null +++ b/.changeset/mod-repo-name-prompt.md @@ -0,0 +1,5 @@ +--- +"playground-cli": minor +--- + +`dot mod` now prompts for the fork repository name after you pick (or pass) an app, with the previously random-suffixed default prefilled — press Enter to keep it, or type your own. The prompt is skipped with `--clone` (the target is only a local directory anyway), with `-y` / `--yes` (non-interactive default), or when you pass `--repo-name ` (which also doubles as the scripted override). Supplied names are validated against GitHub's repository-name rules and against existing directories on disk. diff --git a/src/commands/mod/SetupScreen.tsx b/src/commands/mod/SetupScreen.tsx index 10f8c58..193f38d 100644 --- a/src/commands/mod/SetupScreen.tsx +++ b/src/commands/mod/SetupScreen.tsx @@ -5,7 +5,7 @@ import { resolve } from "node:path"; import { getGateway, fetchJson } from "@polkadot-apps/bulletin"; import { StepRunner, type Step } from "../../utils/ui/components/StepRunner.js"; import { Header, Hint, Row, Section } from "../../utils/ui/theme/index.js"; -import { isGhAuthenticated, forkAndClone, cloneRepo, runCommand } from "../../utils/git.js"; +import { forkAndClone, cloneRepo, runCommand } from "../../utils/git.js"; import { VERSION_LABEL } from "../../utils/version.js"; interface AppMetadata { @@ -22,7 +22,7 @@ interface Props { metadata: AppMetadata | null; registry: any; targetDir: string; - forceClone: boolean; + canFork: boolean; onDone: (ok: boolean) => void; } @@ -31,11 +31,9 @@ export function SetupScreen({ metadata: initial, registry, targetDir, - forceClone, + canFork, onDone, }: Props) { - const canFork = !forceClone && isGhAuthenticated(); - // Metadata is fetched in step 1 and shared with later steps via this ref let meta: AppMetadata = initial ?? {}; diff --git a/src/commands/mod/index.ts b/src/commands/mod/index.ts index c9ec3aa..3303dd6 100644 --- a/src/commands/mod/index.ts +++ b/src/commands/mod/index.ts @@ -1,13 +1,15 @@ 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 { isGhAuthenticated } from "../../utils/git.js"; +import { Input } from "../../utils/ui/theme/index.js"; import { AppBrowser, type AppEntry } from "./AppBrowser.js"; import { SetupScreen } from "./SetupScreen.js"; +import { defaultRepoName, validateRepoName } from "./repoName.js"; export const modCommand = new Command("mod") .description("Fork a playground app to customize") @@ -15,63 +17,121 @@ export const modCommand = new Command("mod") .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); + .option("-y, --yes", "Skip interactive prompts (use default repo name)") + .option("--repo-name ", "Repository / directory name (skips the prompt)") + .action( + async ( + rawDomain: string | undefined, + opts: { suri?: string; clone?: boolean; yes?: boolean; repoName?: string }, + ) => { + 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; + 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; - } + 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 canFork = !opts.clone && isGhAuthenticated(); + const targetDir = await resolveTargetDir({ + domain, + canFork, + repoName: opts.repoName, + yes: !!opts.yes, + }); + if (!targetDir) return; - 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, - }); + const ok = await runSetup({ + domain, + metadata: metadata + ? { + name: metadata.name ?? undefined, + description: metadata.description ?? undefined, + repository: metadata.repository ?? undefined, + } + : null, + registry, + targetDir, + canFork, + }); - 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"); + 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(); } - } finally { - resolved.destroy(); - destroyConnection(); + }, + ); + +/** + * Decide the fork / local-directory name, honouring (in order): an explicit + * `--repo-name`, a `-y` suppression of the prompt, `--clone` skipping the + * prompt since the name is only a throwaway local dir, and otherwise an + * interactive prompt with the auto-generated name prefilled as the default. + * Returns `null` if a supplied name is invalid — the action logs and bails + * in that case. + */ +async function resolveTargetDir(args: { + domain: string; + canFork: boolean; + repoName: string | undefined; + yes: boolean; +}): Promise { + const fallback = defaultRepoName(args.domain); + + if (args.repoName) { + const err = validateRepoName(args.repoName); + if (err) { + console.error(` ${err}`); + process.exitCode = 1; + return null; } - }); + return args.repoName; + } + + // We only prompt when forking: the clone path produces a throwaway local + // dir, so the random-suffixed default is fine and matches prior behaviour. + if (args.yes || !args.canFork) { + if (existsSync(fallback)) { + console.error(` Directory "${fallback}" already exists.`); + process.exitCode = 1; + return null; + } + return fallback; + } -function slugify(s: string): string { - return s - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); + return promptRepoName(fallback); +} + +function promptRepoName(defaultName: string): Promise { + return new Promise((resolve) => { + const app = render( + React.createElement(Input, { + label: "repository name", + initial: defaultName, + validate: validateRepoName, + onSubmit: (name: string) => { + app.unmount(); + resolve(name); + }, + }), + ); + }); } function browseAndPick(registry: any): Promise { @@ -93,7 +153,7 @@ function runSetup(props: { metadata: Record | null; registry: any; targetDir: string; - forceClone: boolean; + canFork: boolean; }): Promise { return new Promise((resolve) => { const app = render( diff --git a/src/commands/mod/repoName.test.ts b/src/commands/mod/repoName.test.ts new file mode 100644 index 0000000..4e553b6 --- /dev/null +++ b/src/commands/mod/repoName.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defaultRepoName, validateRepoName } from "./repoName.js"; + +describe("defaultRepoName", () => { + it("slugifies and appends a 6-hex-char suffix", () => { + const name = defaultRepoName("My Cool App.dot"); + expect(name).toMatch(/^my-cool-app-[0-9a-f]{6}$/); + }); + + it("strips the .dot suffix", () => { + expect(defaultRepoName("foo.dot")).toMatch(/^foo-[0-9a-f]{6}$/); + }); + + it("handles domains without .dot", () => { + expect(defaultRepoName("bar")).toMatch(/^bar-[0-9a-f]{6}$/); + }); + + it("produces different suffixes on consecutive calls", () => { + const a = defaultRepoName("x.dot"); + const b = defaultRepoName("x.dot"); + expect(a).not.toBe(b); + }); +}); + +describe("validateRepoName", () => { + // Each test runs inside a fresh temp dir so existsSync checks are + // deterministic and isolated from the repo working tree. + let prev: string; + let tmp: string; + beforeEach(() => { + prev = process.cwd(); + tmp = mkdtempSync(join(tmpdir(), "mod-reponame-")); + process.chdir(tmp); + }); + afterEach(() => { + process.chdir(prev); + rmSync(tmp, { recursive: true, force: true }); + }); + + it("accepts a simple name", () => { + expect(validateRepoName("my-app")).toBeNull(); + }); + + it("accepts letters, digits, '.', '-', '_'", () => { + expect(validateRepoName("A.b-c_1")).toBeNull(); + }); + + it("rejects empty", () => { + expect(validateRepoName("")).toMatch(/required/); + }); + + it("rejects spaces and slashes", () => { + expect(validateRepoName("a b")).toMatch(/may only contain/); + expect(validateRepoName("a/b")).toMatch(/may only contain/); + }); + + it("rejects leading '.' or '-'", () => { + expect(validateRepoName(".hidden")).toMatch(/cannot start/); + expect(validateRepoName("-dash")).toMatch(/cannot start/); + }); + + it("rejects names that collide with an existing directory", () => { + mkdirSync("taken"); + expect(validateRepoName("taken")).toMatch(/already exists/); + }); + + it("rejects names over 100 chars", () => { + expect(validateRepoName("a".repeat(101))).toMatch(/too long/); + }); +}); diff --git a/src/commands/mod/repoName.ts b/src/commands/mod/repoName.ts new file mode 100644 index 0000000..088a965 --- /dev/null +++ b/src/commands/mod/repoName.ts @@ -0,0 +1,38 @@ +import { existsSync } from "node:fs"; +import { randomBytes } from "node:crypto"; + +/** + * Build the default target-directory / fork name for `dot mod`: a slugified + * domain with a short random suffix so repeated mods of the same app don't + * collide. The random suffix matches GitHub's own `fork-name` conflict + * handling — nothing explodes if two users happen to race on the same fork. + */ +export function defaultRepoName(domain: string): string { + return slugify(domain.replace(/\.dot$/, "")) + "-" + randomBytes(3).toString("hex"); +} + +function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Validate a name supplied for --repo-name or entered in the repo-name prompt. + * The rules mirror GitHub's repository name constraints (letters, digits, + * `.`, `-`, `_`, not leading with `.` or `-`) and additionally reject names + * that would collide with an existing directory in the current working tree. + * + * Returns an error message, or `null` when the name is usable. + */ +export function validateRepoName(name: string): string | null { + if (!name) return "repository name is required"; + if (name.length > 100) return "repository name is too long (max 100 chars)"; + if (!/^[A-Za-z0-9._-]+$/.test(name)) { + return "repository name may only contain letters, digits, '.', '-', '_'"; + } + if (/^[-.]/.test(name)) return "repository name cannot start with '.' or '-'"; + if (existsSync(name)) return `directory "${name}" already exists`; + return null; +} From 3e01974dc4d0a16e670523ba76acac12f6de78f6 Mon Sep 17 00:00:00 2001 From: Charles Hetterich Date: Sun, 19 Apr 2026 09:21:20 -0400 Subject: [PATCH 2/2] update readme & removed dead flag on `dot mod` --- README.md | 14 ++++++++++++-- src/commands/mod/index.ts | 1 - 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b7127e9..da8e0cd 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,19 @@ Passing all four of `--signer`, `--domain`, `--buildDir`, and `--playground` run The publish step is always signed by the user so the registry contract records their address as the app owner — this is what drives the Playground "my apps" view. -### `dot mod` (stub) +### `dot mod` -Planned. No behaviour yet. +Fork (or clone) a playground app into a local directory so you can customise and re-deploy it. Fetches the app's metadata from the registry, forks the underlying GitHub repo into your account (falling back to a fresh-history clone if `gh` isn't authenticated or `--clone` is passed), runs its `setup.sh`, and prints next steps. + +Flags: + +- `[domain]` — positional; interactive picker over the registry if omitted. `.dot` suffix optional. +- `--clone` — clone instead of forking. Skips the repo-name prompt (the target is only a throwaway local directory). +- `--suri ` — dev signer secret URI (e.g. `//Alice`). +- `-y, --yes` — skip interactive prompts; use the auto-generated default repo name. +- `--repo-name ` — repo / directory name; skips the prompt. Validated against GitHub's repository-name rules (letters, digits, `.`, `-`, `_`, not leading with `.` or `-`) and rejected if the directory already exists. + +When forking, you're prompted for the repo name after picking an app; the default is `-<6 hex chars>` and Enter keeps it. Pass `--repo-name` or `-y` to run non-interactively. ## Contributing diff --git a/src/commands/mod/index.ts b/src/commands/mod/index.ts index 3303dd6..31eb430 100644 --- a/src/commands/mod/index.ts +++ b/src/commands/mod/index.ts @@ -16,7 +16,6 @@ export const modCommand = new Command("mod") .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") .option("-y, --yes", "Skip interactive prompts (use default repo name)") .option("--repo-name ", "Repository / directory name (skips the prompt)") .action(