Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions src/commands/decentralize/index.ts
Original file line number Diff line number Diff line change
@@ -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>", "URL of the static site to clone (http/https)")
.option(
"--dot <name>",
"DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.",
)
.option("--env <env>", "Target environment (default: paseo-next-v2)", DEFAULT_ENV)
.option(
"--suri <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
}
}
}
}),
);
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
9 changes: 8 additions & 1 deletion src/telemetry-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>;
Expand Down
149 changes: 149 additions & 0 deletions src/utils/decentralize/mirror.ts
Original file line number Diff line number Diff line change
@@ -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<MirrorResult> {
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<void>((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 };
}
Loading
Loading