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 new file mode 100644 index 0000000..7d81fce --- /dev/null +++ b/src/commands/decentralize/index.ts @@ -0,0 +1,314 @@ +// 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 # 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 + * + * 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 React from "react"; +import { render } from "ink"; +import { runCliCommand } from "../../cli-runtime.js"; +import { errorMessage, withSpan } from "../../telemetry.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; + 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", + ) + .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.", + ) + .option("--env ", "Target environment (default: paseo-next-v2)", DEFAULT_ENV) + .option( + "--suri ", + "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 }); + } + }), + ); + +// ── Headless path (preserves the existing dot decentralize --site=... contract) ─ + +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; + } + 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/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/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 new file mode 100644 index 0000000..f839073 --- /dev/null +++ b/src/utils/decentralize/mirror.ts @@ -0,0 +1,222 @@ +// 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; + /** + * @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 wget wrote into. Owned by the + * caller — passed to `rm -rf` once the upload finishes. */ + directory: string; + /** + * 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; +} + +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"; + } +} + +/** + * 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); + } 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(); +} + +/** + * 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)) { + 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(options.wgetBinary ?? "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.`, + ); + } + 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/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 new file mode 100644 index 0000000..517f845 --- /dev/null +++ b/src/utils/decentralize/randomName.ts @@ -0,0 +1,156 @@ +// 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"; + +/** + * 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" + */ +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 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 + * 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 = generateLabel(options.siteUrl); + 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}`, + ); +} 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). 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", + }, ];