diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts new file mode 100644 index 0000000..e91792d --- /dev/null +++ b/src/commands/decentralize/index.ts @@ -0,0 +1,184 @@ +// 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 + * + * 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. + */ + +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; +} + +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 (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); + + let signer: ResolvedSigner | null = null; + let mirrorDir: string | 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`); + + // ── 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 .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 + } + } + } + }), + ); diff --git a/src/index.ts b/src/index.ts index c023a45..a1b6e5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import pkg from "../package.json" with { type: "json" }; import { initCommand } from "./commands/init/index.js"; import { modCommand } from "./commands/mod/index.js"; import { buildCommand } from "./commands/build.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"; @@ -127,6 +128,7 @@ program.addCommand(initCommand); program.addCommand(modCommand); program.addCommand(buildCommand); 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 94b08f8..4cb541e 100644 --- a/src/telemetry-config.ts +++ b/src/telemetry-config.ts @@ -46,7 +46,14 @@ export interface InternalContextSignals { branch?: string; } -export type CliCommandName = "init" | "deploy" | "mod" | "build" | "update" | "logout"; +export type CliCommandName = + | "init" + | "deploy" + | "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.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/toolchain.ts b/src/utils/toolchain.ts index 014d044..f6d2f47 100644 --- a/src/utils/toolchain.ts +++ b/src/utils/toolchain.ts @@ -172,6 +172,24 @@ 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", + }, { name: "foundry (polkadot)", check: () => hasFoundryPolkadot(),