From 2899393a3e2487c0b4c81bd966540b6ed65ef670 Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Tue, 19 May 2026 21:37:29 +0900 Subject: [PATCH 1/6] initial ideas --- src/commands/decentralize/index.ts | 230 +++++++++++++++++++++++++++ src/index.ts | 2 + src/telemetry-config.ts | 10 +- src/utils/decentralize/mirror.ts | 149 +++++++++++++++++ src/utils/decentralize/randomName.ts | 81 ++++++++++ 5 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 src/commands/decentralize/index.ts create mode 100644 src/utils/decentralize/mirror.ts create mode 100644 src/utils/decentralize/randomName.ts diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts new file mode 100644 index 0000000..9d0eacf --- /dev/null +++ b/src/commands/decentralize/index.ts @@ -0,0 +1,230 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * `dot decentralize` — point at a live static site, get back a .dot URL. + * + * dot decentralize --site=shawntabrizi.github.io --dot=shawntabrizi.dot + * + * Zero-account by default: we sign with //Bob on paseo-next-v2 so a first-time + * user can see end-to-end value with no wallet setup. The migration story is + * surfaced in the success footer — re-deploy from `dot deploy` with their own + * --suri or QR session and the same domain check at availability.ts:261 will + * recognise the "owned by you" path and update in place. + * + * Site cloning is `wget --mirror` (see utils/decentralize/mirror.ts). The + * upload + DotNS register flow re-uses `runStorageDeploy` exactly like + * `dot deploy`, so any improvements to the underlying primitives flow into + * this command for free. + */ + +import { Command } from "commander"; +import { rmSync } from "node:fs"; +import { runCliCommand } from "../../cli-runtime.js"; +import { errorMessage, withSpan } from "../../telemetry.js"; +import { + DEFAULT_ENV, + type Env, + getChainConfig, + resolveLegacyEnv, +} from "../../config.js"; +import { resolveSigner, type ResolvedSigner } from "../../utils/signer.js"; +import { runStorageDeploy } from "../../utils/deploy/storage.js"; +import { + checkDomainAvailability, + formatAvailability, +} from "../../utils/deploy/availability.js"; +import { normalizeDomain } from "../../utils/deploy/playground.js"; +import { mirrorSite } from "../../utils/decentralize/mirror.js"; +import { findAvailableRandomName } from "../../utils/decentralize/randomName.js"; + +interface DecentralizeOpts { + site: string; + dot?: string; + env: string; + suri?: string; +} + +/** + * //Bob is a hard-coded dev SURI. It only has funds / authorisation on the + * paseo-next-v2 testnet — using it on mainnet would silently fail at the + * funding step. Refusing here gives a friendly error instead. + */ +const DEFAULT_SURI = "//Bob"; + +export const decentralizeCommand = new Command("decentralize") + .description( + "Mirror a live static site to Polkadot Bulletin and register a .dot name pointing at it", + ) + .requiredOption("--site ", "URL of the static site to clone (http/https)") + .option( + "--dot ", + "DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.", + ) + .option( + "--env ", + "Target environment (default: paseo-next-v2)", + DEFAULT_ENV, + ) + .option( + "--suri ", + "Sign with this SURI instead of the default //Bob test account", + ) + .action(async (opts: DecentralizeOpts) => + runCliCommand("decentralize", { hardExit: true }, async () => { + const env: Env = resolveLegacyEnv(opts.env); + const usingDefaultBob = !opts.suri; + + if (usingDefaultBob && getChainConfig(env).network !== "testnet") { + throw new Error( + `--env ${env} is a non-testnet network; //Bob has no funds there. ` + + `Pass --suri or use --env paseo-next-v2.`, + ); + } + + let signer: ResolvedSigner | null = null; + let mirrorDir: string | null = null; + + try { + signer = await withSpan( + "cli.decentralize.signer", + "resolve signer", + () => resolveSigner({ suri: opts.suri ?? DEFAULT_SURI }), + ); + + process.stdout.write( + `\n▸ Signing as ${signer.address} (${signer.source})\n`, + ); + + // ── 1. Pick a domain ──────────────────────────────────────── + let label: string; + let fullDomain: string; + if (opts.dot) { + const normalized = normalizeDomain(opts.dot); + label = normalized.label; + fullDomain = normalized.fullDomain; + + process.stdout.write(`\n▸ Checking ${fullDomain}…\n`); + const availability = await withSpan( + "cli.decentralize.availability", + "check domain availability", + () => + checkDomainAvailability(label, { + env, + ownerSs58Address: signer?.address, + }), + ); + if ( + availability.status === "reserved" || + availability.status === "taken" + ) { + throw new Error(formatAvailability(availability)); + } + if (availability.status === "unknown") { + process.stderr.write( + `\n⚠ ${formatAvailability(availability)} — continuing anyway.\n`, + ); + } + } else { + process.stdout.write( + `\n▸ Picking a free random .dot name…\n`, + ); + const chosen = await withSpan( + "cli.decentralize.random-name", + "find available random name", + () => + findAvailableRandomName({ + env, + ownerSs58Address: signer?.address, + }), + ); + label = chosen.label; + fullDomain = chosen.availability.fullDomain; + process.stdout.write(` → ${fullDomain}\n`); + } + + // ── 2. Mirror the site ────────────────────────────────────── + process.stdout.write(`\n▸ Mirroring ${opts.site}…\n`); + const mirror = await withSpan( + "cli.decentralize.mirror", + "mirror site", + () => + mirrorSite({ + url: opts.site, + onLine: (line) => + process.stdout.write(` ${line}\n`), + }), + ); + mirrorDir = mirror.directory; + process.stdout.write( + ` → ${mirror.fileCount} files in ${mirror.directory}\n`, + ); + + // ── 3. Upload to Bulletin + register DotNS ────────────────── + process.stdout.write( + `\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`, + ); + const result = await withSpan( + "cli.decentralize.storage", + "bulletin upload + dotns register", + () => + runStorageDeploy({ + content: mirror.directory, + domainName: label, + auth: { + signer: signer?.signer, + signerAddress: signer?.address, + }, + env, + onLogEvent: (event) => + process.stdout.write(` • ${event.kind}\n`), + }), + ); + + // ── 4. Print success ──────────────────────────────────────── + const cfg = getChainConfig(env); + const appUrl = `https://${fullDomain}.li`; + const gatewayUrl = `${cfg.bulletinGateway}${result.cid}`; + process.stdout.write( + "\n✔ Decentralized!\n" + + ` Site ${appUrl}\n` + + ` IPFS CID ${result.cid}\n` + + ` Gateway ${gatewayUrl}\n`, + ); + if (usingDefaultBob) { + process.stdout.write( + "\n Owned by //Bob (testnet demo). To claim a name under your own\n" + + " account, run `dot init` to pair a wallet, then re-deploy with\n" + + " `dot deploy --domain .dot` from a project of your own.\n", + ); + } + process.stdout.write("\n"); + } catch (err) { + process.stderr.write(`\n✖ ${errorMessage(err)}\n`); + process.exitCode = 1; + throw err; + } finally { + signer?.destroy(); + if (mirrorDir) { + try { + rmSync(mirrorDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup; tmpdir is OS-managed anyway + } + } + } + }), + ); + diff --git a/src/index.ts b/src/index.ts index 012b0e6..d908e2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { initCommand } from "./commands/init/index.js"; import { modCommand } from "./commands/mod/index.js"; import { buildCommand } from "./commands/build.js"; import { contractCommand } from "./commands/contract.js"; +import { decentralizeCommand } from "./commands/decentralize/index.js"; import { logoutCommand } from "./commands/logout/index.js"; import { updateCommand } from "./commands/update.js"; import { captureWarning, closeTelemetry, flushTelemetry, initTelemetry } from "./telemetry.js"; @@ -129,6 +130,7 @@ program.addCommand(modCommand); program.addCommand(buildCommand); program.addCommand(contractCommand); program.addCommand(await createDeployCommand()); +program.addCommand(decentralizeCommand); program.addCommand(logoutCommand); program.addCommand(updateCommand); diff --git a/src/telemetry-config.ts b/src/telemetry-config.ts index c1c126a..fd00478 100644 --- a/src/telemetry-config.ts +++ b/src/telemetry-config.ts @@ -46,7 +46,15 @@ export interface InternalContextSignals { branch?: string; } -export type CliCommandName = "init" | "deploy" | "contract" | "mod" | "build" | "update" | "logout"; +export type CliCommandName = + | "init" + | "deploy" + | "contract" + | "mod" + | "build" + | "update" + | "logout" + | "decentralize"; export type TelemetryAttribute = string | number | boolean | undefined; type EnvLike = Record; diff --git a/src/utils/decentralize/mirror.ts b/src/utils/decentralize/mirror.ts new file mode 100644 index 0000000..323806d --- /dev/null +++ b/src/utils/decentralize/mirror.ts @@ -0,0 +1,149 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { spawn } from "node:child_process"; +import { mkdtempSync, readdirSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export interface MirrorOptions { + /** http(s) URL to mirror. Other schemes are rejected. */ + url: string; + /** Optional callback for streaming wget output, one line at a time. */ + onLine?: (line: string) => void; +} + +export interface MirrorResult { + /** Absolute path to the temp directory containing the mirrored site. */ + directory: string; + /** Number of files written under `directory`. */ + fileCount: number; +} + +export class WgetMissingError extends Error { + constructor() { + super( + "wget is required to mirror sites but was not found on PATH. " + + "Install it via `brew install wget` (macOS) or your package manager.", + ); + this.name = "WgetMissingError"; + } +} + +export class InvalidSiteUrlError extends Error { + constructor(url: string, reason: string) { + super(`Invalid --site URL "${url}": ${reason}`); + this.name = "InvalidSiteUrlError"; + } +} + +function validateUrl(input: string): string { + let parsed: URL; + try { + parsed = new URL(input); + } catch { + // Allow shorthand like "shawntabrizi.github.io" by adding https://. + try { + parsed = new URL(`https://${input}`); + } catch { + throw new InvalidSiteUrlError(input, "not a parseable URL"); + } + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new InvalidSiteUrlError(input, `unsupported scheme ${parsed.protocol}`); + } + return parsed.toString(); +} + +function countFiles(root: string): number { + let count = 0; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) walk(full); + else if (st.isFile()) count++; + } + }; + try { + walk(root); + } catch { + // ignore — caller validates non-empty via the returned count. + } + return count; +} + +/** + * Mirror a live HTTP(S) static site into a fresh temp directory using `wget`. + * + * Flags chosen for "useful default for static sites": + * --mirror recursive download + timestamping + infinite depth + * --convert-links rewrite absolute → relative so the local copy renders + * --adjust-extension add .html so links resolve from a flat filesystem + * --page-requisites pull CSS/JS/images that pages reference + * --no-parent don't climb above the URL's directory + * --no-host-directories drop the hostname segment from the output path + * --no-verbose one progress line per file; not silent so onLine works + * + * URL safety: passed as a separate execve argument, never spliced into a shell + * string, so a malicious URL cannot inject other flags or shell metacharacters. + */ +export async function mirrorSite(options: MirrorOptions): Promise { + const url = validateUrl(options.url); + const directory = mkdtempSync(join(tmpdir(), "dot-decentralize-")); + + const args = [ + "--mirror", + "--convert-links", + "--adjust-extension", + "--page-requisites", + "--no-parent", + "--no-host-directories", + "--no-verbose", + `--directory-prefix=${directory}`, + url, + ]; + + await new Promise((resolve, reject) => { + const proc = spawn("wget", args, { stdio: ["ignore", "pipe", "pipe"] }); + + proc.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") reject(new WgetMissingError()); + else reject(err); + }); + + const forward = (chunk: Buffer) => { + if (!options.onLine) return; + for (const line of chunk.toString("utf8").split("\n")) { + if (line.trim()) options.onLine(line); + } + }; + proc.stdout?.on("data", forward); + proc.stderr?.on("data", forward); + + proc.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`wget failed (exit ${code}) — site may be unreachable`)); + }); + }); + + const fileCount = countFiles(directory); + if (fileCount === 0) { + throw new Error( + `wget completed but no files were downloaded from ${url}. The site may be empty or block crawlers.`, + ); + } + return { directory, fileCount }; +} diff --git a/src/utils/decentralize/randomName.ts b/src/utils/decentralize/randomName.ts new file mode 100644 index 0000000..1fccd52 --- /dev/null +++ b/src/utils/decentralize/randomName.ts @@ -0,0 +1,81 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { randomBytes } from "node:crypto"; +import { + checkDomainAvailability, + type AvailabilityResult, +} from "../deploy/availability.js"; +import type { Env } from "../../config.js"; + +/** + * Random label prefix for the "free / not-great domain" tier. The base length + * (8 chars before the trailing digits) keeps us in DotNS's NoStatus bucket + * (`baseLength >= 9` after 2 trailing digits → no PoP required) so //Bob can + * self-register without a personhood credential. See `availability.ts`'s + * `classifyLabel` for the rule. + */ +const PREFIX = "decent-"; + +function randomLabel(): string { + // 6 random base36 chars + 2 trailing digits = "decent-xxxxxx12". + // Trailing digits keep us inside the "Available to all" classifier branch + // for short-ish names without needing PoP. + const suffix = randomBytes(4).toString("hex").slice(0, 6); + const digits = String(randomBytes(1)[0] % 90 + 10); // always 2 digits + return `${PREFIX}${suffix}${digits}`; +} + +export interface FindAvailableNameOptions { + env?: Env; + ownerSs58Address?: string; + /** Cap on attempts; defaults to 20. */ + maxAttempts?: number; +} + +/** + * Generate `decent-NN` candidates until one is `available` according to + * `checkDomainAvailability`. Returns the chosen label and the matching + * availability result so callers can reuse the embedded `DeployPlan`. + * + * Bails after `maxAttempts` with a descriptive error — collisions in this + * keyspace would indicate either a broken RNG or an attacker pre-claiming the + * keyspace, both of which are worth surfacing rather than looping forever. + */ +export async function findAvailableRandomName( + options: FindAvailableNameOptions = {}, +): Promise<{ label: string; availability: Extract }> { + const maxAttempts = options.maxAttempts ?? 20; + let lastFailure: AvailabilityResult | null = null; + + for (let i = 0; i < maxAttempts; i++) { + const candidate = randomLabel(); + const result = await checkDomainAvailability(candidate, { + env: options.env, + ownerSs58Address: options.ownerSs58Address, + }); + if (result.status === "available") { + return { label: candidate, availability: result }; + } + lastFailure = result; + } + + const reason = lastFailure + ? `last attempt was "${lastFailure.fullDomain}" (${lastFailure.status})` + : "no availability response"; + throw new Error( + `Could not find an available random domain after ${maxAttempts} attempts — ${reason}`, + ); +} From bd4c6aa9fc1c5d17eb48356b6de47bb70e82d2fd Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Tue, 19 May 2026 22:17:29 +0900 Subject: [PATCH 2/6] fixes --- src/commands/decentralize/index.ts | 68 +++++++--------------------- src/utils/decentralize/randomName.ts | 7 +-- 2 files changed, 19 insertions(+), 56 deletions(-) diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index 9d0eacf..32b25f3 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -34,18 +34,10 @@ import { Command } from "commander"; import { rmSync } from "node:fs"; import { runCliCommand } from "../../cli-runtime.js"; import { errorMessage, withSpan } from "../../telemetry.js"; -import { - DEFAULT_ENV, - type Env, - getChainConfig, - resolveLegacyEnv, -} from "../../config.js"; +import { DEFAULT_ENV, type Env, getChainConfig, resolveLegacyEnv } from "../../config.js"; import { resolveSigner, type ResolvedSigner } from "../../utils/signer.js"; import { runStorageDeploy } from "../../utils/deploy/storage.js"; -import { - checkDomainAvailability, - formatAvailability, -} from "../../utils/deploy/availability.js"; +import { checkDomainAvailability, formatAvailability } from "../../utils/deploy/availability.js"; import { normalizeDomain } from "../../utils/deploy/playground.js"; import { mirrorSite } from "../../utils/decentralize/mirror.js"; import { findAvailableRandomName } from "../../utils/decentralize/randomName.js"; @@ -73,15 +65,8 @@ export const decentralizeCommand = new Command("decentralize") "--dot ", "DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.", ) - .option( - "--env ", - "Target environment (default: paseo-next-v2)", - DEFAULT_ENV, - ) - .option( - "--suri ", - "Sign with this SURI instead of the default //Bob test account", - ) + .option("--env ", "Target environment (default: paseo-next-v2)", DEFAULT_ENV) + .option("--suri ", "Sign with this SURI instead of the default //Bob test account") .action(async (opts: DecentralizeOpts) => runCliCommand("decentralize", { hardExit: true }, async () => { const env: Env = resolveLegacyEnv(opts.env); @@ -98,15 +83,11 @@ export const decentralizeCommand = new Command("decentralize") let mirrorDir: string | null = null; try { - signer = await withSpan( - "cli.decentralize.signer", - "resolve signer", - () => resolveSigner({ suri: opts.suri ?? DEFAULT_SURI }), + signer = await withSpan("cli.decentralize.signer", "resolve signer", () => + resolveSigner({ suri: opts.suri ?? DEFAULT_SURI }), ); - process.stdout.write( - `\n▸ Signing as ${signer.address} (${signer.source})\n`, - ); + process.stdout.write(`\n▸ Signing as ${signer.address} (${signer.source})\n`); // ── 1. Pick a domain ──────────────────────────────────────── let label: string; @@ -126,10 +107,7 @@ export const decentralizeCommand = new Command("decentralize") ownerSs58Address: signer?.address, }), ); - if ( - availability.status === "reserved" || - availability.status === "taken" - ) { + if (availability.status === "reserved" || availability.status === "taken") { throw new Error(formatAvailability(availability)); } if (availability.status === "unknown") { @@ -138,9 +116,7 @@ export const decentralizeCommand = new Command("decentralize") ); } } else { - process.stdout.write( - `\n▸ Picking a free random .dot name…\n`, - ); + process.stdout.write(`\n▸ Picking a free random .dot name…\n`); const chosen = await withSpan( "cli.decentralize.random-name", "find available random name", @@ -157,25 +133,17 @@ export const decentralizeCommand = new Command("decentralize") // ── 2. Mirror the site ────────────────────────────────────── process.stdout.write(`\n▸ Mirroring ${opts.site}…\n`); - const mirror = await withSpan( - "cli.decentralize.mirror", - "mirror site", - () => - mirrorSite({ - url: opts.site, - onLine: (line) => - process.stdout.write(` ${line}\n`), - }), + const mirror = await withSpan("cli.decentralize.mirror", "mirror site", () => + mirrorSite({ + url: opts.site, + onLine: (line) => process.stdout.write(` ${line}\n`), + }), ); mirrorDir = mirror.directory; - process.stdout.write( - ` → ${mirror.fileCount} files in ${mirror.directory}\n`, - ); + process.stdout.write(` → ${mirror.fileCount} files in ${mirror.directory}\n`); // ── 3. Upload to Bulletin + register DotNS ────────────────── - process.stdout.write( - `\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`, - ); + process.stdout.write(`\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`); const result = await withSpan( "cli.decentralize.storage", "bulletin upload + dotns register", @@ -188,8 +156,7 @@ export const decentralizeCommand = new Command("decentralize") signerAddress: signer?.address, }, env, - onLogEvent: (event) => - process.stdout.write(` • ${event.kind}\n`), + onLogEvent: (event) => process.stdout.write(` • ${event.kind}\n`), }), ); @@ -227,4 +194,3 @@ export const decentralizeCommand = new Command("decentralize") } }), ); - diff --git a/src/utils/decentralize/randomName.ts b/src/utils/decentralize/randomName.ts index 1fccd52..80e7451 100644 --- a/src/utils/decentralize/randomName.ts +++ b/src/utils/decentralize/randomName.ts @@ -14,10 +14,7 @@ // limitations under the License. import { randomBytes } from "node:crypto"; -import { - checkDomainAvailability, - type AvailabilityResult, -} from "../deploy/availability.js"; +import { checkDomainAvailability, type AvailabilityResult } from "../deploy/availability.js"; import type { Env } from "../../config.js"; /** @@ -34,7 +31,7 @@ function randomLabel(): string { // Trailing digits keep us inside the "Available to all" classifier branch // for short-ish names without needing PoP. const suffix = randomBytes(4).toString("hex").slice(0, 6); - const digits = String(randomBytes(1)[0] % 90 + 10); // always 2 digits + const digits = String((randomBytes(1)[0] % 90) + 10); // always 2 digits return `${PREFIX}${suffix}${digits}`; } From f0000c6e2ce2801c2c0c499f68138623d331abd0 Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Tue, 19 May 2026 22:22:38 +0900 Subject: [PATCH 3/6] Update toolchain.ts --- src/utils/toolchain.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/utils/toolchain.ts b/src/utils/toolchain.ts index 973d027..6d44f2d 100644 --- a/src/utils/toolchain.ts +++ b/src/utils/toolchain.ts @@ -172,4 +172,22 @@ export const TOOL_STEPS: ToolStep[] = [ }, manualHint: "https://git-scm.com/downloads", }, + { + // Required by `dot decentralize` (mirrors a live site via `wget --mirror`). + // macOS doesn't ship wget by default; Linux distros vary. + name: "wget", + check: () => commandExists("wget"), + install: async (onData) => { + if (platform() === "darwin" && (await commandExists("brew"))) { + await runPiped("brew install wget", onData); + } else if (platform() === "linux") { + await runPiped(`${sudo()}apt update && ${sudo()}apt install -y wget`, onData); + } else { + throw new Error( + "Cannot install wget automatically on this platform — install manually.", + ); + } + }, + manualHint: "brew install wget (macOS) or your distro's package manager", + }, ]; From ced529724f6be1b152bf82228ad64d4d6973de7b Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Tue, 19 May 2026 23:32:08 +0900 Subject: [PATCH 4/6] Update index.ts --- src/commands/decentralize/index.ts | 39 ++++++++++-------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index 32b25f3..cc80943 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -18,11 +18,10 @@ * * dot decentralize --site=shawntabrizi.github.io --dot=shawntabrizi.dot * - * Zero-account by default: we sign with //Bob on paseo-next-v2 so a first-time - * user can see end-to-end value with no wallet setup. The migration story is - * surfaced in the success footer — re-deploy from `dot deploy` with their own - * --suri or QR session and the same domain check at availability.ts:261 will - * recognise the "owned by you" path and update in place. + * Default signer is the `dot init` session signer — the user owns the + * registered .dot name. Pass `--suri //Bob` (or any dev name / mnemonic) for + * an explicit signer; that's what the demo service at dot-decentralize uses + * to give first-time visitors a zero-setup path on paseo-next-v2. * * Site cloning is `wget --mirror` (see utils/decentralize/mirror.ts). The * upload + DotNS register flow re-uses `runStorageDeploy` exactly like @@ -49,13 +48,6 @@ interface DecentralizeOpts { suri?: string; } -/** - * //Bob is a hard-coded dev SURI. It only has funds / authorisation on the - * paseo-next-v2 testnet — using it on mainnet would silently fail at the - * funding step. Refusing here gives a friendly error instead. - */ -const DEFAULT_SURI = "//Bob"; - export const decentralizeCommand = new Command("decentralize") .description( "Mirror a live static site to Polkadot Bulletin and register a .dot name pointing at it", @@ -66,25 +58,21 @@ export const decentralizeCommand = new Command("decentralize") "DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.", ) .option("--env ", "Target environment (default: paseo-next-v2)", DEFAULT_ENV) - .option("--suri ", "Sign with this SURI instead of the default //Bob test account") + .option( + "--suri ", + "Sign with this SURI (dev name like //Bob, or a BIP-39 mnemonic). " + + "Default: the session signer paired by `dot init`.", + ) .action(async (opts: DecentralizeOpts) => runCliCommand("decentralize", { hardExit: true }, async () => { const env: Env = resolveLegacyEnv(opts.env); - const usingDefaultBob = !opts.suri; - - if (usingDefaultBob && getChainConfig(env).network !== "testnet") { - throw new Error( - `--env ${env} is a non-testnet network; //Bob has no funds there. ` + - `Pass --suri or use --env paseo-next-v2.`, - ); - } let signer: ResolvedSigner | null = null; let mirrorDir: string | null = null; try { signer = await withSpan("cli.decentralize.signer", "resolve signer", () => - resolveSigner({ suri: opts.suri ?? DEFAULT_SURI }), + resolveSigner({ suri: opts.suri }), ); process.stdout.write(`\n▸ Signing as ${signer.address} (${signer.source})\n`); @@ -170,11 +158,10 @@ export const decentralizeCommand = new Command("decentralize") ` IPFS CID ${result.cid}\n` + ` Gateway ${gatewayUrl}\n`, ); - if (usingDefaultBob) { + if (signer.source === "dev") { process.stdout.write( - "\n Owned by //Bob (testnet demo). To claim a name under your own\n" + - " account, run `dot init` to pair a wallet, then re-deploy with\n" + - " `dot deploy --domain .dot` from a project of your own.\n", + "\n Owned by a development account (testnet demo). To claim a name\n" + + " under your own account, run `dot init` and re-deploy without --suri.\n", ); } process.stdout.write("\n"); From 62f176ea4c592ee699db992b9a272b2e77085f2d Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Wed, 20 May 2026 00:09:14 +0900 Subject: [PATCH 5/6] feat(decentralize): derive auto names from site URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, `dot decentralize --site=example.com` picked a random `decent-ffe0ab72.dot`. The label was unrecognisable in the resulting `.dot.li` URL and gave the user no clue which site they were mirroring. After, the auto-name starts with a dumb transliteration of the URL's hostname (lowercase, dots → hyphens, sanitised to `[a-z0-9-]`, capped at 30 chars), followed by a 4-letter random tail + 2 digits. No TLD or `www.` stripping — that requires the Public Suffix List, which we don't want as a dep; users who want a clean name pass `--dot` explicitly. Falls back to the legacy `decent-` shape for unparseable inputs. example.com → example-com-uslj17.dot shawntabrizi.com → shawntabrizi-com-byhq57.dot shawntabrizi.github.io → shawntabrizi-github-io-NN.dot Also fixes a latent classifier bug. The previous hex-based suffix produced labels with >2 trailing digits ~62% of the time (whenever the random hex happened to end in a digit), classified as RESERVED and silently masked by the 20-attempt retry loop. The new generator uses lowercase letters only in the variable middle so the trailing- digit invariant holds for every call. New test file covers the invariants across 200 iterations: exactly 2 trailing digits, base length ≥9 (NoStatus), normalizeDomain regex compatibility, plus the hostname-incorporation cases. --- src/commands/decentralize/index.ts | 3 +- src/utils/decentralize/randomName.test.ts | 147 ++++++++++++++++++++++ src/utils/decentralize/randomName.ts | 114 ++++++++++++++--- 3 files changed, 245 insertions(+), 19 deletions(-) create mode 100644 src/utils/decentralize/randomName.test.ts diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index cc80943..e91792d 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -104,7 +104,7 @@ export const decentralizeCommand = new Command("decentralize") ); } } else { - process.stdout.write(`\n▸ Picking a free random .dot name…\n`); + process.stdout.write(`\n▸ Picking a free .dot name from ${opts.site}…\n`); const chosen = await withSpan( "cli.decentralize.random-name", "find available random name", @@ -112,6 +112,7 @@ export const decentralizeCommand = new Command("decentralize") findAvailableRandomName({ env, ownerSs58Address: signer?.address, + siteUrl: opts.site, }), ); label = chosen.label; diff --git a/src/utils/decentralize/randomName.test.ts b/src/utils/decentralize/randomName.test.ts new file mode 100644 index 0000000..bd248be --- /dev/null +++ b/src/utils/decentralize/randomName.test.ts @@ -0,0 +1,147 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Shape-invariant tests for `generateLabel`. Two things must hold for every + * generated label, regardless of input, or //Bob and other NoStatus signers + * will be rejected at the DotNS chain step: + * + * 1. Exactly 2 trailing digits (>2 is RESERVED for governance) + * 2. Base length (everything before the trailing digits) >= 9 + * + * Each invariant is checked across many iterations to catch RNG-dependent + * edge cases — the previous hex-suffix implementation produced >2 trailing + * digits ~62 % of the time, silently masked by the retry loop. + */ + +import { describe, expect, it } from "vitest"; +import { generateLabel } from "./randomName.js"; + +const ITERATIONS = 200; + +function trailingDigits(label: string): number { + return /[0-9]*$/.exec(label)?.[0].length ?? 0; +} + +function baseLength(label: string): number { + return label.length - trailingDigits(label); +} + +describe("generateLabel", () => { + it("incorporates the hostname verbatim (TLD kept)", () => { + expect(generateLabel("https://shawntabrizi.com")).toMatch(/^shawntabrizi-com-/); + expect(generateLabel("https://example.com")).toMatch(/^example-com-/); + }); + + it("preserves the www. prefix (we don't second-guess what the user typed)", () => { + expect(generateLabel("https://www.example.com")).toMatch(/^www-example-com-/); + }); + + it("preserves multi-segment public suffixes (we don't consult the PSL)", () => { + expect(generateLabel("https://example.co.uk")).toMatch(/^example-co-uk-/); + expect(generateLabel("https://shawntabrizi.github.io")).toMatch(/^shawntabrizi-github-io-/); + }); + + it("replaces dots with hyphens", () => { + expect(generateLabel("https://a.b.c.example.com")).toMatch(/^a-b-c-example-com-/); + }); + + it("ignores the path", () => { + const label = generateLabel("https://example.com/blog/post-1?x=y"); + expect(label).toMatch(/^example-com-/); + expect(label).not.toContain("blog"); + expect(label).not.toContain("post"); + }); + + it("accepts bare hostnames (no protocol)", () => { + expect(generateLabel("example.com")).toMatch(/^example-com-/); + }); + + it("falls back to decent- when the URL is unusable", () => { + // node:URL accepts plenty of weird inputs; an outright empty hostname + // should be the trigger. We exercise it via `garbage://` which parses + // but yields no hostname. + const label = generateLabel("garbage://"); + expect(label).toMatch(/^decent-/); + }); + + it("falls back to decent- when no siteUrl is provided", () => { + expect(generateLabel(undefined)).toMatch(/^decent-/); + }); + + it("always ends in exactly 2 trailing digits", () => { + for (let i = 0; i < ITERATIONS; i++) { + const label = generateLabel("https://example.com"); + expect(trailingDigits(label)).toBe(2); + } + }); + + it("produces NoStatus-compatible base length (>=9)", () => { + for (let i = 0; i < ITERATIONS; i++) { + const label = generateLabel("https://example.com"); + expect(baseLength(label)).toBeGreaterThanOrEqual(9); + } + }); + + it("pads short hostnames so the NoStatus base threshold is still met", () => { + // "a.b" → base "a-b" is only 3 chars; needs extra letters to reach 9. + for (let i = 0; i < ITERATIONS; i++) { + const label = generateLabel("https://a.b"); + expect(baseLength(label)).toBeGreaterThanOrEqual(9); + expect(trailingDigits(label)).toBe(2); + } + }); + + it("preserves shape invariants for the decent- fallback", () => { + for (let i = 0; i < ITERATIONS; i++) { + const label = generateLabel(undefined); + expect(baseLength(label)).toBeGreaterThanOrEqual(9); + expect(trailingDigits(label)).toBe(2); + } + }); + + it("caps absurdly long hostnames", () => { + const longHost = `${"a".repeat(80)}.com`; + const label = generateLabel(`https://${longHost}`); + // DNS labels max out at 63; we cap the host segment at 30 and add a + // short suffix, so the final length is well under that. + expect(label.length).toBeLessThanOrEqual(40); + }); + + it("produces labels matching normalizeDomain's regex", () => { + // playground.ts::normalizeDomain enforces /^[a-z0-9][a-z0-9-]*$/i. + const re = /^[a-z0-9][a-z0-9-]*$/; + for (const input of [ + "https://example.com", + "https://www.shawntabrizi.com", + "https://x.com", + "https://a.b", + "https://sub.domain.example.com", + ]) { + const label = generateLabel(input); + expect(label).toMatch(re); + } + }); + + it("varies on each call (RNG is wired)", () => { + const labels = new Set(); + for (let i = 0; i < 50; i++) { + labels.add(generateLabel("https://example.com")); + } + // Tail is 4 letters (26^4 ≈ 456k) + 2 digits, so 50 calls colliding + // would point at a broken RNG. + expect(labels.size).toBe(50); + }); +}); diff --git a/src/utils/decentralize/randomName.ts b/src/utils/decentralize/randomName.ts index 80e7451..517f845 100644 --- a/src/utils/decentralize/randomName.ts +++ b/src/utils/decentralize/randomName.ts @@ -18,34 +18,112 @@ import { checkDomainAvailability, type AvailabilityResult } from "../deploy/avai import type { Env } from "../../config.js"; /** - * Random label prefix for the "free / not-great domain" tier. The base length - * (8 chars before the trailing digits) keeps us in DotNS's NoStatus bucket - * (`baseLength >= 9` after 2 trailing digits → no PoP required) so //Bob can - * self-register without a personhood credential. See `availability.ts`'s - * `classifyLabel` for the rule. + * Label-generation rules (see `availability.ts::classifyLabel`): + * + * - `baseLength >= 9` + exactly 2 trailing digits → `POP_STATUS_NO_STATUS` + * (any signer, no personhood credential) + * - More than 2 trailing digits → `POP_STATUS_RESERVED` (unregistrable) + * - Base <= 5 chars → reserved for governance + * + * We aim for NoStatus so a fresh //Bob demo or any user without PoP can + * register the name. The variable middle uses lowercase letters only (no + * digits) so the "exactly 2 trailing digits" invariant holds no matter where + * the RNG lands — the earlier hex-suffix design could produce >2 trailing + * digits ~62 % of the time, silently rejected by the retry loop as RESERVED. + */ + +const MIN_BASE_LEN = 9; +const MIN_LETTERS = 4; +const FALLBACK_PREFIX = "decent-"; +/** Cap the derived host segment so the final label stays well under DNS's 63-char ceiling. */ +const MAX_HOST_LEN = 30; + +/** + * Sanitise a site URL into a domain-safe prefix derived from its hostname. + * Returns null if no usable base can be extracted; callers fall back to + * `FALLBACK_PREFIX`. + * + * We deliberately do NOT strip TLDs, public suffixes, or `www.` — they're + * part of the URL the user typed and we want the auto-generated name to be a + * predictable transliteration of it. Stripping `.com` requires a Public + * Suffix List to handle `co.uk`/`github.io`/`vercel.app` correctly, which is + * a dep we don't want. Users who want a clean name pass `--dot=`. + * + * https://www.shawntabrizi.com/blog → www-shawntabrizi-com + * https://example.com:8080 → example-com + * https://shawntabrizi.github.io → shawntabrizi-github-io + * https://example.co.uk → example-co-uk + * https://x.com → x-com + * https://garbage:// → null + */ +function deriveBase(siteUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(siteUrl); + } catch { + try { + parsed = new URL(`https://${siteUrl}`); + } catch { + return null; + } + } + + let s = parsed.hostname + .toLowerCase() + .replace(/\./g, "-") + .replace(/[^a-z0-9-]/g, ""); + s = s.replace(/-+/g, "-").replace(/^-+|-+$/g, ""); + if (!s) return null; + + // `normalizeDomain` (playground.ts) requires `^[a-z0-9]` as first char. + if (!/^[a-z0-9]/.test(s)) return null; + + if (s.length > MAX_HOST_LEN) s = s.slice(0, MAX_HOST_LEN).replace(/-+$/, ""); + + return s || null; +} + +/** + * Generate one candidate label. Each call produces fresh randomness, so the + * retry loop in `findAvailableRandomName` can resolve collisions. + * + * generateLabel("https://shawntabrizi.com") → "shawntabrizi-com-abcd42" + * generateLabel("https://x.com") → "x-com-abcd42" + * generateLabel(undefined) → "decent-abcd42" */ -const PREFIX = "decent-"; - -function randomLabel(): string { - // 6 random base36 chars + 2 trailing digits = "decent-xxxxxx12". - // Trailing digits keep us inside the "Available to all" classifier branch - // for short-ish names without needing PoP. - const suffix = randomBytes(4).toString("hex").slice(0, 6); - const digits = String((randomBytes(1)[0] % 90) + 10); // always 2 digits - return `${PREFIX}${suffix}${digits}`; +export function generateLabel(siteUrl?: string): string { + const base = siteUrl ? deriveBase(siteUrl) : null; + const prefix = base ? `${base}-` : FALLBACK_PREFIX; + + // Pad with lowercase letters so prefix + letters >= MIN_BASE_LEN. + const lettersLen = Math.max(MIN_LETTERS, MIN_BASE_LEN - prefix.length); + const letters = Array.from(randomBytes(lettersLen)) + .map((b) => String.fromCharCode(97 + (b % 26))) + .join(""); + + const digits = String((randomBytes(1)[0] % 90) + 10); // 10..99 + + return `${prefix}${letters}${digits}`; } export interface FindAvailableNameOptions { env?: Env; ownerSs58Address?: string; + /** + * URL of the site being decentralized. When provided, the generated + * candidates start with a sanitised version of the hostname (e.g. + * `shawntabrizi-com-abcd42` rather than `decent-abcd42`). Improves + * recognisability of the resulting `.dot.li` URL. + */ + siteUrl?: string; /** Cap on attempts; defaults to 20. */ maxAttempts?: number; } /** - * Generate `decent-NN` candidates until one is `available` according to - * `checkDomainAvailability`. Returns the chosen label and the matching - * availability result so callers can reuse the embedded `DeployPlan`. + * Generate URL-derived NoStatus candidates until one is `available`. Returns + * the chosen label and the matching availability result so callers can reuse + * the embedded `DeployPlan`. * * Bails after `maxAttempts` with a descriptive error — collisions in this * keyspace would indicate either a broken RNG or an attacker pre-claiming the @@ -58,7 +136,7 @@ export async function findAvailableRandomName( let lastFailure: AvailabilityResult | null = null; for (let i = 0; i < maxAttempts; i++) { - const candidate = randomLabel(); + const candidate = generateLabel(options.siteUrl); const result = await checkDomainAvailability(candidate, { env: options.env, ownerSs58Address: options.ownerSs58Address, From b8ed87f0e28fa2cd3c8011f746dd0525a2911ca5 Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Tue, 26 May 2026 21:17:00 +0100 Subject: [PATCH 6/6] feat(decentralize): interactive TUI, deploy-flow integration, render fix Add an interactive flow to `dot decentralize` and wire it through deploy's existing publish machinery. - `dot decentralize` with no `--site` opens an Ink TUI: yellow advisory callout, site URL prompt, dev/phone signer picker, `.dot` name prompt with live availability check, publish-to-playground prompt, confirm, and a deploy-style step-row progress view (mirror / upload+dotns / publish) with a throttled latest-log line. - The headless `dot decentralize --site=...` contract is unchanged; add an opt-in `--playground` flag for registry publish parity. - Runner threads `(mode, userSigner)` through deploy's `resolveSignerSetup` and calls `publishToPlayground` so dev-mode-with-session records the user's H160 as owner; mirrored sites publish with `repositoryUrl: null` and `isModdable: false`. - Phone-mode signing surfaces the "check your phone" callout (and a headless equivalent) via deploy's signing-event proxy. - Fix unrenderable sites: `wget --no-host-directories` keeps URL path segments as subdirs, so `index.html` landed below the upload root and Bulletin's viewer reported "Archive missing index.html". `findIndexHtmlRoot` resolves the real document root before upload. - Auto-exit the done/error screens instead of waiting for a keypress. - Rename `createAliceSignerForDevPublish` to `createDevPublishSigner` and export it for reuse (the name is not Substrate's //Alice). Tests: stage machine, URL/domain validators, mirror helpers (findIndexHtmlRoot, WgetMissingError, empty-mirror), and the deploy-event formatter. --- .changeset/decentralize-interactive.md | 18 + .../decentralize/DecentralizeScreen.tsx | 656 ++++++++++++++++++ src/commands/decentralize/index.ts | 388 +++++++---- src/commands/decentralize/state.test.ts | 178 +++++ src/commands/decentralize/state.ts | 108 +++ src/utils/decentralize/domain.ts | 94 +++ src/utils/decentralize/mirror.test.ts | 179 +++++ src/utils/decentralize/mirror.ts | 85 ++- src/utils/decentralize/run.test.ts | 37 + src/utils/decentralize/run.ts | 293 ++++++++ src/utils/deploy/signerMode.ts | 14 +- src/utils/deploy/signerModeAlice.test.ts | 4 +- 12 files changed, 1915 insertions(+), 139 deletions(-) create mode 100644 .changeset/decentralize-interactive.md create mode 100644 src/commands/decentralize/DecentralizeScreen.tsx create mode 100644 src/commands/decentralize/state.test.ts create mode 100644 src/commands/decentralize/state.ts create mode 100644 src/utils/decentralize/domain.ts create mode 100644 src/utils/decentralize/mirror.test.ts create mode 100644 src/utils/decentralize/run.test.ts create mode 100644 src/utils/decentralize/run.ts diff --git a/.changeset/decentralize-interactive.md b/.changeset/decentralize-interactive.md new file mode 100644 index 0000000..b2e622b --- /dev/null +++ b/.changeset/decentralize-interactive.md @@ -0,0 +1,18 @@ +--- +"playground-cli": minor +--- + +`dot decentralize` is now interactive when invoked with no `--site` flag. +Running `dot decentralize` on its own opens a TUI that walks through a +short flow — a yellow "about this command" callout explaining that the +command mirrors a live static site (https URL) and republishes it as a +.dot site, then prompts for the site URL, a signer (dev / your phone), +and a `.dot` name. Domain availability is checked inline against the +chain (same path as `dot deploy`); leaving the name blank auto-generates +a free hostname-derived label as before. The pipeline then runs the same +mirror + Bulletin upload + DotNS register the headless path uses, and +prints a final summary card with the live URL, IPFS CID, and gateway. + +`dot decentralize --site=…` (with or without `--dot` / `--suri`) keeps +the existing headless contract — the demo service that passes +`--suri=//Bob` is unchanged. diff --git a/src/commands/decentralize/DecentralizeScreen.tsx b/src/commands/decentralize/DecentralizeScreen.tsx new file mode 100644 index 0000000..cba3fc1 --- /dev/null +++ b/src/commands/decentralize/DecentralizeScreen.tsx @@ -0,0 +1,656 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Interactive TUI for `dot decentralize`. The state-machine in `state.ts` + * decides which prompt to show next; this file only wires the prompts to + * `runDecentralize` and renders the live progress + final summary. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text } from "ink"; +import { + Callout, + Header, + Hint, + Input, + type MarkKind, + Row, + Section, + Select, + type SelectOption, +} from "../../utils/ui/theme/index.js"; +import { PhoneApprovalCallout } from "../../utils/ui/theme/PhoneApprovalCallout.js"; +import { getNetworkLabel, type Env } from "../../config.js"; +import { VERSION_LABEL } from "../../utils/version.js"; +import type { ResolvedSigner } from "../../utils/signer.js"; +import { createDevPublishSigner, type SignerMode } from "../../utils/deploy/signerMode.js"; +import type { SigningEvent } from "../../utils/deploy/signingProxy.js"; +import { resolveDomain } from "../../utils/decentralize/domain.js"; +import { + describeDeployEvent, + runDecentralize, + type DecentralizeOutcome, +} from "../../utils/decentralize/run.js"; +import { pickNextStage, validateDomainInput, validateSiteUrlInput, type Stage } from "./state.js"; + +/** + * What the screen reports back when it unmounts. The host (`runInteractive`) + * maps each variant to an exit code: `success` and `cancel` resolve cleanly + * (exit 0); `error` rejects so telemetry records the failure (exit 1). The + * TUI itself has already rendered any user-visible message before this fires + * — `runInteractive` never re-prints. + */ +export type DecentralizeResult = + | { kind: "success"; outcome: DecentralizeOutcome } + | { kind: "cancel" } + | { kind: "error"; message: string }; + +export interface DecentralizeScreenProps { + env: Env; + initialSiteUrl: string | null; + initialDot: string | null; + /** `--suri` resolved up front. When set, the signer picker is skipped. */ + explicitSigner: ResolvedSigner | null; + /** Session signer from `dot init`, if any. Picked when "phone" is selected. */ + sessionSigner: ResolvedSigner | null; + /** + * Pre-set when `--playground` was passed on the CLI. `null` means the + * publish prompt is shown. + */ + initialPublishToPlayground: boolean | null; + onDone: (result: DecentralizeResult) => void; +} + +export function DecentralizeScreen({ + env, + initialSiteUrl, + initialDot, + explicitSigner, + sessionSigner, + initialPublishToPlayground, + onDone, +}: DecentralizeScreenProps) { + const [siteUrl, setSiteUrl] = useState(initialSiteUrl); + // If --suri was passed, the user has effectively pre-chosen dev. + const [signerMode, setSignerMode] = useState(explicitSigner ? "dev" : null); + const [domainRaw, setDomainRaw] = useState(initialDot); + const [domainLabel, setDomainLabel] = useState(null); + const [fullDomain, setFullDomain] = useState(null); + const [availabilityNote, setAvailabilityNote] = useState(null); + const [domainError, setDomainError] = useState(null); + const [validationMessage, setValidationMessage] = useState(null); + const [publishToPlayground, setPublishToPlayground] = useState( + initialPublishToPlayground, + ); + + const [stage, setStage] = useState(() => + pickNextStage({ + siteUrl: initialSiteUrl, + signerMode: explicitSigner ? "dev" : null, + domainLabel: null, + domainRaw: initialDot, + publishToPlayground: initialPublishToPlayground, + }), + ); + + const advance = ( + next: Partial<{ + siteUrl: string | null; + signerMode: SignerMode | null; + domainLabel: string | null; + domainRaw: string | null; + publishToPlayground: boolean | null; + }> = {}, + ) => { + setStage( + pickNextStage({ + siteUrl: next.siteUrl !== undefined ? next.siteUrl : siteUrl, + signerMode: next.signerMode !== undefined ? next.signerMode : signerMode, + domainLabel: next.domainLabel !== undefined ? next.domainLabel : domainLabel, + domainRaw: next.domainRaw !== undefined ? next.domainRaw : domainRaw, + publishToPlayground: + next.publishToPlayground !== undefined + ? next.publishToPlayground + : publishToPlayground, + }), + ); + }; + + // Compose the active signer for downstream stages. Memoised so the + // ResolvedSigner identity stays stable across re-renders (the dev branch + // would otherwise produce a fresh `createDevPublishSigner()` instance on + // every render — fine functionally because `DEV_PUBLISH_ACCOUNT` is + // module-scope, but it makes downstream effect dependencies look churny). + const activeSigner = useMemo(() => { + if (explicitSigner) return explicitSigner; + if (signerMode === "phone") return sessionSigner; + if (signerMode === "dev") return createDevPublishSigner(); + return null; + }, [explicitSigner, signerMode, sessionSigner]); + + return ( + +
+ + {stage.kind === "prompt-url" && ( + <> + + + Mirrors a live static site (https URL) and republishes it as a .dot + site. + + + { + setSiteUrl(value); + advance({ siteUrl: value }); + }} + /> + + )} + + {stage.kind === "prompt-signer" && ( + + label="signer" + options={signerOptions(sessionSigner)} + onSelect={(mode) => { + if (mode === "phone" && !sessionSigner) { + setStage({ + kind: "error", + message: + 'No session found — run "dot init" to log in, then re-run, or pick the dev signer.', + }); + return; + } + setSignerMode(mode); + advance({ signerMode: mode }); + }} + /> + )} + + {stage.kind === "prompt-domain" && ( + { + setDomainError(null); + setDomainRaw(value); + advance({ domainRaw: value }); + }} + /> + )} + + {stage.kind === "validate-domain" && ( + { + setDomainLabel(label); + setFullDomain(full); + setAvailabilityNote(note); + setValidationMessage(null); + advance({ domainLabel: label }); + }} + onFailed={(message) => { + setDomainError(message); + setDomainLabel(null); + setValidationMessage(null); + // Re-prompt: clear domainRaw so prompt-domain reopens. + setDomainRaw(null); + setStage({ kind: "prompt-domain" }); + }} + onProgress={(message) => setValidationMessage(message)} + progressMessage={validationMessage} + /> + )} + + {stage.kind === "prompt-publish" && ( + + label="publish to the playground registry?" + options={[ + { + value: false, + label: "no", + hint: "just register the .dot name (DotNS only)", + }, + { + value: true, + label: "yes", + hint: "list the mirrored site in the playground apps tab", + }, + ]} + onSelect={(choice) => { + setPublishToPlayground(choice); + advance({ publishToPlayground: choice }); + }} + /> + )} + + {stage.kind === "confirm" && ( + setStage({ kind: "running" })} + onCancel={() => onDone({ kind: "cancel" })} + /> + )} + + {stage.kind === "running" && ( + setStage({ kind: "done", outcome })} + onFailed={(message) => setStage({ kind: "error", message })} + /> + )} + + {stage.kind === "done" && ( + onDone({ kind: "success", outcome: stage.outcome })} + /> + )} + + {stage.kind === "error" && ( + onDone({ kind: "error", message: stage.message })} + /> + )} + + ); +} + +function signerOptions(sessionSigner: ResolvedSigner | null): SelectOption[] { + return [ + { + value: "dev", + label: "dev signer", + hint: "fast, signs locally with the bulletin-deploy default account", + }, + { + value: "phone", + label: "your phone signer", + hint: sessionSigner + ? "signed with your logged-in account" + : "requires `dot init` first", + }, + ]; +} + +// ── Validate-domain stage ──────────────────────────────────────────────────── + +function ValidateDomainStage({ + raw, + env, + siteUrl, + signer, + progressMessage, + onResolved, + onFailed, + onProgress, +}: { + raw: string; + env: Env; + siteUrl: string; + signer: ResolvedSigner | null; + progressMessage: string | null; + onResolved: (result: { label: string; fullDomain: string; note: string | null }) => void; + onFailed: (message: string) => void; + onProgress: (message: string) => void; +}) { + useEffect(() => { + let cancelled = false; + (async () => { + try { + const result = await resolveDomain({ + env, + providedDot: raw || null, + siteUrl, + signer, + onMessage: (m) => { + if (!cancelled) onProgress(m.trim()); + }, + }); + if (!cancelled) onResolved(result); + } catch (err) { + if (!cancelled) onFailed(err instanceof Error ? err.message : String(err)); + } + })(); + return () => { + cancelled = true; + }; + // We intentionally key on `raw` only — `signer`/`siteUrl` are stable + // for the lifetime of a single validate stage. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [raw]); + + return ( + + + + ); +} + +// ── Confirm stage ──────────────────────────────────────────────────────────── + +function ConfirmStage({ + siteUrl, + fullDomain, + availabilityNote, + signer, + signerMode, + publishToPlayground, + onConfirm, + onCancel, +}: { + siteUrl: string; + fullDomain: string; + availabilityNote: string | null; + signer: ResolvedSigner; + signerMode: SignerMode; + publishToPlayground: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + +
+ + + + + {availabilityNote && } +
+ + label="proceed?" + options={[ + { + value: "go", + label: "yes, decentralize it", + hint: publishToPlayground + ? "mirror + upload + register + publish" + : "mirror + upload + register", + }, + { value: "cancel", label: "cancel", hint: "exit without changes" }, + ]} + onSelect={(choice) => (choice === "go" ? onConfirm() : onCancel())} + /> +
+ ); +} + +// ── Running stage ──────────────────────────────────────────────────────────── + +type StepStatus = "idle" | "running" | "complete"; + +function stepMark(status: StepStatus): MarkKind { + switch (status) { + case "complete": + return "ok"; + case "running": + return "run"; + default: + return "idle"; + } +} + +function RunningStage({ + siteUrl, + label, + fullDomain, + mode, + userSigner, + publishToPlayground, + env, + onComplete, + onFailed, +}: { + siteUrl: string; + label: string; + fullDomain: string; + mode: SignerMode; + userSigner: ResolvedSigner | null; + publishToPlayground: boolean; + env: Env; + onComplete: (outcome: DecentralizeOutcome) => void; + onFailed: (message: string) => void; +}) { + const [mirrorStatus, setMirrorStatus] = useState("running"); + const [uploadStatus, setUploadStatus] = useState("idle"); + const [playgroundStatus, setPlaygroundStatus] = useState("idle"); + const [latestLog, setLatestLog] = useState(null); + // Active "check your phone" prompt — set on sign-request, cleared on + // sign-complete / sign-error. Only ever populated in phone mode. + const [signingPrompt, setSigningPrompt] = useState(null); + + // Throttle the latest-log line to ≤10 Hz. bulletin-deploy emits per-chunk + // events in bursts; setState-per-event floods Ink's reconciler (see + // CLAUDE.md "Throttle TUI info updates"). We keep only the most recent + // line — it's a status indicator, not a scrollback. + const pendingRef = useRef(null); + const flushTimer = useRef | null>(null); + const queueLog = (line: string) => { + pendingRef.current = line.length > 160 ? `${line.slice(0, 159)}…` : line; + if (flushTimer.current) return; + flushTimer.current = setTimeout(() => { + if (pendingRef.current !== null) { + setLatestLog(pendingRef.current); + pendingRef.current = null; + } + flushTimer.current = null; + }, 100); + }; + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const outcome = await runDecentralize({ + siteUrl, + label, + fullDomain, + mode, + userSigner, + publishToPlayground, + env, + onEvent: (event) => { + switch (event.kind) { + case "mirror-start": + setMirrorStatus("running"); + queueLog(`mirroring ${event.url}`); + break; + case "mirror-line": + queueLog(event.line); + break; + case "mirror-done": + setMirrorStatus("complete"); + setUploadStatus("running"); + queueLog(`mirrored ${event.fileCount} files`); + break; + case "storage-start": + setUploadStatus("running"); + break; + case "storage-event": { + const line = describeDeployEvent(event.event); + if (line) queueLog(line); + break; + } + case "storage-done": + setUploadStatus("complete"); + queueLog(`registered ${fullDomain}`); + break; + case "playground-start": + setPlaygroundStatus("running"); + break; + case "playground-event": { + const line = describeDeployEvent(event.event); + if (line) queueLog(line); + break; + } + case "playground-done": + setPlaygroundStatus("complete"); + break; + case "signing": + if (event.event.kind === "sign-request") { + setSigningPrompt(event.event); + } else if (event.event.kind === "sign-complete") { + setSigningPrompt(null); + } else if (event.event.kind === "sign-error") { + setSigningPrompt(null); + queueLog(`signing failed: ${event.event.message}`); + } + break; + } + }, + }); + if (!cancelled) onComplete(outcome); + } catch (err) { + if (!cancelled) onFailed(err instanceof Error ? err.message : String(err)); + } + })(); + return () => { + cancelled = true; + if (flushTimer.current) { + clearTimeout(flushTimer.current); + flushTimer.current = null; + } + }; + // The pipeline is keyed on the inputs frozen at confirm time; we + // never re-run it within a single mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const running = + mirrorStatus === "running" || uploadStatus === "running" || playgroundStatus === "running"; + + return ( + +
+ + + {publishToPlayground && ( + + )} + {running && latestLog && {latestLog}} +
+ + {signingPrompt && signingPrompt.kind === "sign-request" && ( + + )} +
+ ); +} + +// ── Done stage ─────────────────────────────────────────────────────────────── + +function DoneStage({ + outcome, + onExit, +}: { + outcome: DecentralizeOutcome; + onExit: () => void; +}) { + // Auto-exit: the rendered frame stays in terminal scrollback, so users + // see the summary without having to press a key. Matches the implicit + // "command finishes when work finishes" convention every other CLI uses. + useEffect(() => { + onExit(); + // onExit is captured at mount; we never want to re-fire on identity churn. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + +
+ + + + + {outcome.metadataCid && } +
+ {outcome.signerSource === "dev" && ( + + + To deploy to a domain owned by you, run `dot init` and re-run `dot + decentralize` with the mobile signer. + + + )} +
+ ); +} + +// ── Error stage ────────────────────────────────────────────────────────────── + +function ErrorStage({ message, onExit }: { message: string; onExit: () => void }) { + // Same auto-exit rationale as DoneStage — the danger callout stays in + // scrollback so the user can still read it after the prompt returns. + useEffect(() => { + onExit(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + {message} + + + ); +} diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index e91792d..7d81fce 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -16,43 +16,57 @@ /** * `dot decentralize` — point at a live static site, get back a .dot URL. * - * dot decentralize --site=shawntabrizi.github.io --dot=shawntabrizi.dot + * dot decentralize # interactive + * dot decentralize --site=shawntabrizi.github.io # headless + * dot decentralize --site=foo.com --dot=bar # headless, explicit name + * dot decentralize --site=foo.com --suri=//Bob # headless, dev signer + * dot decentralize --site=foo.com --playground # also publish to playground * - * Default signer is the `dot init` session signer — the user owns the - * registered .dot name. Pass `--suri //Bob` (or any dev name / mnemonic) for - * an explicit signer; that's what the demo service at dot-decentralize uses - * to give first-time visitors a zero-setup path on paseo-next-v2. - * - * Site cloning is `wget --mirror` (see utils/decentralize/mirror.ts). The - * upload + DotNS register flow re-uses `runStorageDeploy` exactly like - * `dot deploy`, so any improvements to the underlying primitives flow into - * this command for free. + * Headless flow runs when `--site` is provided (preserves the existing + * `dot decentralize --suri=//Bob` demo-service contract). Without `--site`, + * the command mounts an Ink TUI that prompts for URL → signer → domain → + * publish? before kicking off the same upload pipeline. The publish-to- + * playground step delegates to deploy's `publishToPlayground` helper. */ import { Command } from "commander"; -import { rmSync } from "node:fs"; +import React from "react"; +import { render } from "ink"; import { runCliCommand } from "../../cli-runtime.js"; import { errorMessage, withSpan } from "../../telemetry.js"; -import { DEFAULT_ENV, type Env, getChainConfig, resolveLegacyEnv } from "../../config.js"; -import { resolveSigner, type ResolvedSigner } from "../../utils/signer.js"; -import { runStorageDeploy } from "../../utils/deploy/storage.js"; -import { checkDomainAvailability, formatAvailability } from "../../utils/deploy/availability.js"; -import { normalizeDomain } from "../../utils/deploy/playground.js"; -import { mirrorSite } from "../../utils/decentralize/mirror.js"; -import { findAvailableRandomName } from "../../utils/decentralize/randomName.js"; +import { DEFAULT_ENV, type Env, resolveLegacyEnv } from "../../config.js"; +import { resolveSigner, type ResolvedSigner, SignerNotAvailableError } from "../../utils/signer.js"; +import { resolveDomain } from "../../utils/decentralize/domain.js"; +import { + describeDeployEvent, + runDecentralize, + type DecentralizeOutcome, +} from "../../utils/decentralize/run.js"; +import { destroyConnection } from "../../utils/connection.js"; +import type { SignerMode } from "../../utils/deploy/signerMode.js"; +import { onProcessShutdown } from "../../utils/process-guard.js"; interface DecentralizeOpts { - site: string; + site?: string; dot?: string; env: string; suri?: string; + /** + * Commander coerces `--playground` (no arg) to `true` and the flag's + * absence to `undefined`. We treat `undefined` as "ask in the TUI / skip + * in headless" — i.e. opt-in publish. + */ + playground?: boolean; } export const decentralizeCommand = new Command("decentralize") .description( "Mirror a live static site to Polkadot Bulletin and register a .dot name pointing at it", ) - .requiredOption("--site ", "URL of the static site to clone (http/https)") + .option( + "--site ", + "URL of the static site to clone (http/https). Omit to launch the interactive TUI.", + ) .option( "--dot ", "DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.", @@ -63,122 +77,238 @@ export const decentralizeCommand = new Command("decentralize") "Sign with this SURI (dev name like //Bob, or a BIP-39 mnemonic). " + "Default: the session signer paired by `dot init`.", ) + .option( + "--playground", + "After upload, also publish a minimal AppInfo entry to the playground registry " + + "(visible in the playground-app's Apps tab). Off by default.", + ) .action(async (opts: DecentralizeOpts) => runCliCommand("decentralize", { hardExit: true }, async () => { const env: Env = resolveLegacyEnv(opts.env); + if (opts.site) { + await runHeadless({ env, opts }); + } else { + await runInteractive({ env, opts }); + } + }), + ); - let signer: ResolvedSigner | null = null; - let mirrorDir: string | null = null; +// ── Headless path (preserves the existing dot decentralize --site=... contract) ─ - try { - signer = await withSpan("cli.decentralize.signer", "resolve signer", () => - resolveSigner({ suri: opts.suri }), - ); - - process.stdout.write(`\n▸ Signing as ${signer.address} (${signer.source})\n`); - - // ── 1. Pick a domain ──────────────────────────────────────── - let label: string; - let fullDomain: string; - if (opts.dot) { - const normalized = normalizeDomain(opts.dot); - label = normalized.label; - fullDomain = normalized.fullDomain; - - process.stdout.write(`\n▸ Checking ${fullDomain}…\n`); - const availability = await withSpan( - "cli.decentralize.availability", - "check domain availability", - () => - checkDomainAvailability(label, { - env, - ownerSs58Address: signer?.address, - }), - ); - if (availability.status === "reserved" || availability.status === "taken") { - throw new Error(formatAvailability(availability)); - } - if (availability.status === "unknown") { - process.stderr.write( - `\n⚠ ${formatAvailability(availability)} — continuing anyway.\n`, +async function runHeadless({ + env, + opts, +}: { + env: Env; + opts: DecentralizeOpts; +}): Promise { + let signer: ResolvedSigner | null = null; + + try { + signer = await withSpan("cli.decentralize.signer", "resolve signer", () => + resolveSigner({ suri: opts.suri }), + ); + + process.stdout.write(`\n▸ Signing as ${signer.address} (${signer.source})\n`); + + const { label, fullDomain } = await resolveDomain({ + env, + providedDot: opts.dot, + siteUrl: opts.site!, + signer, + onMessage: (line) => process.stdout.write(`${line}\n`), + }); + + // Headless mode: "dev" when --suri was passed (signer.source === "dev"), + // "phone" when we fell back to the session signer (source === "session"). + const mode: SignerMode = signer.source === "session" ? "phone" : "dev"; + + process.stdout.write(`\n▸ Mirroring ${opts.site}…\n`); + const outcome = await runDecentralize({ + siteUrl: opts.site!, + label, + fullDomain, + mode, + userSigner: signer, + publishToPlayground: opts.playground === true, + env, + onEvent: (ev) => { + switch (ev.kind) { + case "mirror-line": + process.stdout.write(` ${ev.line}\n`); + break; + case "mirror-done": + process.stdout.write( + ` → ${ev.fileCount} files in ${ev.directory}\n` + + `\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`, ); + break; + case "storage-event": { + const line = describeDeployEvent(ev.event); + if (line) process.stdout.write(` • ${line}\n`); + break; } - } else { - process.stdout.write(`\n▸ Picking a free .dot name from ${opts.site}…\n`); - const chosen = await withSpan( - "cli.decentralize.random-name", - "find available random name", - () => - findAvailableRandomName({ - env, - ownerSs58Address: signer?.address, - siteUrl: opts.site, - }), - ); - label = chosen.label; - fullDomain = chosen.availability.fullDomain; - process.stdout.write(` → ${fullDomain}\n`); - } - - // ── 2. Mirror the site ────────────────────────────────────── - process.stdout.write(`\n▸ Mirroring ${opts.site}…\n`); - const mirror = await withSpan("cli.decentralize.mirror", "mirror site", () => - mirrorSite({ - url: opts.site, - onLine: (line) => process.stdout.write(` ${line}\n`), - }), - ); - mirrorDir = mirror.directory; - process.stdout.write(` → ${mirror.fileCount} files in ${mirror.directory}\n`); - - // ── 3. Upload to Bulletin + register DotNS ────────────────── - process.stdout.write(`\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`); - const result = await withSpan( - "cli.decentralize.storage", - "bulletin upload + dotns register", - () => - runStorageDeploy({ - content: mirror.directory, - domainName: label, - auth: { - signer: signer?.signer, - signerAddress: signer?.address, - }, - env, - onLogEvent: (event) => process.stdout.write(` • ${event.kind}\n`), - }), - ); - - // ── 4. Print success ──────────────────────────────────────── - const cfg = getChainConfig(env); - const appUrl = `https://${fullDomain}.li`; - const gatewayUrl = `${cfg.bulletinGateway}${result.cid}`; - process.stdout.write( - "\n✔ Decentralized!\n" + - ` Site ${appUrl}\n` + - ` IPFS CID ${result.cid}\n` + - ` Gateway ${gatewayUrl}\n`, - ); - if (signer.source === "dev") { - process.stdout.write( - "\n Owned by a development account (testnet demo). To claim a name\n" + - " under your own account, run `dot init` and re-deploy without --suri.\n", - ); - } - process.stdout.write("\n"); - } catch (err) { - process.stderr.write(`\n✖ ${errorMessage(err)}\n`); - process.exitCode = 1; - throw err; - } finally { - signer?.destroy(); - if (mirrorDir) { - try { - rmSync(mirrorDir, { recursive: true, force: true }); - } catch { - // best-effort cleanup; tmpdir is OS-managed anyway + case "playground-start": + process.stdout.write(`\n▸ Publishing ${ev.fullDomain} to playground…\n`); + break; + case "playground-event": { + const line = describeDeployEvent(ev.event); + if (line) process.stdout.write(` • ${line}\n`); + break; } + case "signing": + if (ev.event.kind === "sign-request") { + process.stdout.write( + `\n ▸ Check your phone — approve step ${ev.event.step}/${ev.event.total}: ${ev.event.label}\n`, + ); + } else if (ev.event.kind === "sign-error") { + process.stdout.write(` ✖ signing failed: ${ev.event.message}\n`); + } + break; } - } - }), + }, + }); + + printHeadlessSuccess(outcome); + } catch (err) { + process.stderr.write(`\n✖ ${errorMessage(err)}\n`); + process.exitCode = 1; + throw err; + } finally { + signer?.destroy(); + destroyConnection(); + } +} + +// ── Interactive path ───────────────────────────────────────────────────────── + +async function runInteractive({ + env, + opts, +}: { + env: Env; + opts: DecentralizeOpts; +}): Promise { + // Preflight: resolve an explicit --suri signer if provided, otherwise try the + // persisted session — tolerating its absence so the picker can still offer + // the dev option. + const preflight = await withSpan("cli.decentralize.preflight", "decentralize preflight", () => + resolvePreflightSigner(opts.suri), ); + + const cleanupOnce = (() => { + let ran = false; + return () => { + if (ran) return; + ran = true; + try { + preflight.explicitSigner?.destroy(); + } catch {} + try { + preflight.sessionSigner?.destroy(); + } catch {} + // Release the shared Asset Hub WS that publishToPlayground opens + // via getConnection(). Without this the event loop stays alive and + // `dot decentralize` hangs after the work visibly finishes (the + // CLAUDE.md "hanging after work finishes" gotcha). + try { + destroyConnection(); + } catch {} + }; + })(); + onProcessShutdown(cleanupOnce); + + try { + const { DecentralizeScreen } = await import("./DecentralizeScreen.js"); + + await new Promise((resolvePromise, rejectPromise) => { + let settled = false; + const app = render( + React.createElement(DecentralizeScreen, { + env, + initialSiteUrl: opts.site ?? null, + initialDot: opts.dot ?? null, + explicitSigner: preflight.explicitSigner, + sessionSigner: preflight.sessionSigner, + initialPublishToPlayground: opts.playground === true ? true : null, + onDone: (result) => { + if (settled) return; + settled = true; + app.unmount(); + // The TUI has already rendered the user-visible message — + // never re-log here. Cancel and success both exit 0; only + // a real failure throws so telemetry records the SAD% bump. + switch (result.kind) { + case "success": + case "cancel": + resolvePromise(); + break; + case "error": + process.exitCode = 1; + rejectPromise(new Error(result.message)); + break; + } + }, + }), + ); + + app.waitUntilExit().catch((err) => { + if (!settled) { + settled = true; + rejectPromise(err); + } + }); + }); + } finally { + cleanupOnce(); + } +} + +interface PreflightSigners { + /** Signer explicitly chosen by `--suri`. Picker is skipped when set. */ + explicitSigner: ResolvedSigner | null; + /** Session signer from `dot init`, if any. Drives the "phone" picker option. */ + sessionSigner: ResolvedSigner | null; +} + +async function resolvePreflightSigner(suri: string | undefined): Promise { + if (suri) { + return { + explicitSigner: await resolveSigner({ suri }), + sessionSigner: null, + }; + } + try { + return { + explicitSigner: null, + sessionSigner: await resolveSigner({}), + }; + } catch (err) { + if (err instanceof SignerNotAvailableError) { + return { explicitSigner: null, sessionSigner: null }; + } + throw err; + } +} + +// ── Shared helpers ─────────────────────────────────────────────────────────── + +export function printHeadlessSuccess(outcome: DecentralizeOutcome): void { + const lines = [ + "\n✔ Decentralized!", + ` Site ${outcome.appUrl}`, + ` IPFS CID ${outcome.ipfsCid}`, + ` Gateway ${outcome.gatewayUrl}`, + ]; + if (outcome.metadataCid) { + lines.push(` Metadata CID ${outcome.metadataCid}`); + } + process.stdout.write(`${lines.join("\n")}\n`); + if (outcome.signerSource === "dev") { + process.stdout.write( + "\n To deploy to a domain owned by you, run `dot init` and re-run\n" + + " `dot decentralize` with the mobile signer.\n", + ); + } + process.stdout.write("\n"); +} diff --git a/src/commands/decentralize/state.test.ts b/src/commands/decentralize/state.test.ts new file mode 100644 index 0000000..e1012ab --- /dev/null +++ b/src/commands/decentralize/state.test.ts @@ -0,0 +1,178 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect } from "vitest"; +import { pickNextStage, validateDomainInput, validateSiteUrlInput } from "./state.js"; + +describe("pickNextStage", () => { + it("starts at prompt-url when nothing has been filled", () => { + expect( + pickNextStage({ + siteUrl: null, + signerMode: null, + domainLabel: null, + domainRaw: null, + publishToPlayground: null, + }), + ).toEqual({ kind: "prompt-url" }); + }); + + it("advances to prompt-signer once the URL is known", () => { + expect( + pickNextStage({ + siteUrl: "https://example.com", + signerMode: null, + domainLabel: null, + domainRaw: null, + publishToPlayground: null, + }), + ).toEqual({ kind: "prompt-signer" }); + }); + + it("advances to prompt-domain once URL + signer are picked", () => { + expect( + pickNextStage({ + siteUrl: "https://example.com", + signerMode: "dev", + domainLabel: null, + domainRaw: null, + publishToPlayground: null, + }), + ).toEqual({ kind: "prompt-domain" }); + }); + + it("advances to validate-domain once domain has been typed but not yet validated", () => { + expect( + pickNextStage({ + siteUrl: "https://example.com", + signerMode: "phone", + domainLabel: null, + domainRaw: "myapp", + publishToPlayground: null, + }), + ).toEqual({ kind: "validate-domain", raw: "myapp" }); + }); + + it("asks the publish question once the domain is validated", () => { + expect( + pickNextStage({ + siteUrl: "https://example.com", + signerMode: "dev", + domainLabel: "myapp", + domainRaw: "myapp", + publishToPlayground: null, + }), + ).toEqual({ kind: "prompt-publish" }); + }); + + it("lands on confirm once the publish answer is locked in", () => { + expect( + pickNextStage({ + siteUrl: "https://example.com", + signerMode: "dev", + domainLabel: "myapp", + domainRaw: "myapp", + publishToPlayground: false, + }), + ).toEqual({ kind: "confirm" }); + }); + + it("also lands on confirm when publish was pre-answered via --playground", () => { + expect( + pickNextStage({ + siteUrl: "https://example.com", + signerMode: "dev", + domainLabel: "myapp", + domainRaw: "myapp", + publishToPlayground: true, + }), + ).toEqual({ kind: "confirm" }); + }); + + it("treats an empty-string domainRaw as 'asked-already, use auto'", () => { + // Mirrors the user submitting a blank domain prompt to opt into auto-naming. + expect( + pickNextStage({ + siteUrl: "https://example.com", + signerMode: "dev", + domainLabel: null, + domainRaw: "", + publishToPlayground: null, + }), + ).toEqual({ kind: "validate-domain", raw: "" }); + }); +}); + +describe("validateSiteUrlInput", () => { + it("accepts https URLs", () => { + expect(validateSiteUrlInput("https://example.com")).toBeNull(); + }); + + it("accepts http URLs", () => { + expect(validateSiteUrlInput("http://example.com")).toBeNull(); + }); + + it("accepts bare hostnames (mirror.ts will prepend https)", () => { + expect(validateSiteUrlInput("example.com")).toBeNull(); + expect(validateSiteUrlInput("you.github.io/site")).toBeNull(); + }); + + it("rejects non-http schemes with a precise message", () => { + expect(validateSiteUrlInput("ftp://example.com")).toBe("only http(s) URLs are supported"); + expect(validateSiteUrlInput("file:///etc/passwd")).toBe("only http(s) URLs are supported"); + }); + + it("rejects empty input", () => { + expect(validateSiteUrlInput("")).toBe("enter a URL"); + expect(validateSiteUrlInput(" ")).toBe("enter a URL"); + }); + + it("rejects obvious junk", () => { + expect(validateSiteUrlInput("not a url at all!!")).toBe("doesn't look like a URL"); + }); +}); + +describe("validateDomainInput", () => { + it("accepts a bare label", () => { + expect(validateDomainInput("myapp")).toBeNull(); + }); + + it("accepts the .dot suffix", () => { + expect(validateDomainInput("myapp.dot")).toBeNull(); + }); + + it("accepts digits and dashes", () => { + expect(validateDomainInput("my-app-42")).toBeNull(); + }); + + it("treats empty as 'auto-generate'", () => { + expect(validateDomainInput("")).toBeNull(); + expect(validateDomainInput(" ")).toBeNull(); + }); + + it("rejects leading dashes and underscores", () => { + // Matches deploy's validator shape — leading char must be alphanumeric. + expect(validateDomainInput("-leading")).toBe("use lowercase letters, digits, and dashes"); + expect(validateDomainInput("under_score")).toBe( + "use lowercase letters, digits, and dashes", + ); + }); + + it("is case-insensitive on input (deploy normalizes downstream)", () => { + // Mirrors deploy's `/^[a-z0-9][a-z0-9-]*(\.dot)?$/i` — `normalizeDomain` + // lowercases the label before any chain check, so we accept MixedCase here. + expect(validateDomainInput("MyApp")).toBeNull(); + }); +}); diff --git a/src/commands/decentralize/state.ts b/src/commands/decentralize/state.ts new file mode 100644 index 0000000..13cec65 --- /dev/null +++ b/src/commands/decentralize/state.ts @@ -0,0 +1,108 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Pure stage-machine + helpers for `dot decentralize`'s interactive TUI. + * + * Lives in a `.ts` (not `.tsx`) so tests can exercise the prompt ordering + * without importing Ink / React. Mirrors the layout convention used by + * `init/completion.ts`, `init/identityLine.ts`, etc. + */ + +import type { DecentralizeOutcome } from "../../utils/decentralize/run.js"; +import type { SignerMode } from "../../utils/deploy/signerMode.js"; + +export type Stage = + | { kind: "prompt-url" } + | { kind: "prompt-signer" } + | { kind: "prompt-domain" } + | { kind: "validate-domain"; raw: string } + | { kind: "prompt-publish" } + | { kind: "confirm" } + | { kind: "running" } + | { kind: "done"; outcome: DecentralizeOutcome } + | { kind: "error"; message: string }; + +export interface PickStageInput { + siteUrl: string | null; + /** + * `null` when neither --suri nor a session signer has resolved one yet + * AND the user hasn't picked a mode in the TUI. `"phone" | "dev"` once a + * choice is locked in. + */ + signerMode: SignerMode | null; + /** Normalized `.dot` label (without `.dot`) once the validate step has accepted it. */ + domainLabel: string | null; + /** Raw user input from the domain prompt. `null` if the prompt hasn't happened yet. */ + domainRaw: string | null; + /** + * Whether to publish to the playground registry after the storage upload. + * `null` ⇒ user hasn't answered the prompt yet; `true`/`false` locks the + * choice and unblocks the confirm stage. Pre-set when the caller passed + * `--playground` so the prompt is skipped. + */ + publishToPlayground: boolean | null; +} + +/** + * Decide which prompt stage to show next given the inputs collected so far. + * URL → signer → domain → validate-domain → publish? → confirm. Each missing + * piece surfaces its prompt; once everything is filled the `confirm` stage + * gates the actual run. + * + * `domainRaw` exists so the screen can distinguish "user hasn't been + * asked yet" from "user typed input but validation hasn't finished". + */ +export function pickNextStage(input: PickStageInput): Stage { + if (input.siteUrl === null) return { kind: "prompt-url" }; + if (input.signerMode === null) return { kind: "prompt-signer" }; + if (input.domainLabel === null) { + if (input.domainRaw === null) return { kind: "prompt-domain" }; + return { kind: "validate-domain", raw: input.domainRaw }; + } + if (input.publishToPlayground === null) return { kind: "prompt-publish" }; + return { kind: "confirm" }; +} + +/** + * Allow callers to validate a typed site URL before submission. Matches + * `mirror.ts`'s tolerance: bare hostnames (`example.com`) are accepted — + * `mirrorSite` will prepend `https://` itself — but anything with a + * non-http(s) scheme is rejected up front. + * + * Returns `null` when the input is acceptable, an error message otherwise. + */ +export function validateSiteUrlInput(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return "enter a URL"; + if (/^https?:\/\//i.test(trimmed)) return null; + if (/^[a-z]+:\/\//i.test(trimmed)) return "only http(s) URLs are supported"; + // Bare hostname — mirror.ts will normalise it. + if (/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(\/.*)?$/i.test(trimmed)) return null; + return "doesn't look like a URL"; +} + +/** + * `dot deploy`'s same shape check, plus a tolerance for an optional `.dot` + * suffix. Availability + reservation are decided by the chain in the + * validate-domain stage, so this only screens the obviously-malformed. + */ +export function validateDomainInput(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; // empty = "auto-generate from URL" + return /^[a-z0-9][a-z0-9-]*(\.dot)?$/i.test(trimmed) + ? null + : "use lowercase letters, digits, and dashes"; +} diff --git a/src/utils/decentralize/domain.ts b/src/utils/decentralize/domain.ts new file mode 100644 index 0000000..2b3dd77 --- /dev/null +++ b/src/utils/decentralize/domain.ts @@ -0,0 +1,94 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Resolve the `.dot` label to deploy under — either the user's `--dot` (or + * typed input from the TUI), validated for availability, or an + * auto-generated name derived from the site URL. + * + * Pure logic (no React/Ink) so both the headless `dot decentralize --site=...` + * path and the interactive `validate-domain` stage can share it. + */ + +import { withSpan } from "../../telemetry.js"; +import { type Env } from "../../config.js"; +import { checkDomainAvailability, formatAvailability } from "../deploy/availability.js"; +import { normalizeDomain } from "../deploy/playground.js"; +import type { ResolvedSigner } from "../signer.js"; +import { findAvailableRandomName } from "./randomName.js"; + +export interface ResolveDomainOptions { + env: Env; + /** When set, treated as the requested label/full-domain (with or without `.dot`). */ + providedDot: string | undefined | null; + /** Source site URL — required when `providedDot` is empty (drives the auto-name). */ + siteUrl: string; + /** Used for "already owned by you" availability detection. */ + signer: ResolvedSigner | null; + /** Optional progress sink (TUI surfaces these as a single line). */ + onMessage?: (message: string) => void; +} + +export interface ResolvedDomain { + label: string; + fullDomain: string; + /** Advisory note from the availability check (e.g. PoP requirement). */ + note: string | null; +} + +export async function resolveDomain(opts: ResolveDomainOptions): Promise { + const { env, providedDot, siteUrl, signer, onMessage } = opts; + + if (providedDot) { + const normalized = normalizeDomain(providedDot); + onMessage?.(`\n▸ Checking ${normalized.fullDomain}…`); + const availability = await withSpan( + "cli.decentralize.availability", + "check domain availability", + () => + checkDomainAvailability(normalized.label, { + env, + ownerSs58Address: signer?.address, + }), + ); + if (availability.status === "reserved" || availability.status === "taken") { + throw new Error(formatAvailability(availability)); + } + if (availability.status === "unknown") { + onMessage?.(`\n⚠ ${formatAvailability(availability)} — continuing anyway.`); + } + const note = + availability.status === "available" && availability.note ? availability.note : null; + return { label: normalized.label, fullDomain: normalized.fullDomain, note }; + } + + onMessage?.(`\n▸ Picking a free .dot name from ${siteUrl}…`); + const chosen = await withSpan( + "cli.decentralize.random-name", + "find available random name", + () => + findAvailableRandomName({ + env, + ownerSs58Address: signer?.address, + siteUrl, + }), + ); + onMessage?.(` → ${chosen.availability.fullDomain}`); + return { + label: chosen.label, + fullDomain: chosen.availability.fullDomain, + note: null, + }; +} diff --git a/src/utils/decentralize/mirror.test.ts b/src/utils/decentralize/mirror.test.ts new file mode 100644 index 0000000..f41fa8b --- /dev/null +++ b/src/utils/decentralize/mirror.test.ts @@ -0,0 +1,179 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + countFiles, + findIndexHtmlRoot, + InvalidSiteUrlError, + mirrorSite, + validateUrl, + WgetMissingError, +} from "./mirror.js"; + +describe("validateUrl", () => { + it("accepts https URLs verbatim", () => { + expect(validateUrl("https://example.com")).toBe("https://example.com/"); + }); + + it("accepts http URLs verbatim", () => { + expect(validateUrl("http://example.com")).toBe("http://example.com/"); + }); + + it("prepends https:// to bare hostnames", () => { + expect(validateUrl("example.com")).toBe("https://example.com/"); + }); + + it("preserves path segments under a bare hostname", () => { + expect(validateUrl("you.github.io/site")).toBe("https://you.github.io/site"); + }); + + it("rejects ftp:// with a precise reason", () => { + expect(() => validateUrl("ftp://example.com")).toThrow(InvalidSiteUrlError); + expect(() => validateUrl("ftp://example.com")).toThrow(/unsupported scheme ftp:/); + }); + + it("rejects file:// (defends against `--site=file:///etc/passwd`)", () => { + expect(() => validateUrl("file:///etc/passwd")).toThrow(InvalidSiteUrlError); + }); + + it("rejects unparseable input", () => { + expect(() => validateUrl("::: not a url :::")).toThrow(InvalidSiteUrlError); + expect(() => validateUrl("::: not a url :::")).toThrow(/not a parseable URL/); + }); +}); + +describe("countFiles", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mirror-countfiles-test-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("returns 0 for an empty directory", () => { + expect(countFiles(dir)).toBe(0); + }); + + it("counts files at the root", () => { + writeFileSync(join(dir, "a.txt"), "a"); + writeFileSync(join(dir, "b.txt"), "b"); + expect(countFiles(dir)).toBe(2); + }); + + it("walks subdirectories", () => { + writeFileSync(join(dir, "root.html"), ""); + mkdirSync(join(dir, "assets")); + writeFileSync(join(dir, "assets", "style.css"), "body{}"); + mkdirSync(join(dir, "assets", "img")); + writeFileSync(join(dir, "assets", "img", "logo.svg"), ""); + expect(countFiles(dir)).toBe(3); + }); + + it("does not count directories", () => { + mkdirSync(join(dir, "empty")); + mkdirSync(join(dir, "another")); + expect(countFiles(dir)).toBe(0); + }); + + it("returns 0 when the path does not exist (defensive)", () => { + expect(countFiles(join(dir, "this-does-not-exist"))).toBe(0); + }); +}); + +describe("findIndexHtmlRoot", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "mirror-findroot-test-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("returns the root when index.html sits at the top level", () => { + // The shawntabrizi.com case: `https://host/` mirror, no path segments. + writeFileSync(join(dir, "index.html"), ""); + writeFileSync(join(dir, "style.css"), "body{}"); + expect(findIndexHtmlRoot(dir)).toBe(dir); + }); + + it("resolves down through one path segment (the user's reported case)", () => { + // Reproduces `https://you.github.io/visualise-agents/` → + // wget writes to `/visualise-agents/index.html`. + mkdirSync(join(dir, "visualise-agents")); + writeFileSync(join(dir, "visualise-agents", "index.html"), ""); + writeFileSync(join(dir, "visualise-agents", "page.html"), ""); + expect(findIndexHtmlRoot(dir)).toBe(join(dir, "visualise-agents")); + }); + + it("resolves down through deeper paths", () => { + // Multi-segment URL paths like `https://host/team/project/`. + mkdirSync(join(dir, "team")); + mkdirSync(join(dir, "team", "project")); + writeFileSync(join(dir, "team", "project", "index.html"), ""); + expect(findIndexHtmlRoot(dir)).toBe(join(dir, "team", "project")); + }); + + it("picks the shallowest index.html when several exist (BFS)", () => { + // `/index.html` AND `/sub/index.html` — return the outer one + // so the user's chosen page wins over any sub-page index. + writeFileSync(join(dir, "index.html"), "outer"); + mkdirSync(join(dir, "sub")); + writeFileSync(join(dir, "sub", "index.html"), "inner"); + expect(findIndexHtmlRoot(dir)).toBe(dir); + }); + + it("returns null when no index.html exists anywhere", () => { + // Edge case: a fully-client-rendered SPA mirror with only asset files. + writeFileSync(join(dir, "style.css"), "body{}"); + mkdirSync(join(dir, "assets")); + writeFileSync(join(dir, "assets", "logo.svg"), ""); + expect(findIndexHtmlRoot(dir)).toBeNull(); + }); +}); + +describe("mirrorSite (spawn-injected)", () => { + it("maps spawn ENOENT to WgetMissingError so users get a clear install hint", async () => { + // Point at a path that cannot exist on any sane system. Node's + // child_process emits `error` with `code: "ENOENT"` synchronously, + // which mirror.ts maps to WgetMissingError. + await expect( + mirrorSite({ + url: "https://example.com", + wgetBinary: "/this/binary/definitely/does/not/exist-12345", + }), + ).rejects.toThrow(WgetMissingError); + }); + + it("rejects the empty-mirror case (wget exits 0 but writes no files)", async () => { + // `/usr/bin/true` exits 0 immediately and writes nothing. mirror.ts + // then sees fileCount === 0 from the temp dir and surfaces the + // "no files were downloaded" error. + await expect( + mirrorSite({ + url: "https://example.com", + wgetBinary: "/usr/bin/true", + }), + ).rejects.toThrow(/no files were downloaded/); + }); +}); diff --git a/src/utils/decentralize/mirror.ts b/src/utils/decentralize/mirror.ts index 323806d..f839073 100644 --- a/src/utils/decentralize/mirror.ts +++ b/src/utils/decentralize/mirror.ts @@ -23,12 +23,28 @@ export interface MirrorOptions { url: string; /** Optional callback for streaming wget output, one line at a time. */ onLine?: (line: string) => void; + /** + * @internal Override the binary that gets spawned. Tests use this to point + * at a deliberately-missing path (to exercise the `WgetMissingError` + * branch) or at `/usr/bin/true` (to exercise the empty-mirror branch + * without making a network request). Production callers leave this unset. + */ + wgetBinary?: string; } export interface MirrorResult { - /** Absolute path to the temp directory containing the mirrored site. */ + /** Absolute path to the temp directory wget wrote into. Owned by the + * caller — passed to `rm -rf` once the upload finishes. */ directory: string; - /** Number of files written under `directory`. */ + /** + * Directory to actually upload — the parent of the shallowest + * `index.html`. Equals `directory` when the URL has no path (`/`); for + * URLs like `https://host/foo/bar/`, wget writes to `directory/foo/bar/` + * because `--no-host-directories` strips only the hostname segment, so + * we resolve down to the actual document root before handing off. + */ + uploadRoot: string; + /** Number of files written under `directory` (NOT `uploadRoot`). */ fileCount: number; } @@ -49,7 +65,12 @@ export class InvalidSiteUrlError extends Error { } } -function validateUrl(input: string): string { +/** + * Normalise a user-typed site URL into the canonical `http(s)://…` form that + * `wget` will accept. Exported so the TUI and unit tests can validate + * candidate input without going through the whole mirror pipeline. + */ +export function validateUrl(input: string): string { let parsed: URL; try { parsed = new URL(input); @@ -67,7 +88,49 @@ function validateUrl(input: string): string { return parsed.toString(); } -function countFiles(root: string): number { +/** + * BFS for the directory containing the shallowest `index.html`. Used as + * the upload root so Bulletin's renderer always sees `index.html` at the + * top level regardless of URL path depth. + * + * Root cause this guards against: `wget --no-host-directories` strips only + * the hostname segment, so `https://host/foo/bar/` writes + * `/foo/bar/index.html` — not `/index.html`. Uploading the wget + * directory verbatim would put a directory at the IPFS root with no + * document, producing "Archive missing index.html" at view time. + * + * Returns `null` when no `index.html` exists anywhere in the tree (e.g. + * dynamic sites that need server-side rendering); callers should surface + * that to the user rather than upload an unrenderable archive. + */ +export function findIndexHtmlRoot(rootDir: string): string | null { + const queue: string[] = [rootDir]; + while (queue.length > 0) { + const dir = queue.shift()!; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + if (entries.includes("index.html")) return dir; + for (const entry of entries) { + const full = join(dir, entry); + try { + if (statSync(full).isDirectory()) queue.push(full); + } catch { + // dangling symlink / permission error — skip + } + } + } + return null; +} + +/** + * Recursive file count under `root`. Used after a wget run to detect the + * empty-mirror case (success exit, zero files). Exported for tests. + */ +export function countFiles(root: string): number { let count = 0; const walk = (dir: string) => { for (const entry of readdirSync(dir)) { @@ -117,7 +180,9 @@ export async function mirrorSite(options: MirrorOptions): Promise ]; await new Promise((resolve, reject) => { - const proc = spawn("wget", args, { stdio: ["ignore", "pipe", "pipe"] }); + const proc = spawn(options.wgetBinary ?? "wget", args, { + stdio: ["ignore", "pipe", "pipe"], + }); proc.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") reject(new WgetMissingError()); @@ -145,5 +210,13 @@ export async function mirrorSite(options: MirrorOptions): Promise `wget completed but no files were downloaded from ${url}. The site may be empty or block crawlers.`, ); } - return { directory, fileCount }; + const uploadRoot = findIndexHtmlRoot(directory); + if (!uploadRoot) { + throw new Error( + `wget downloaded ${fileCount} files from ${url} but none was index.html. ` + + "Bulletin's viewer needs an index.html at the root — the site may be " + + "fully client-side-rendered or served from a redirect.", + ); + } + return { directory, uploadRoot, fileCount }; } diff --git a/src/utils/decentralize/run.test.ts b/src/utils/decentralize/run.test.ts new file mode 100644 index 0000000..1f07466 --- /dev/null +++ b/src/utils/decentralize/run.test.ts @@ -0,0 +1,37 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, it } from "vitest"; +import { describeDeployEvent } from "./run.js"; + +describe("describeDeployEvent", () => { + it("renders chunk-progress as a human-readable upload line", () => { + expect(describeDeployEvent({ kind: "chunk-progress", current: 3, total: 7 })).toBe( + "uploading chunk 3/7", + ); + }); + + it("passes info messages through verbatim", () => { + expect(describeDeployEvent({ kind: "info", message: "reserving domain" })).toBe( + "reserving domain", + ); + }); + + it("drops phase-start banners (step rows / phase headers convey those)", () => { + // This is the bug the rewrite fixed: phase banners used to surface as + // the raw "phase-start" string in the log tail. + expect(describeDeployEvent({ kind: "phase-start", phase: "storage" })).toBeNull(); + }); +}); diff --git a/src/utils/decentralize/run.ts b/src/utils/decentralize/run.ts new file mode 100644 index 0000000..a6cbb0b --- /dev/null +++ b/src/utils/decentralize/run.ts @@ -0,0 +1,293 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Pure runner for `dot decentralize` — mirrors a live site, uploads it via + * `runStorageDeploy` (Bulletin chunked store + DotNS register), and + * optionally publishes a minimal AppInfo entry to the playground registry. + * + * Signer matrix mirrors `dot deploy`: callers pass `(mode, userSigner)` and + * the runner threads them through `resolveSignerSetup` so dev-mode-with- + * session correctly records the user's H160 as `owner` while the dev key + * signs the on-chain phases. + * + * No React/Ink imports — this file lives under `src/utils/decentralize/*` + * which the RevX WebContainer consumes as the SDK surface. + */ + +import { rmSync } from "node:fs"; +import { getChainConfig, type Env } from "../../config.js"; +import { publishToPlayground } from "../deploy/playground.js"; +import type { DeployLogEvent } from "../deploy/progress.js"; +import { type DeployApproval, resolveSignerSetup, type SignerMode } from "../deploy/signerMode.js"; +import { + createSigningCounter, + type SigningCounter, + type SigningEvent, + wrapSignerWithEvents, +} from "../deploy/signingProxy.js"; +import { runStorageDeploy } from "../deploy/storage.js"; +import type { ResolvedSigner } from "../signer.js"; +import { mirrorSite } from "./mirror.js"; + +export type DecentralizeLogEvent = + | { kind: "mirror-start"; url: string } + | { kind: "mirror-line"; line: string } + | { kind: "mirror-done"; fileCount: number; directory: string } + | { kind: "storage-start"; fullDomain: string } + | { kind: "storage-event"; event: DeployLogEvent } + | { kind: "storage-done"; cid: string } + | { kind: "playground-start"; fullDomain: string } + | { kind: "playground-event"; event: DeployLogEvent } + | { kind: "playground-done"; metadataCid: string } + // Phone-signing lifecycle — drives the "check your phone" callout. Only + // emitted in phone mode (dev signers sign in-process with no human tap). + | { kind: "signing"; event: SigningEvent }; + +/** + * Translate a bulletin-deploy `DeployLogEvent` into a single human-readable + * progress line. `chunk-progress` becomes "uploading chunk X/Y"; phase banners + * are dropped (the TUI's step rows / the headless phase headers convey those). + * Shared by the interactive RunningStage and the headless stdout path so both + * surfaces read the same — no raw `event.kind` dumps. + */ +export function describeDeployEvent(event: DeployLogEvent): string | null { + switch (event.kind) { + case "chunk-progress": + return `uploading chunk ${event.current}/${event.total}`; + case "info": + return event.message; + case "phase-start": + return null; + } +} + +export interface RunDecentralizeOptions { + siteUrl: string; + label: string; + fullDomain: string; + /** + * Mirrors deploy's signer contract. "phone" requires a session in + * `userSigner`; "dev" uses either the SURI-resolved signer (when + * `userSigner.source === "dev"`) or the bulletin-deploy default + * mnemonic, with the session's H160 claimed as owner when present. + */ + mode: SignerMode; + /** + * The user's existing signer — either a session (from `dot init`) or + * a SURI-resolved dev signer (when `--suri` was passed). `null` when + * neither exists; only valid for `mode: "dev"`. + */ + userSigner: ResolvedSigner | null; + /** + * When true, after the storage upload + DotNS register the runner + * publishes a minimal AppInfo entry to the playground registry. No + * `repository` is recorded (decentralized sites aren't moddable from + * GitHub) and `isModdable` is forced to false. + */ + publishToPlayground?: boolean; + env: Env; + onEvent?: (event: DecentralizeLogEvent) => void; +} + +export interface DecentralizeOutcome { + appUrl: string; + fullDomain: string; + ipfsCid: string; + gatewayUrl: string; + /** Present iff publishToPlayground was true and the publish succeeded. */ + metadataCid: string | null; + /** The actual signer source used to sign the on-chain phases. */ + signerSource: ResolvedSigner["source"]; + signerAddress: string; +} + +export async function runDecentralize( + options: RunDecentralizeOptions, +): Promise { + const { siteUrl, label, fullDomain, mode, userSigner, env, onEvent } = options; + const wantPlayground = options.publishToPlayground === true; + + // Compose the storage + publish identities through deploy's single + // source of truth. Same call shape as `runDeploy` so the mainnet rewrite + // (which lives in signerMode.ts) flows through unchanged. + const setup = resolveSignerSetup({ + mode, + userSigner, + publishToPlayground: wantPlayground, + }); + + // Pick the signer used for the DotNS register tx. bulletin-deploy + // accepts `{ signer, signerAddress }` or `{}` (falls back to its + // DEFAULT_MNEMONIC). Either way we surface a single visible address + // for the outcome. + const storageSignerAddress = + setup.bulletinDeployAuthOptions.signerAddress ?? + setup.publishSigner?.address ?? + // Defensive fallback: should never hit because dev mode synthesises + // a signer for the publish phase even when one isn't strictly + // needed; we keep the address visible to the user either way. + userSigner?.address ?? + "(bulletin-deploy default)"; + // Phone mode signs every on-chain phase with the session; dev mode always + // signs with a dev key (bulletin-deploy default mnemonic or `--suri`). + // This drives the "owned by a development account" callout — which speaks + // to DotNS *domain* ownership (dev-signed in dev mode regardless of any + // registry-level `claimedOwnerH160`). + const storageSignerSource: ResolvedSigner["source"] = mode === "phone" ? "session" : "dev"; + + // Shared counter across every phone tap (DotNS commitment/finalize/link + // + the optional playground publish) so the callout reads "step N of M". + const counter = createSigningCounter(setup.approvals.length); + const emitSigning = (event: SigningEvent) => onEvent?.({ kind: "signing", event }); + + let mirrorDir: string | null = null; + + try { + onEvent?.({ kind: "mirror-start", url: siteUrl }); + const mirror = await mirrorSite({ + url: siteUrl, + onLine: (line) => onEvent?.({ kind: "mirror-line", line }), + }); + mirrorDir = mirror.directory; + onEvent?.({ + kind: "mirror-done", + fileCount: mirror.fileCount, + directory: mirror.uploadRoot, + }); + + onEvent?.({ kind: "storage-start", fullDomain }); + const result = await runStorageDeploy({ + // Upload from the resolved index.html parent, NOT from + // `mirror.directory`. See `findIndexHtmlRoot` in mirror.ts. + content: mirror.uploadRoot, + domainName: label, + // Wrap the DotNS auth signer so each phone tap surfaces a + // "check your phone" lifecycle event. No-op in dev mode (auth + // has no signer — bulletin-deploy uses its default mnemonic). + auth: wrapAuthForSigning( + setup.bulletinDeployAuthOptions, + setup.approvals, + counter, + emitSigning, + ), + env, + onLogEvent: (event) => onEvent?.({ kind: "storage-event", event }), + }); + onEvent?.({ kind: "storage-done", cid: result.cid }); + + let metadataCid: string | null = null; + if (wantPlayground) { + if (!setup.publishSigner) { + // `resolveSignerSetup` always returns a `publishSigner` when + // `publishToPlayground: true` (constructs a dev signer when + // needed). If this ever fires, the matrix in signerMode.ts + // has drifted out from under us. + throw new Error( + "Internal error: resolveSignerSetup returned no publishSigner despite publishToPlayground=true", + ); + } + // Only wrap interactive (session) signers — a dev signer signs + // in-process with no human in the loop, so flashing "check your + // phone" would contradict the 0-taps reality. + const publishSigner = + setup.publishSigner.source === "session" + ? { + ...setup.publishSigner, + signer: wrapSignerWithEvents(setup.publishSigner.signer, { + label: "Publish to playground registry", + counter, + onEvent: emitSigning, + }), + } + : setup.publishSigner; + + onEvent?.({ kind: "playground-start", fullDomain }); + const publishResult = await publishToPlayground({ + domain: label, + publishSigner, + claimedOwnerH160: setup.claimedOwnerH160, + // Mirrored sites have no git source — `repository` is omitted + // from the metadata JSON and `is_moddable` is forced false. + repositoryUrl: null, + env, + isPrivate: false, + isModdable: false, + isDevSigner: setup.publishSigner.source === "dev", + onLogEvent: (event) => onEvent?.({ kind: "playground-event", event }), + }); + metadataCid = publishResult.metadataCid; + onEvent?.({ kind: "playground-done", metadataCid }); + } + + const cfg = getChainConfig(env); + return { + appUrl: `https://${fullDomain}.li`, + fullDomain, + ipfsCid: result.cid, + gatewayUrl: `${cfg.bulletinGateway}${result.cid}`, + metadataCid, + signerSource: storageSignerSource, + signerAddress: storageSignerAddress, + }; + } finally { + if (mirrorDir) { + try { + rmSync(mirrorDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup; tmpdir is OS-managed anyway + } + } + } +} + +/** + * Wrap the bulletin-deploy DotNS auth signer so each `signTx` call surfaces a + * "check your phone" lifecycle event labelled by the matching DotNS approval. + * Mirrors deploy's `maybeWrapAuthForSigning`. Returns `auth` unchanged when + * there's no signer (dev mode → bulletin-deploy uses its default mnemonic, + * no human tap). + */ +function wrapAuthForSigning( + auth: ReturnType["bulletinDeployAuthOptions"], + approvals: DeployApproval[], + counter: SigningCounter, + onEvent: (event: SigningEvent) => void, +) { + if (!auth.signer || !auth.signerAddress) return auth; + + const labels = approvals.filter((a) => a.phase === "dotns").map((a) => a.label); + const fallbackLabel = labels[labels.length - 1] ?? "DotNS step"; + const signer = auth.signer; + let seen = 0; + + return { + ...auth, + signer: { + publicKey: signer.publicKey, + signTx: (...args: Parameters) => { + const label = labels[seen] ?? fallbackLabel; + seen += 1; + return wrapSignerWithEvents(signer, { label, counter, onEvent }).signTx(...args); + }, + signBytes: (...args: Parameters) => + wrapSignerWithEvents(signer, { + label: "DotNS signBytes", + counter, + onEvent, + }).signBytes(...args), + }, + }; +} diff --git a/src/utils/deploy/signerMode.ts b/src/utils/deploy/signerMode.ts index 99c2215..abcf9d7 100644 --- a/src/utils/deploy/signerMode.ts +++ b/src/utils/deploy/signerMode.ts @@ -67,7 +67,17 @@ import type { DeployPlan } from "./availability.js"; const DEV_PUBLISH_ACCOUNT = seedToAccount(DEFAULT_MNEMONIC, ""); export const DEV_PUBLISH_ADDRESS = ss58Encode(DEV_PUBLISH_ACCOUNT.publicKey); -function createAliceSignerForDevPublish(): ResolvedSigner { +/** + * Construct a `ResolvedSigner` for bulletin-deploy's `DEFAULT_MNEMONIC` + * bare-root account. Used by deploy's dev-mode publish flow, and by + * `dot decentralize`'s interactive dev signer option — both keep + * storage / DotNS / registry signing coherent under one identity. + * + * Despite the historical "Alice" label in the test snapshot, this is NOT + * Substrate's `//Alice` (`5Grwva...`). It is bulletin-deploy's bare-root + * (`5DfhGyQd...`). See `signerModeAlice.test.ts` for the pin. + */ +export function createDevPublishSigner(): ResolvedSigner { return { signer: DEV_PUBLISH_ACCOUNT.signer, address: DEV_PUBLISH_ADDRESS, @@ -224,7 +234,7 @@ export function resolveSignerSetup(opts: ResolveOptions): DeploySignerSetup { // or nothing (Alice owns). Construct Alice fresh either way — // bulletin-deploy uses the same default mnemonic so all three // tx phases sign as the same on-chain identity. - publishSigner = createAliceSignerForDevPublish(); + publishSigner = createDevPublishSigner(); if (opts.userSigner?.source === "session") { claimedOwnerH160 = opts.userSigner.addresses?.productH160 ?? null; } diff --git a/src/utils/deploy/signerModeAlice.test.ts b/src/utils/deploy/signerModeAlice.test.ts index 9a2364d..ddb8273 100644 --- a/src/utils/deploy/signerModeAlice.test.ts +++ b/src/utils/deploy/signerModeAlice.test.ts @@ -54,7 +54,7 @@ describe("dev-mode publish signer identity", () => { it("signerMode.ts publishes as bulletin-deploy's DEFAULT_MNEMONIC bare-root account", () => { // The headline guarantee: signerMode's synthesised dev signer // address matches what bulletin-deploy uses for storage + DotNS. - // If a future change swaps `createAliceSignerForDevPublish` to + // If a future change swaps `createDevPublishSigner` to // `createDevSigner("Alice")` (= `//Alice` = `5Grwva…`), this // assertion fails. The previous-version of this test only // compared two `bulletin-deploy` constants against each other — @@ -66,7 +66,7 @@ describe("dev-mode publish signer identity", () => { // Upstream-equivalence pin. If `bulletin-deploy` swaps its // default mnemonic for any reason, this test surfaces the // change before we ship a broken dev flow. `seedToAccount(_, "")` - // is the exact derivation `createAliceSignerForDevPublish` uses, + // is the exact derivation `createDevPublishSigner` uses, // so verifying it here ALSO covers the SDK side of the // equivalence (a future product-sdk-keys release that changes // "no derivation" semantics would fail this).