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/mod-repo-name-prompt.md
Original file line number Diff line number Diff line change
@@ -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 <name>` (which also doubles as the scripted override). Supplied names are validated against GitHub's repository-name rules and against existing directories on disk.
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <suri>` — dev signer secret URI (e.g. `//Alice`).
- `-y, --yes` — skip interactive prompts; use the auto-generated default repo name.
- `--repo-name <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 `<slug>-<6 hex chars>` and Enter keeps it. Pass `--repo-name` or `-y` to run non-interactively.

## Contributing

Expand Down
8 changes: 3 additions & 5 deletions src/commands/mod/SetupScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,7 +22,7 @@ interface Props {
metadata: AppMetadata | null;
registry: any;
targetDir: string;
forceClone: boolean;
canFork: boolean;
onDone: (ok: boolean) => void;
}

Expand All @@ -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 ?? {};

Expand Down
163 changes: 111 additions & 52 deletions src/commands/mod/index.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,136 @@
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")
.argument("[domain]", "App domain (interactive picker if omitted)")
.option("--suri <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 <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<string | null> {
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<string> {
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<AppEntry> {
Expand All @@ -93,7 +152,7 @@ function runSetup(props: {
metadata: Record<string, string | undefined> | null;
registry: any;
targetDir: string;
forceClone: boolean;
canFork: boolean;
}): Promise<boolean> {
return new Promise((resolve) => {
const app = render(
Expand Down
73 changes: 73 additions & 0 deletions src/commands/mod/repoName.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
38 changes: 38 additions & 0 deletions src/commands/mod/repoName.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading