diff --git a/.changeset/decentralize-path-moddable.md b/.changeset/decentralize-path-moddable.md
new file mode 100644
index 00000000..c0b549a1
--- /dev/null
+++ b/.changeset/decentralize-path-moddable.md
@@ -0,0 +1,5 @@
+---
+"playground-cli": minor
+---
+
+`playground decentralize --path` can now publish moddable apps: the interactive flow asks "let others remix (mod) this app?" when publishing a local directory to the playground (with the same git-origin preflight and recovery menu as `playground deploy`), and headless mode accepts a `--moddable` flag. Publishing a local directory also inlines the project's README.md as the app's playground detail page — resolved from the enclosing git repo root, so it's found even when `--path` points at a build dir like `./dist` (the same anchor the moddable git-origin preflight walks up to) — and the TUI now says so up front at the publish prompt. Mirrored URL sites are unchanged (no git source — never moddable, no README).
diff --git a/.changeset/sharp-llamas-design.md b/.changeset/sharp-llamas-design.md
new file mode 100644
index 00000000..b7de520c
--- /dev/null
+++ b/.changeset/sharp-llamas-design.md
@@ -0,0 +1,5 @@
+---
+"playground-cli": minor
+---
+
+Interactive `playground decentralize` can now deploy a local directory: a new first prompt asks whether to mirror a live URL or upload an already-built static site (e.g. `./dist`), then both flows share the same signer/domain/publish steps. The confirm screen shows the resolved upload root and file count for local directories.
diff --git a/.changeset/wild-pears-shake.md b/.changeset/wild-pears-shake.md
new file mode 100644
index 00000000..d60f2aa2
--- /dev/null
+++ b/.changeset/wild-pears-shake.md
@@ -0,0 +1,5 @@
+---
+"playground-cli": minor
+---
+
+`playground decentralize --path
` deploys a local static directory (e.g. `./dist`) to Bulletin + DotNS without mirroring a URL. Mutually exclusive with `--site`; auto-generated `.dot` names derive from the directory basename.
diff --git a/src/commands/decentralize/DecentralizeScreen.tsx b/src/commands/decentralize/DecentralizeScreen.tsx
index c355cff4..c6d1bf98 100644
--- a/src/commands/decentralize/DecentralizeScreen.tsx
+++ b/src/commands/decentralize/DecentralizeScreen.tsx
@@ -47,13 +47,24 @@ import { PLAYGROUND_TAGS } from "../../utils/deploy/tags.js";
import { decentralizeSignerOptions, decentralizeSignerInitialIndex } from "./signerPrompt.js";
import type { SigningEvent } from "../../utils/deploy/signingProxy.js";
import { resolveDomain } from "../../utils/decentralize/domain.js";
+import { prepareLocalDirectory, type LocalSiteResult } from "../../utils/decentralize/local.js";
import { FREE_DOMAIN_SUFFIX_NOTE } from "../../utils/decentralize/randomName.js";
import {
describeDeployEvent,
+ LARGE_SITE_FILE_THRESHOLD,
runDecentralize,
type DecentralizeOutcome,
+ type DecentralizeSource,
} from "../../utils/decentralize/run.js";
-import { pickNextStage, validateDomainInput, validateSiteUrlInput, type Stage } from "./state.js";
+import { ModdableErrorStage, ModdablePreflightStage } from "../deploy/ModdableStages.js";
+import {
+ pickNextStage,
+ validateDomainInput,
+ validateLocalPathInput,
+ validateSiteUrlInput,
+ type SourceKind,
+ type Stage,
+} from "./state.js";
/**
* What the screen reports back when it unmounts. The host (`runInteractive`)
@@ -85,6 +96,13 @@ export interface DecentralizeScreenProps {
* `undefined` means the tag prompt is shown when publishing.
*/
initialTag?: string;
+ /**
+ * Pre-set when `--moddable` was passed on the CLI: skips the moddable
+ * prompt and drives straight into the git-origin preflight (only if the
+ * user ends up in the path + publish flow — URL mode ignores it). `null`
+ * means the prompt is shown.
+ */
+ initialModdable: boolean | null;
onDone: (result: DecentralizeResult) => void;
}
@@ -96,9 +114,17 @@ export function DecentralizeScreen({
sessionSigner,
initialPublishToPlayground,
initialTag,
+ initialModdable,
onDone,
}: DecentralizeScreenProps) {
+ // A caller-provided site URL pre-selects the URL flow (vestigial today —
+ // `--site` forces headless — but kept so the prop keeps meaning something).
+ const [sourceKind, setSourceKind] = useState(initialSiteUrl ? "url" : null);
const [siteUrl, setSiteUrl] = useState(initialSiteUrl);
+ const [localPath, setLocalPath] = useState(null);
+ // Captured at path submit so the confirm screen can show the resolved
+ // upload root + file count without re-walking the directory on render.
+ const [preparedLocal, setPreparedLocal] = useState(null);
// If --suri was passed, the user has effectively pre-chosen dev.
const [signerMode, setSignerMode] = useState(explicitSigner ? "dev" : null);
// Which signer option the cursor is on in the prompt-signer Select. Drives
@@ -120,31 +146,43 @@ export function DecentralizeScreen({
// Category tag (tri-state, mirroring deploy): `undefined` = not asked yet,
// `null` = explicitly skipped, a string = chosen. Pre-filled from `--tag`.
const [tag, setTag] = useState(initialTag);
+ const [moddable, setModdable] = useState(initialModdable);
+ const [repositoryUrl, setRepositoryUrl] = useState(null);
const [stage, setStage] = useState(() =>
pickNextStage({
+ sourceKind: initialSiteUrl ? "url" : null,
siteUrl: initialSiteUrl,
+ localPath: null,
signerMode: explicitSigner ? "dev" : null,
domainLabel: null,
domainRaw: initialDot,
publishToPlayground: initialPublishToPlayground,
tag: initialTag,
+ moddable: initialModdable,
+ repositoryUrl: null,
}),
);
const advance = (
next: Partial<{
+ sourceKind: SourceKind | null;
siteUrl: string | null;
+ localPath: string | null;
signerMode: SignerMode | null;
domainLabel: string | null;
domainRaw: string | null;
publishToPlayground: boolean | null;
tag: string | null;
+ moddable: boolean | null;
+ repositoryUrl: string | null;
}> = {},
) => {
setStage(
pickNextStage({
+ sourceKind: next.sourceKind !== undefined ? next.sourceKind : sourceKind,
siteUrl: next.siteUrl !== undefined ? next.siteUrl : siteUrl,
+ localPath: next.localPath !== undefined ? next.localPath : localPath,
signerMode: next.signerMode !== undefined ? next.signerMode : signerMode,
domainLabel: next.domainLabel !== undefined ? next.domainLabel : domainLabel,
domainRaw: next.domainRaw !== undefined ? next.domainRaw : domainRaw,
@@ -155,10 +193,21 @@ export function DecentralizeScreen({
// `undefined` is a meaningful tag value ("not asked"), so detect
// presence with `in` rather than the `!== undefined` sentinel.
tag: "tag" in next ? next.tag : tag,
+ moddable: next.moddable !== undefined ? next.moddable : moddable,
+ repositoryUrl:
+ next.repositoryUrl !== undefined ? next.repositoryUrl : repositoryUrl,
}),
);
};
+ // Single "user declined moddable" transition, shared by the remix prompt's
+ // "no" answer and the setup-error menu's "continue without moddable", so
+ // the two paths can't drift apart. Mirrors deploy's helper of the same name.
+ const declineModdable = () => {
+ setModdable(false);
+ advance({ moddable: false });
+ };
+
// 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
@@ -171,31 +220,85 @@ export function DecentralizeScreen({
return null;
}, [explicitSigner, signerMode, sessionSigner]);
+ // The resolved content source. Null until the picker + matching prompt
+ // are answered; stages past prompt-url/prompt-path only mount once set.
+ const source = useMemo(() => {
+ if (sourceKind === "url" && siteUrl !== null) return { kind: "url", url: siteUrl };
+ if (sourceKind === "path" && localPath !== null) {
+ return { kind: "path", directory: localPath };
+ }
+ return null;
+ }, [sourceKind, siteUrl, localPath]);
+
return (
- {stage.kind === "prompt-url" && (
+ {stage.kind === "prompt-source" && (
<>
- Mirrors a live static site (https URL) and republishes it as a .dot
- site. Large sites can take several minutes to download — press Ctrl+C
- any time to cancel.
+ Republishes a static site as a .dot site — either mirrored from a live
+ https URL or uploaded from a local build directory. Press Ctrl+C any
+ time to cancel.
+
+ label="source"
+ options={[
+ {
+ value: "url",
+ label: "live site (URL)",
+ hint: "mirror it with wget — large sites can take minutes",
+ },
+ {
+ value: "path",
+ label: "local directory",
+ hint: "upload an already-built static site, e.g. ./dist",
+ },
+ ]}
+ onSelect={(kind) => {
+ setSourceKind(kind);
+ advance({ sourceKind: kind });
+ }}
+ />
+ >
+ )}
+
+ {stage.kind === "prompt-url" && (
+ {
+ setSiteUrl(value);
+ advance({ siteUrl: value });
+ }}
+ />
+ )}
+
+ {stage.kind === "prompt-path" && (
+ <>
+
+ The directory must contain an index.html — a built static site like ./dist.
+ Files upload as-is (no build step runs).
+
{
- setSiteUrl(value);
- advance({ siteUrl: value });
+ // validate just accepted this path, so the second
+ // walk can't throw; it returns the resolved upload
+ // root + file count for the confirm screen.
+ setPreparedLocal(prepareLocalDirectory(value));
+ setLocalPath(value);
+ advance({ localPath: value });
}}
/>
>
@@ -239,7 +342,11 @@ export function DecentralizeScreen({
{
setDomainLabel(label);
@@ -282,13 +389,22 @@ export function DecentralizeScreen({
{stage.kind === "prompt-publish" && (
+ {sourceKind === "path" && (
+
+
+ If you publish, your project's README.md becomes your app's detail
+ page on the playground (found at your repo root, even when --path is
+ a build dir like ./dist). Make sure it's up to date.
+
+
+ )}
label="publish to the playground registry?"
options={[
{
value: true,
label: "yes",
- hint: "list the mirrored site in the playground apps tab",
+ hint: "list the site in the playground apps tab",
},
{
value: false,
@@ -305,6 +421,33 @@ export function DecentralizeScreen({
)}
+ {stage.kind === "prompt-moddable" && (
+
+ label="let others remix (mod) this app?"
+ options={[
+ {
+ value: true,
+ label: "yes",
+ hint: "record my public GitHub repo so others can `playground mod` it",
+ },
+ {
+ value: false,
+ label: "no",
+ hint: "keep my source private",
+ },
+ ]}
+ initialIndex={0}
+ onSelect={(yes) => {
+ if (yes) {
+ setModdable(true);
+ setStage({ kind: "moddable-preflight" });
+ } else {
+ declineModdable();
+ }
+ }}
+ />
+ )}
+
{stage.kind === "prompt-tags" && (
@@ -325,9 +468,31 @@ export function DecentralizeScreen({
)}
+ {stage.kind === "moddable-preflight" && (
+ {
+ setRepositoryUrl(url);
+ advance({ moddable: true, repositoryUrl: url });
+ }}
+ onError={(msg) => setStage({ kind: "moddable-error", message: msg })}
+ />
+ )}
+
+ {stage.kind === "moddable-error" && (
+ onDone({ kind: "cancel" })}
+ />
+ )}
+
{stage.kind === "confirm" && (
setStage({ kind: "running" })}
onCancel={() => onDone({ kind: "cancel" })}
/>
@@ -342,13 +508,14 @@ export function DecentralizeScreen({
{stage.kind === "running" && (
setStage({ kind: "done", outcome })}
onFailed={(message) => setStage({ kind: "error", message })}
@@ -377,7 +544,7 @@ export function DecentralizeScreen({
function ValidateDomainStage({
raw,
env,
- siteUrl,
+ source,
signer,
progressMessage,
onResolved,
@@ -386,7 +553,7 @@ function ValidateDomainStage({
}: {
raw: string;
env: Env;
- siteUrl: string;
+ source: DecentralizeSource;
signer: ResolvedSigner | null;
progressMessage: string | null;
onResolved: (result: {
@@ -405,7 +572,7 @@ function ValidateDomainStage({
const result = await resolveDomain({
env,
providedDot: raw || null,
- siteUrl,
+ source,
signer,
onMessage: (m) => {
if (!cancelled) onProgress(m.trim());
@@ -419,7 +586,7 @@ function ValidateDomainStage({
return () => {
cancelled = true;
};
- // We intentionally key on `raw` only — `signer`/`siteUrl` are stable
+ // We intentionally key on `raw` only — `signer`/`source` are stable
// for the lifetime of a single validate stage.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [raw]);
@@ -437,7 +604,8 @@ function ValidateDomainStage({
// ── Confirm stage ────────────────────────────────────────────────────────────
function ConfirmStage({
- siteUrl,
+ source,
+ preparedLocal,
fullDomain,
autoGenerated,
availabilityNote,
@@ -445,10 +613,13 @@ function ConfirmStage({
signerMode,
publishToPlayground,
tag,
+ repositoryUrl,
onConfirm,
onCancel,
}: {
- siteUrl: string;
+ source: DecentralizeSource;
+ /** Set when `source.kind === "path"` — resolved upload root + file count. */
+ preparedLocal: LocalSiteResult | null;
fullDomain: string;
autoGenerated: boolean;
availabilityNote: string | null;
@@ -456,13 +627,36 @@ function ConfirmStage({
signerMode: SignerMode;
publishToPlayground: boolean;
tag: string | null;
+ /** Resolved public GitHub URL when moddable was accepted; null otherwise. */
+ repositoryUrl: string | null;
onConfirm: () => void;
onCancel: () => void;
}) {
+ const steps = source.kind === "url" ? "mirror + upload + register" : "upload + register";
+ // For local dirs the file count is known up front — reuse the mirror
+ // flow's threshold to flag a long upload before the user commits.
+ const largeLocal =
+ source.kind === "path" &&
+ preparedLocal !== null &&
+ preparedLocal.fileCount >= LARGE_SITE_FILE_THRESHOLD;
return (
-
+ {source.kind === "url" ? (
+
+ ) : (
+
+ )}
+ {source.kind === "path" && publishToPlayground && (
+
+ )}
{/* Surface the chosen tag before the irreversible publish, like
deploy's confirm summary. Only shown when publishing — the
tag is otherwise irrelevant. */}
@@ -481,6 +682,13 @@ function ConfirmStage({
)}
{availabilityNote &&
}
+ {largeLocal && (
+
+ )}
{autoGenerated && {FREE_DOMAIN_SUFFIX_NOTE} }
@@ -489,9 +697,7 @@ function ConfirmStage({
{
value: "go",
label: "yes, decentralize it",
- hint: publishToPlayground
- ? "mirror + upload + register + publish"
- : "mirror + upload + register",
+ hint: publishToPlayground ? `${steps} + publish` : steps,
},
{ value: "cancel", label: "cancel", hint: "exit without changes" },
]}
@@ -517,24 +723,27 @@ function stepMark(status: StepStatus): MarkKind {
}
function RunningStage({
- siteUrl,
+ source,
label,
fullDomain,
mode,
userSigner,
publishToPlayground,
tag,
+ repositoryUrl,
env,
onComplete,
onFailed,
}: {
- siteUrl: string;
+ source: DecentralizeSource;
label: string;
fullDomain: string;
mode: SignerMode;
userSigner: ResolvedSigner | null;
publishToPlayground: boolean;
tag: string | null;
+ /** Preflighted public GitHub URL when moddable was accepted; null otherwise. */
+ repositoryUrl: string | null;
env: Env;
onComplete: (outcome: DecentralizeOutcome) => void;
onFailed: (message: string) => void;
@@ -574,13 +783,14 @@ function RunningStage({
(async () => {
try {
const outcome = await runDecentralize({
- siteUrl,
+ source,
label,
fullDomain,
mode,
userSigner,
publishToPlayground,
tag,
+ repositoryUrl,
env,
onEvent: (event) => {
switch (event.kind) {
@@ -599,6 +809,11 @@ function RunningStage({
setUploadStatus("running");
queueLog(`mirrored ${event.fileCount} files`);
break;
+ case "local-done":
+ setMirrorStatus("complete");
+ setUploadStatus("running");
+ queueLog(`prepared ${event.fileCount} files`);
+ break;
case "storage-start":
setUploadStatus("running");
break;
@@ -658,7 +873,11 @@ function RunningStage({
return (
-
+
{publishToPlayground && (
",
"URL of the static site to clone (http/https). Omit to launch the interactive TUI.",
)
+ .addOption(
+ new Option(
+ "--path ",
+ "Local directory containing a built static site (e.g. ./dist). Alternative to --site.",
+ ).conflicts("site"),
+ )
.option(
"--dot ",
"DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.",
@@ -111,10 +137,17 @@ export const decentralizeCommand = new Command("decentralize")
"Tag the published app so people can filter for it in the playground. Requires --playground.",
).choices([...PLAYGROUND_TAGS]),
)
+ .addOption(
+ new Option(
+ "--moddable",
+ "Record the public GitHub origin of --path's repo so others can " +
+ "`playground mod` it. Requires --path and --playground. Off by default.",
+ ).conflicts("site"),
+ )
.action(async (opts: DecentralizeOpts) =>
runCliCommand("decentralize", { hardExit: true }, async () => {
const env: Env = resolveLegacyEnv(opts.env);
- if (opts.site) {
+ if (opts.site || opts.path) {
await runHeadless({ env, opts });
} else {
await runInteractive({ env, opts });
@@ -136,16 +169,60 @@ async function runHeadless({
let signer: ResolvedSigner | null = null;
try {
+ // Fail fast on a bad --path before any signer/network work —
+ // otherwise the user waits out the domain availability check only to
+ // learn the directory doesn't exist. runDecentralize re-validates
+ // (prepareLocalDirectory is cheap and pure fs).
+ if (opts.path) prepareLocalDirectory(opts.path);
+
+ // Moddable preflight, same fail-fast rationale: resolve the public
+ // GitHub origin (git walks up from the --path directory) before any
+ // signer/chain work. `ModdablePreflightError`'s headless message
+ // already names the fix, so it propagates as-is. `--site` is blocked
+ // by commander's `.conflicts()`, so `opts.path` is set here.
+ let repositoryUrl: string | null = null;
+ if (opts.moddable) {
+ if (opts.playground !== true) {
+ throw new Error(
+ "--moddable requires --playground — the repo URL is recorded in the " +
+ "playground metadata, which is only published with --playground.",
+ );
+ }
+ await ensureGitInstalled();
+ try {
+ repositoryUrl = await resolveRepositoryUrl({
+ cwd: opts.path!,
+ onLog: (line) => process.stdout.write(` ${line}\n`),
+ });
+ } catch (err) {
+ // The headless message in moddable.ts names deploy's
+ // `--no-moddable` escape hatch, which this command doesn't
+ // have — use the surface-neutral copy + the right remedy.
+ if (err instanceof ModdablePreflightError) {
+ throw new Error(
+ `${err.interactiveMessage} Or omit --moddable to publish without source.`,
+ );
+ }
+ throw err;
+ }
+ }
+
signer = await withSpan("cli.decentralize.signer", "resolve signer", () =>
resolveSigner({ suri: opts.suri }),
);
process.stdout.write(`\n▸ Signing as ${signer.address} (${signer.source})\n`);
+ // The action gates headless on `opts.site || opts.path` and commander's
+ // `.conflicts()` rejects passing both, so exactly one is set here.
+ const source: DecentralizeSource = opts.path
+ ? { kind: "path", directory: opts.path }
+ : { kind: "url", url: opts.site! };
+
const { label, fullDomain } = await resolveDomain({
env,
providedDot: opts.dot,
- siteUrl: opts.site!,
+ source,
signer,
onMessage: (line) => process.stdout.write(`${line}\n`),
});
@@ -155,16 +232,19 @@ async function runHeadless({
const mode: SignerMode = signer.source === "session" ? "phone" : "dev";
process.stdout.write(
- `\n▸ Mirroring ${opts.site}… (large sites take a few minutes — press Ctrl+C to cancel)\n`,
+ source.kind === "url"
+ ? `\n▸ Mirroring ${source.url}… (large sites take a few minutes — press Ctrl+C to cancel)\n`
+ : `\n▸ Preparing ${source.directory}…\n`,
);
const outcome = await runDecentralize({
- siteUrl: opts.site!,
+ source,
label,
fullDomain,
mode,
userSigner: signer,
publishToPlayground: opts.playground === true,
tag: opts.tag ?? null,
+ repositoryUrl,
env,
onEvent: (ev) => {
switch (ev.kind) {
@@ -177,6 +257,7 @@ async function runHeadless({
);
break;
case "mirror-done":
+ case "local-done":
process.stdout.write(
` → ${ev.fileCount} files in ${ev.directory}\n` +
`\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`,
@@ -271,6 +352,7 @@ async function runInteractive({
sessionSigner: preflight.sessionSigner,
initialPublishToPlayground: opts.playground === true ? true : null,
initialTag: opts.tag,
+ initialModdable: opts.moddable === true ? true : null,
onDone: (result) => {
if (settled) return;
settled = true;
diff --git a/src/commands/decentralize/state.test.ts b/src/commands/decentralize/state.test.ts
index 1b831876..d79c6064 100644
--- a/src/commands/decentralize/state.test.ts
+++ b/src/commands/decentralize/state.test.ts
@@ -13,42 +13,83 @@
// 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";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, it, expect } from "vitest";
+import {
+ pickNextStage,
+ validateDomainInput,
+ validateLocalPathInput,
+ validateSiteUrlInput,
+ type PickStageInput,
+} from "./state.js";
+
+/** All-empty input; tests spread the fields a stage transition depends on. */
+const base: PickStageInput = {
+ sourceKind: null,
+ siteUrl: null,
+ localPath: null,
+ signerMode: null,
+ domainLabel: null,
+ domainRaw: null,
+ publishToPlayground: null,
+ moddable: null,
+ repositoryUrl: null,
+};
+
+/** A url-source flow answered up to (and including) the domain validation. */
+const urlThroughDomain: PickStageInput = {
+ ...base,
+ sourceKind: "url",
+ siteUrl: "https://example.com",
+ signerMode: "dev",
+ domainLabel: "myapp",
+ domainRaw: "myapp",
+};
+
+/** A path-source flow answered up to (and including) the domain validation. */
+const pathThroughDomain: PickStageInput = {
+ ...base,
+ sourceKind: "path",
+ localPath: "./dist",
+ signerMode: "dev",
+ domainLabel: "myapp",
+ domainRaw: "myapp",
+};
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("starts at prompt-source when nothing has been filled", () => {
+ expect(pickNextStage(base)).toEqual({ kind: "prompt-source" });
+ });
+
+ it("prompts for the URL once the url source is picked", () => {
+ expect(pickNextStage({ ...base, sourceKind: "url" })).toEqual({ kind: "prompt-url" });
+ });
+
+ it("prompts for the directory once the path source is picked", () => {
+ expect(pickNextStage({ ...base, sourceKind: "path" })).toEqual({ kind: "prompt-path" });
+ });
+
+ it("path flow joins the shared stages at prompt-signer", () => {
+ expect(pickNextStage({ ...base, sourceKind: "path", localPath: "./dist" })).toEqual({
+ kind: "prompt-signer",
+ });
});
it("advances to prompt-signer once the URL is known", () => {
expect(
- pickNextStage({
- siteUrl: "https://example.com",
- signerMode: null,
- domainLabel: null,
- domainRaw: null,
- publishToPlayground: null,
- }),
+ pickNextStage({ ...base, sourceKind: "url", siteUrl: "https://example.com" }),
).toEqual({ kind: "prompt-signer" });
});
it("advances to prompt-domain once URL + signer are picked", () => {
expect(
pickNextStage({
+ ...base,
+ sourceKind: "url",
siteUrl: "https://example.com",
signerMode: "dev",
- domainLabel: null,
- domainRaw: null,
- publishToPlayground: null,
}),
).toEqual({ kind: "prompt-domain" });
});
@@ -56,67 +97,40 @@ describe("pickNextStage", () => {
it("advances to validate-domain once domain has been typed but not yet validated", () => {
expect(
pickNextStage({
+ ...base,
+ sourceKind: "url",
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" });
+ expect(pickNextStage(urlThroughDomain)).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" });
+ expect(pickNextStage({ ...urlThroughDomain, publishToPlayground: false })).toEqual({
+ kind: "confirm",
+ });
});
it("asks for a tag when publishing and no --tag pre-filled it", () => {
- // publish=true + tag undefined (not asked yet) → the tag picker runs
- // before confirm, mirroring deploy.
- expect(
- pickNextStage({
- siteUrl: "https://example.com",
- signerMode: "dev",
- domainLabel: "myapp",
- domainRaw: "myapp",
- publishToPlayground: true,
- }),
- ).toEqual({ kind: "prompt-tags" });
+ // url source publishing: moddable is skipped (no git source), so the
+ // tag picker is the only publish-only follow-up before confirm.
+ expect(pickNextStage({ ...urlThroughDomain, publishToPlayground: true })).toEqual({
+ kind: "prompt-tags",
+ });
});
it("lands on confirm once a tag is chosen (or skipped) when publishing", () => {
// A resolved tag (a string OR an explicit null "skip") clears the last
// publish-only prompt.
for (const tag of ["defi", null] as const) {
- expect(
- pickNextStage({
- siteUrl: "https://example.com",
- signerMode: "dev",
- domainLabel: "myapp",
- domainRaw: "myapp",
- publishToPlayground: true,
- tag,
- }),
- ).toEqual({ kind: "confirm" });
+ expect(pickNextStage({ ...urlThroughDomain, publishToPlayground: true, tag })).toEqual({
+ kind: "confirm",
+ });
}
});
@@ -124,14 +138,72 @@ describe("pickNextStage", () => {
// Mirrors the user submitting a blank domain prompt to opt into auto-naming.
expect(
pickNextStage({
+ ...base,
+ sourceKind: "url",
siteUrl: "https://example.com",
signerMode: "dev",
- domainLabel: null,
domainRaw: "",
- publishToPlayground: null,
}),
).toEqual({ kind: "validate-domain", raw: "" });
});
+
+ // ── Moddable (path + publish only) ───────────────────────────────────────
+
+ it("asks the moddable question before the tag for a publishing path source", () => {
+ // Moddable is the first publish-only follow-up (tag still undefined),
+ // mirroring deploy's moddable → tag ordering.
+ expect(pickNextStage({ ...pathThroughDomain, publishToPlayground: true })).toEqual({
+ kind: "prompt-moddable",
+ });
+ });
+
+ it("never asks moddable for a url source — mirrored sites have no git source", () => {
+ // url + publish jumps straight to the tag prompt: moddable is skipped.
+ expect(pickNextStage({ ...urlThroughDomain, publishToPlayground: true })).toEqual({
+ kind: "prompt-tags",
+ });
+ });
+
+ it("never asks moddable when the path source is not publishing", () => {
+ expect(pickNextStage({ ...pathThroughDomain, publishToPlayground: false })).toEqual({
+ kind: "confirm",
+ });
+ });
+
+ it("declining moddable advances to the tag prompt", () => {
+ expect(
+ pickNextStage({ ...pathThroughDomain, publishToPlayground: true, moddable: false }),
+ ).toEqual({ kind: "prompt-tags" });
+ });
+
+ it("accepting moddable (or pre-answering via --moddable) drives into the preflight", () => {
+ expect(
+ pickNextStage({ ...pathThroughDomain, publishToPlayground: true, moddable: true }),
+ ).toEqual({ kind: "moddable-preflight" });
+ });
+
+ it("advances to the tag prompt once the preflight has resolved the repository URL", () => {
+ expect(
+ pickNextStage({
+ ...pathThroughDomain,
+ publishToPlayground: true,
+ moddable: true,
+ repositoryUrl: "https://github.com/acme/site",
+ }),
+ ).toEqual({ kind: "prompt-tags" });
+ });
+
+ it("lands on confirm once a publishing path source resolves both moddable and tag", () => {
+ expect(
+ pickNextStage({
+ ...pathThroughDomain,
+ publishToPlayground: true,
+ moddable: true,
+ repositoryUrl: "https://github.com/acme/site",
+ tag: null,
+ }),
+ ).toEqual({ kind: "confirm" });
+ });
});
describe("validateSiteUrlInput", () => {
@@ -198,3 +270,42 @@ describe("validateDomainInput", () => {
expect(validateDomainInput("my-app-42")).toMatch(/dash/i);
});
});
+
+describe("validateLocalPathInput", () => {
+ const tempDirs: string[] = [];
+
+ function makeTempDir(): string {
+ const dir = mkdtempSync(join(tmpdir(), "dot-state-path-test-"));
+ tempDirs.push(dir);
+ return dir;
+ }
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("accepts a directory containing an index.html", () => {
+ const dir = makeTempDir();
+ writeFileSync(join(dir, "index.html"), "");
+ expect(validateLocalPathInput(dir)).toBeNull();
+ });
+
+ it("rejects empty input", () => {
+ expect(validateLocalPathInput("")).toBe("enter a directory path");
+ expect(validateLocalPathInput(" ")).toBe("enter a directory path");
+ });
+
+ it("rejects a missing directory with prepareLocalDirectory's message", () => {
+ expect(validateLocalPathInput("/tmp/dot-state-path-test-does-not-exist")).toMatch(
+ /directory not found/,
+ );
+ });
+
+ it("states the index.html requirement for a directory without one", () => {
+ const dir = makeTempDir();
+ writeFileSync(join(dir, "main.js"), "console.log(1)");
+ expect(validateLocalPathInput(dir)).toMatch(/no index\.html found.*built static site/);
+ });
+});
diff --git a/src/commands/decentralize/state.ts b/src/commands/decentralize/state.ts
index e3f5e0f9..e97ab9c1 100644
--- a/src/commands/decentralize/state.ts
+++ b/src/commands/decentralize/state.ts
@@ -22,15 +22,25 @@
*/
import { validateDomainLabel } from "../../utils/deploy/dotnsRules.js";
+import { prepareLocalDirectory } from "../../utils/decentralize/local.js";
import type { DecentralizeOutcome } from "../../utils/decentralize/run.js";
import type { SignerMode } from "../../utils/deploy/signerMode.js";
+export type SourceKind = "url" | "path";
+
export type Stage =
+ | { kind: "prompt-source" }
| { kind: "prompt-url" }
+ | { kind: "prompt-path" }
| { kind: "prompt-signer" }
| { kind: "prompt-domain" }
| { kind: "validate-domain"; raw: string }
| { kind: "prompt-publish" }
+ | { kind: "prompt-moddable" }
+ | { kind: "moddable-preflight" }
+ // Entered imperatively by the screen when the preflight fails (missing /
+ // private / non-GitHub origin) — never returned by `pickNextStage`.
+ | { kind: "moddable-error"; message: string }
| { kind: "prompt-tags" }
| { kind: "confirm" }
| { kind: "running" }
@@ -38,7 +48,16 @@ export type Stage =
| { kind: "error"; message: string };
export interface PickStageInput {
+ /**
+ * Where the site content comes from: a live URL (mirrored) or a local
+ * build directory (uploaded as-is). `null` until the user picks in the
+ * source prompt; pre-set to `"url"` when the caller passed a site URL.
+ */
+ sourceKind: SourceKind | null;
+ /** Site URL once submitted. Only relevant when `sourceKind === "url"`. */
siteUrl: string | null;
+ /** Local directory once submitted. Only relevant when `sourceKind === "path"`. */
+ localPath: 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
@@ -56,6 +75,20 @@ export interface PickStageInput {
* `--playground` so the prompt is skipped.
*/
publishToPlayground: boolean | null;
+ /**
+ * Whether to record the path directory's public GitHub origin so others
+ * can `playground mod` the app. Only asked for `path` sources that
+ * publish to the playground (mirrored URL sites have no git source).
+ * `null` ⇒ not answered yet; pre-set to `true` when the caller passed
+ * `--moddable` so the prompt is skipped and the preflight runs directly.
+ */
+ moddable: boolean | null;
+ /**
+ * Public GitHub URL resolved by the moddable preflight. `null` until the
+ * preflight succeeds; `moddable === true` without it keeps the preflight
+ * stage up.
+ */
+ repositoryUrl: string | null;
/**
* Category tag for the playground listing (tri-state, mirroring deploy):
* `undefined` ⇒ not asked yet (the tag prompt runs when publishing);
@@ -68,24 +101,39 @@ export interface PickStageInput {
/**
* Decide which prompt stage to show next given the inputs collected so far.
- * URL → signer → domain → validate-domain → publish? → tag? → confirm. Each
- * missing piece surfaces its prompt; once everything is filled the `confirm`
- * stage gates the actual run. The tag prompt only runs when publishing and no
- * `--tag` pre-filled it (mirroring deploy).
+ * source → URL|path → signer → domain → validate-domain → publish? →
+ * moddable? (path-only) → tag? → confirm. Each missing piece surfaces its
+ * prompt; once everything is filled the `confirm` stage gates the actual run.
+ * Moddable and tag are publish-only follow-ups (mirroring deploy): moddable is
+ * asked first and only for `path` sources, then the tag prompt runs unless
+ * `--tag` pre-filled it.
*
* `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.sourceKind === null) return { kind: "prompt-source" };
+ if (input.sourceKind === "url" && input.siteUrl === null) return { kind: "prompt-url" };
+ if (input.sourceKind === "path" && input.localPath === null) return { kind: "prompt-path" };
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" };
- // Tag is the last publish-only choice: asked only when publishing and no
- // `--tag` flag already set it (`undefined` = not asked yet).
+ // Moddable applies only to local-directory publishes — a mirrored URL has
+ // no git source to record. The screen, not this picker, transitions to
+ // `moddable-error` when the preflight fails.
+ if (input.sourceKind === "path" && input.publishToPlayground === true) {
+ if (input.moddable === null) return { kind: "prompt-moddable" };
+ // --moddable via flag: skip the prompt, drive straight into preflight.
+ if (input.moddable === true && input.repositoryUrl === null) {
+ return { kind: "moddable-preflight" };
+ }
+ }
+ // Tag is the last publish-only choice: asked after the moddable decision is
+ // resolved, only when publishing and no `--tag` flag already set it
+ // (`undefined` = not asked yet).
if (input.publishToPlayground && input.tag === undefined) return { kind: "prompt-tags" };
return { kind: "confirm" };
}
@@ -108,6 +156,26 @@ export function validateSiteUrlInput(raw: string): string | null {
return "doesn't look like a URL";
}
+/**
+ * Inline TUI gate for the local-directory prompt. Delegates the real checks
+ * (exists, is a directory, contains an index.html somewhere) to
+ * `prepareLocalDirectory` — the same validation the run itself applies — and
+ * surfaces its actionable message inline. Sync fs is fine here: the theme
+ * `Input` runs `validate` only on submit, never per keystroke.
+ *
+ * Returns `null` when the input is acceptable, an error message otherwise.
+ */
+export function validateLocalPathInput(raw: string): string | null {
+ const trimmed = raw.trim();
+ if (!trimmed) return "enter a directory path";
+ try {
+ prepareLocalDirectory(trimmed);
+ return null;
+ } catch (err) {
+ return err instanceof Error ? err.message : String(err);
+ }
+}
+
/**
* Inline TUI gate for the domain prompt. Delegates to the canonical DotNS
* `validateDomainLabel` (same rules as `dot deploy` and `normalizeDomain`),
diff --git a/src/commands/deploy/DeployScreen.tsx b/src/commands/deploy/DeployScreen.tsx
index 542a01cf..87fb8631 100644
--- a/src/commands/deploy/DeployScreen.tsx
+++ b/src/commands/deploy/DeployScreen.tsx
@@ -58,11 +58,7 @@ import {
import type { ResolvedSigner } from "../../utils/signer.js";
import { DEFAULT_BUILD_DIR, getChainConfig, getNetworkLabel } from "../../config.js";
import { VERSION_LABEL } from "../../utils/version.js";
-import {
- ensureGitInstalled,
- ModdablePreflightError,
- resolveRepositoryUrl,
-} from "../../utils/deploy/moddable.js";
+import { ModdableErrorStage, ModdablePreflightStage } from "./ModdableStages.js";
import { PLAYGROUND_TAGS } from "../../utils/deploy/tags.js";
import { validateDomainLabel } from "../../utils/deploy/dotnsRules.js";
import {
@@ -674,113 +670,6 @@ function AckStage({ onContinue, onExit }: { onContinue: () => void; onExit: () =
);
}
-// ── Moddable preflight ────────────────────────────────────────────────────────
-
-function ModdablePreflightStage({
- projectDir,
- onResolved,
- onError,
-}: {
- projectDir: string;
- onResolved: (url: string) => void;
- onError: (message: string) => void;
-}) {
- const [status, setStatus] = useState("checking git…");
-
- useEffect(() => {
- let cancelled = false;
- (async () => {
- try {
- setStatus("ensuring git is installed…");
- await ensureGitInstalled();
- if (cancelled) return;
-
- setStatus("resolving repository…");
- const url = await resolveRepositoryUrl({
- cwd: projectDir,
- onLog: (line) => {
- if (!cancelled) setStatus(line);
- },
- });
- if (cancelled) return;
- onResolved(url);
- } catch (err) {
- if (cancelled) return;
- const message =
- err instanceof ModdablePreflightError
- ? err.interactiveMessage
- : err instanceof Error
- ? err.message
- : String(err);
- onError(message);
- }
- })();
- return () => {
- cancelled = true;
- };
- }, [projectDir]);
-
- return (
-
- );
-}
-
-type ModdableErrorChoice = "continue" | "exit";
-
-/**
- * Formal warning stage shown when the moddable preflight cannot proceed,
- * almost always because the user hasn't set up a public GitHub `origin` yet.
- * Renders the actionable error inside a yellow Callout (matching the
- * "check your phone" banner) so it visually registers as a setup requirement
- * rather than a deploy crash. Must never dead-end (#332): the menu offers
- * continuing as non-moddable or a graceful exit. Esc also exits, matching the
- * Ack and Confirm stages (and the previous incarnation of this screen).
- */
-function ModdableErrorStage({
- message,
- onContinueWithoutModdable,
- onExit,
-}: {
- message: string;
- onContinueWithoutModdable: () => void;
- onExit: () => void;
-}) {
- useInput((_input, key) => {
- if (key.escape) onExit();
- });
- return (
-
-
- {message}
-
-
-
- label="how do you want to continue?"
- options={[
- {
- value: "continue",
- label: "continue without moddable",
- hint: "publish, but keep my source private",
- },
- {
- value: "exit",
- label: "exit",
- hint: "set up GitHub first, re-run deploy later",
- },
- ]}
- initialIndex={0}
- onSelect={(choice) => {
- if (choice === "continue") onContinueWithoutModdable();
- else onExit();
- }}
- />
-
-
- );
-}
-
// ── Domain validation ────────────────────────────────────────────────────────
function ValidateDomainStage({
diff --git a/src/commands/deploy/ModdableStages.tsx b/src/commands/deploy/ModdableStages.tsx
new file mode 100644
index 00000000..719762c5
--- /dev/null
+++ b/src/commands/deploy/ModdableStages.tsx
@@ -0,0 +1,138 @@
+// 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.
+
+/**
+ * Shared moddable TUI stages — the async git-origin preflight and the
+ * "setup needed" recovery menu. Used by both `playground deploy` and
+ * `playground decentralize --path`'s interactive flows; the underlying
+ * checks live in `src/utils/deploy/moddable.ts` (no React there — these
+ * components are the Ink layer on top).
+ */
+
+import { useEffect, useState } from "react";
+import { Box, Text, useInput } from "ink";
+import { Callout, Row, Section, Select } from "../../utils/ui/theme/index.js";
+import {
+ ensureGitInstalled,
+ ModdablePreflightError,
+ resolveRepositoryUrl,
+} from "../../utils/deploy/moddable.js";
+
+// ── Moddable preflight ────────────────────────────────────────────────────────
+
+export function ModdablePreflightStage({
+ projectDir,
+ onResolved,
+ onError,
+}: {
+ projectDir: string;
+ onResolved: (url: string) => void;
+ onError: (message: string) => void;
+}) {
+ const [status, setStatus] = useState("checking git…");
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ setStatus("ensuring git is installed…");
+ await ensureGitInstalled();
+ if (cancelled) return;
+
+ setStatus("resolving repository…");
+ const url = await resolveRepositoryUrl({
+ cwd: projectDir,
+ onLog: (line) => {
+ if (!cancelled) setStatus(line);
+ },
+ });
+ if (cancelled) return;
+ onResolved(url);
+ } catch (err) {
+ if (cancelled) return;
+ const message =
+ err instanceof ModdablePreflightError
+ ? err.interactiveMessage
+ : err instanceof Error
+ ? err.message
+ : String(err);
+ onError(message);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [projectDir]);
+
+ return (
+
+ );
+}
+
+type ModdableErrorChoice = "continue" | "exit";
+
+/**
+ * Formal warning stage shown when the moddable preflight cannot proceed,
+ * almost always because the user hasn't set up a public GitHub `origin` yet.
+ * Renders the actionable error inside a yellow Callout (matching the
+ * "check your phone" banner) so it visually registers as a setup requirement
+ * rather than a deploy crash. Must never dead-end (#332): the menu offers
+ * continuing as non-moddable or a graceful exit. Esc also exits, matching the
+ * Ack and Confirm stages (and the previous incarnation of this screen).
+ */
+export function ModdableErrorStage({
+ message,
+ onContinueWithoutModdable,
+ onExit,
+}: {
+ message: string;
+ onContinueWithoutModdable: () => void;
+ onExit: () => void;
+}) {
+ useInput((_input, key) => {
+ if (key.escape) onExit();
+ });
+ return (
+
+
+ {message}
+
+
+
+ label="how do you want to continue?"
+ options={[
+ {
+ value: "continue",
+ label: "continue without moddable",
+ hint: "publish, but keep my source private",
+ },
+ {
+ value: "exit",
+ label: "exit",
+ hint: "set up GitHub first, re-run deploy later",
+ },
+ ]}
+ initialIndex={0}
+ onSelect={(choice) => {
+ if (choice === "continue") onContinueWithoutModdable();
+ else onExit();
+ }}
+ />
+
+
+ );
+}
diff --git a/src/utils/decentralize/domain.test.ts b/src/utils/decentralize/domain.test.ts
index d094ea8b..dfed64fe 100644
--- a/src/utils/decentralize/domain.test.ts
+++ b/src/utils/decentralize/domain.test.ts
@@ -49,7 +49,7 @@ describe("resolveDomain — autoGenerated flag + suffix note", () => {
const resolved = await resolveDomain({
env: "paseo-next-v2",
providedDot: "my-site",
- siteUrl: "https://example.com",
+ source: { kind: "url", url: "https://example.com" },
signer: null,
onMessage: (m) => messages.push(m),
});
@@ -68,7 +68,7 @@ describe("resolveDomain — autoGenerated flag + suffix note", () => {
const resolved = await resolveDomain({
env: "paseo-next-v2",
providedDot: null,
- siteUrl: "https://example.com",
+ source: { kind: "url", url: "https://example.com" },
signer: null,
onMessage: (m) => messages.push(m),
});
diff --git a/src/utils/decentralize/domain.ts b/src/utils/decentralize/domain.ts
index 74a42335..d9722880 100644
--- a/src/utils/decentralize/domain.ts
+++ b/src/utils/decentralize/domain.ts
@@ -16,7 +16,8 @@
/**
* 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.
+ * auto-generated name derived from the site source (URL hostname or local
+ * directory basename).
*
* Pure logic (no React/Ink) so both the headless `dot decentralize --site=...`
* path and the interactive `validate-domain` stage can share it.
@@ -27,14 +28,15 @@ 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 type { DecentralizeSource } from "./run.js";
import { findAvailableRandomName, FREE_DOMAIN_SUFFIX_NOTE } 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;
+ /** Site source (URL or local directory) — drives the auto-name when `providedDot` is empty. */
+ source: DecentralizeSource;
/** Used for "already owned by you" availability detection. */
signer: ResolvedSigner | null;
/** Optional progress sink (TUI surfaces these as a single line). */
@@ -55,7 +57,7 @@ export interface ResolvedDomain {
}
export async function resolveDomain(opts: ResolveDomainOptions): Promise {
- const { env, providedDot, siteUrl, signer, onMessage } = opts;
+ const { env, providedDot, source, signer, onMessage } = opts;
if (providedDot) {
const normalized = normalizeDomain(providedDot);
@@ -85,7 +87,8 @@ export async function resolveDomain(opts: ResolveDomainOptions): Promise {
+ const tempDirs: string[] = [];
+
+ function makeTempDir(): string {
+ const dir = mkdtempSync(join(tmpdir(), "dot-local-test-"));
+ tempDirs.push(dir);
+ return dir;
+ }
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects a missing path", () => {
+ expect(() => prepareLocalDirectory("/tmp/dot-local-test-does-not-exist")).toThrow(
+ InvalidLocalPathError,
+ );
+ expect(() => prepareLocalDirectory("/tmp/dot-local-test-does-not-exist")).toThrow(
+ /directory not found/,
+ );
+ });
+
+ it("rejects a path pointing at a file", () => {
+ const dir = makeTempDir();
+ const file = join(dir, "index.html");
+ writeFileSync(file, "");
+ expect(() => prepareLocalDirectory(file)).toThrow(/not a directory/);
+ });
+
+ it("rejects a directory with no index.html anywhere", () => {
+ const dir = makeTempDir();
+ writeFileSync(join(dir, "main.js"), "console.log(1)");
+ expect(() => prepareLocalDirectory(dir)).toThrow(/no index\.html found/);
+ });
+
+ it("uses the directory itself when index.html sits at the root", () => {
+ const dir = makeTempDir();
+ writeFileSync(join(dir, "index.html"), "");
+ writeFileSync(join(dir, "app.js"), "console.log(1)");
+ mkdirSync(join(dir, "assets"));
+ writeFileSync(join(dir, "assets", "style.css"), "body{}");
+
+ const result = prepareLocalDirectory(dir);
+ expect(result.uploadRoot).toBe(dir);
+ expect(result.fileCount).toBe(3);
+ });
+
+ it("descends to the shallowest index.html so it lands at the upload root", () => {
+ // Mirrors `findIndexHtmlRoot`'s contract from the wget flow: Bulletin's
+ // renderer needs index.html at the top level of the uploaded tree.
+ const dir = makeTempDir();
+ const nested = join(dir, "site");
+ mkdirSync(nested);
+ writeFileSync(join(nested, "index.html"), "");
+
+ const result = prepareLocalDirectory(dir);
+ expect(result.uploadRoot).toBe(nested);
+ expect(result.fileCount).toBe(1);
+ });
+
+ it("resolves a relative path to an absolute upload root", () => {
+ const dir = makeTempDir();
+ writeFileSync(join(dir, "index.html"), "");
+ const previousCwd = process.cwd();
+ try {
+ process.chdir(dir);
+ const result = prepareLocalDirectory(".");
+ // Compare against process.cwd() rather than `dir`: chdir resolves
+ // the macOS tmpdir symlink (/var → /private/var) and `resolve(".")`
+ // builds on cwd, so the two always agree while `dir` may not.
+ expect(result.uploadRoot).toBe(process.cwd());
+ expect(result.fileCount).toBe(1);
+ } finally {
+ process.chdir(previousCwd);
+ }
+ });
+});
+
+describe("findProjectRoot", () => {
+ const tempDirs: string[] = [];
+
+ function makeTempDir(): string {
+ // realpathSync collapses the macOS /var → /private/var symlink so the
+ // returned root matches `findProjectRoot`'s `resolve()`-based output.
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "dot-projroot-test-")));
+ tempDirs.push(dir);
+ return dir;
+ }
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("returns the directory itself when it contains a .git", () => {
+ const dir = makeTempDir();
+ mkdirSync(join(dir, ".git"));
+ expect(findProjectRoot(dir)).toBe(dir);
+ });
+
+ it("walks up to the repo root from a build subdirectory (e.g. ./dist)", () => {
+ // The common case: --path points at the build output, the README and
+ // .git live one level up at the project root.
+ const root = makeTempDir();
+ mkdirSync(join(root, ".git"));
+ const dist = join(root, "dist");
+ mkdirSync(dist);
+ expect(findProjectRoot(dist)).toBe(root);
+ });
+
+ it("treats a .git FILE (linked worktree) as a repo root", () => {
+ const dir = makeTempDir();
+ writeFileSync(join(dir, ".git"), "gitdir: /elsewhere/.git/worktrees/x");
+ expect(findProjectRoot(dir)).toBe(dir);
+ });
+
+ it("falls back to the resolved directory when no .git ancestor exists", () => {
+ const dir = makeTempDir();
+ const nested = join(dir, "a", "b");
+ mkdirSync(nested, { recursive: true });
+ expect(findProjectRoot(nested)).toBe(nested);
+ });
+});
diff --git a/src/utils/decentralize/local.ts b/src/utils/decentralize/local.ts
new file mode 100644
index 00000000..9af2fb0f
--- /dev/null
+++ b/src/utils/decentralize/local.ts
@@ -0,0 +1,103 @@
+// 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.
+
+/**
+ * Prepare an already-on-disk static site for `dot decentralize --path `.
+ *
+ * The local-path flow skips `mirrorSite` (no wget) and enters the pipeline at
+ * the same seam the URL flow does: a directory handed to `runStorageDeploy`.
+ * This module owns the validation between "user typed a path" and that seam.
+ *
+ * No React/Ink imports — `src/utils/decentralize/*` is part of the SDK
+ * surface RevX consumes from a WebContainer.
+ */
+
+import { existsSync, statSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { countFiles, findIndexHtmlRoot } from "./mirror.js";
+
+export class InvalidLocalPathError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "InvalidLocalPathError";
+ }
+}
+
+export interface LocalSiteResult {
+ /**
+ * Parent of the shallowest `index.html` under the given directory — the
+ * directory to upload, so Bulletin's renderer sees `index.html` at the
+ * top level (same rule as `findIndexHtmlRoot` in the mirror flow).
+ */
+ uploadRoot: string;
+ fileCount: number;
+}
+
+/**
+ * Resolve and validate a local directory as an uploadable static site.
+ * Throws `InvalidLocalPathError` with an actionable message when the path is
+ * missing, not a directory, or contains no `index.html` anywhere.
+ */
+export function prepareLocalDirectory(path: string): LocalSiteResult {
+ const abs = resolve(path);
+
+ let stat;
+ try {
+ stat = statSync(abs);
+ } catch {
+ throw new InvalidLocalPathError(`directory not found: ${abs}`);
+ }
+ if (!stat.isDirectory()) {
+ throw new InvalidLocalPathError(
+ `not a directory: ${abs} — point --path at a built static site directory, e.g. ./dist`,
+ );
+ }
+
+ const uploadRoot = findIndexHtmlRoot(abs);
+ if (!uploadRoot) {
+ throw new InvalidLocalPathError(
+ `no index.html found under ${abs} — point --path at a built static site, e.g. ./dist`,
+ );
+ }
+
+ return { uploadRoot, fileCount: countFiles(uploadRoot) };
+}
+
+/**
+ * Walk up from `startDir` to the enclosing git repository root (the first
+ * ancestor — or `startDir` itself — containing a `.git` entry). Falls back to
+ * the resolved `startDir` when no `.git` is found.
+ *
+ * `--path` typically points at a build output (`./dist`) whose README.md lives
+ * at the project root, not in the build dir. Resolving the repo root here lets
+ * `publishToPlayground` inline the project's README as the app detail page —
+ * the same anchor the moddable preflight walks up to for the git origin
+ * (`git remote get-url origin` resolves from any subdirectory), so README and
+ * `repository` metadata stay consistent. Matches `deploy`, which passes its
+ * project root (not the build dir) as the README `cwd`.
+ *
+ * `.git` is matched by existence, not type, so linked worktrees (where `.git`
+ * is a file, not a directory) resolve correctly.
+ */
+export function findProjectRoot(startDir: string): string {
+ const root = resolve(startDir);
+ let dir = root;
+ for (;;) {
+ if (existsSync(join(dir, ".git"))) return dir;
+ const parent = dirname(dir);
+ if (parent === dir) return root; // reached the filesystem root, no repo
+ dir = parent;
+ }
+}
diff --git a/src/utils/decentralize/randomName.test.ts b/src/utils/decentralize/randomName.test.ts
index 3f89678f..ac77aaee 100644
--- a/src/utils/decentralize/randomName.test.ts
+++ b/src/utils/decentralize/randomName.test.ts
@@ -27,7 +27,7 @@
*/
import { describe, expect, it } from "vitest";
-import { generateLabel } from "./randomName.js";
+import { deriveBaseFromPath, generateLabel } from "./randomName.js";
const ITERATIONS = 200;
@@ -146,3 +146,38 @@ describe("generateLabel", () => {
expect(labels.size).toBe(50);
});
});
+
+describe("deriveBaseFromPath (--path flow)", () => {
+ it("uses the directory basename", () => {
+ expect(deriveBaseFromPath("./dist")).toBe("dist");
+ expect(deriveBaseFromPath("/home/me/sites/my-portfolio/build")).toBe("build");
+ });
+
+ it("ignores a trailing slash", () => {
+ expect(deriveBaseFromPath("/home/me/sites/build/")).toBe("build");
+ });
+
+ it("transliterates dots and strips unsafe characters", () => {
+ expect(deriveBaseFromPath("/tmp/my.site.v2")).toBe("my-site-v2");
+ expect(deriveBaseFromPath("/tmp/My Site!")).toBe("mysite");
+ });
+
+ it("returns null when nothing usable remains", () => {
+ expect(deriveBaseFromPath("/")).toBeNull();
+ expect(deriveBaseFromPath("/tmp/---")).toBeNull();
+ });
+
+ it("feeds generateLabel's prefix, with the decent- fallback when unusable", () => {
+ expect(generateLabel(undefined, "./dist")).toMatch(/^dist-/);
+ expect(generateLabel(undefined, "/tmp/---")).toMatch(/^decent-/);
+ });
+
+ it("preserves the NoStatus shape invariants", () => {
+ for (let i = 0; i < ITERATIONS; i++) {
+ const label = generateLabel(undefined, "./dist");
+ expect(baseLength(label)).toBeGreaterThanOrEqual(9);
+ expect(trailingDigits(label)).toBe(2);
+ expect(label).toMatch(/^[a-z0-9][a-z0-9-]*$/);
+ }
+ });
+});
diff --git a/src/utils/decentralize/randomName.ts b/src/utils/decentralize/randomName.ts
index 07b143b4..8b9d9d0f 100644
--- a/src/utils/decentralize/randomName.ts
+++ b/src/utils/decentralize/randomName.ts
@@ -14,6 +14,7 @@
// limitations under the License.
import { randomBytes } from "node:crypto";
+import { basename, resolve } from "node:path";
import { checkDomainAvailability, type AvailabilityResult } from "../deploy/availability.js";
import type { Env } from "../../config.js";
@@ -81,7 +82,22 @@ function deriveBase(siteUrl: string): string | null {
}
}
- let s = parsed.hostname
+ return sanitizeBase(parsed.hostname);
+}
+
+/**
+ * Sanitise a local directory path into a domain-safe prefix derived from its
+ * basename. Same transliteration pipeline as `deriveBase` so `--path ./dist`
+ * yields `dist-abcd42`-style names and `--path ~/sites/my.portfolio/build`
+ * yields `build-…`. Returns null when nothing usable remains (e.g. `--path /`
+ * or an all-symbol directory name); callers fall back to `FALLBACK_PREFIX`.
+ */
+export function deriveBaseFromPath(localPath: string): string | null {
+ return sanitizeBase(basename(resolve(localPath)));
+}
+
+function sanitizeBase(raw: string): string | null {
+ let s = raw
.toLowerCase()
.replace(/\./g, "-")
.replace(/[^a-z0-9-]/g, "");
@@ -102,10 +118,16 @@ function deriveBase(siteUrl: string): string | null {
*
* generateLabel("https://shawntabrizi.com") → "shawntabrizi-com-abcd42"
* generateLabel("https://x.com") → "x-com-abcd42"
+ * generateLabel(undefined, "./dist") → "dist-abcd42"
* generateLabel(undefined) → "decent-abcd42"
+ *
+ * `siteUrl` and `localPath` are mutually exclusive in practice (`--site` vs
+ * `--path`); when both are somehow present the URL wins.
*/
-export function generateLabel(siteUrl?: string): string {
- const base = siteUrl ? deriveBase(siteUrl) : null;
+export function generateLabel(siteUrl?: string, localPath?: string): string {
+ const base =
+ (siteUrl ? deriveBase(siteUrl) : null) ??
+ (localPath ? deriveBaseFromPath(localPath) : null);
const prefix = base ? `${base}-` : FALLBACK_PREFIX;
// Pad with lowercase letters so prefix + letters >= MIN_BASE_LEN.
@@ -129,6 +151,12 @@ export interface FindAvailableNameOptions {
* recognisability of the resulting `.dot.li` URL.
*/
siteUrl?: string;
+ /**
+ * Local directory being decentralized (`--path` flow). Candidates start
+ * with the sanitised directory basename (e.g. `dist-abcd42`). Ignored
+ * when `siteUrl` is also set.
+ */
+ localPath?: string;
/** Cap on attempts; defaults to 20. */
maxAttempts?: number;
}
@@ -149,7 +177,7 @@ export async function findAvailableRandomName(
let lastFailure: AvailabilityResult | null = null;
for (let i = 0; i < maxAttempts; i++) {
- const candidate = generateLabel(options.siteUrl);
+ const candidate = generateLabel(options.siteUrl, options.localPath);
const result = await checkDomainAvailability(candidate, {
env: options.env,
ownerSs58Address: options.ownerSs58Address,
diff --git a/src/utils/decentralize/run.test.ts b/src/utils/decentralize/run.test.ts
index 6e5870ab..cfa630d7 100644
--- a/src/utils/decentralize/run.test.ts
+++ b/src/utils/decentralize/run.test.ts
@@ -18,7 +18,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// Heavy underlying pieces mocked — the orchestrator test only cares about
// which signer reaches the Bulletin storage layer. Same pattern as
// `../deploy/run.test.ts`.
-const { runStorageDeployMock, mirrorSiteMock, ensureSlotAccountSignerMock } = vi.hoisted(() => ({
+const {
+ runStorageDeployMock,
+ mirrorSiteMock,
+ prepareLocalDirectoryMock,
+ findProjectRootMock,
+ ensureSlotAccountSignerMock,
+ publishToPlaygroundMock,
+} = vi.hoisted(() => ({
// Explicit arg type so `mock.calls[0][0]` typechecks (an arg-less vi.fn
// infers Parameters = [] and indexing the empty tuple is a tsc error).
runStorageDeployMock: vi.fn<
@@ -39,11 +46,26 @@ const { runStorageDeployMock, mirrorSiteMock, ensureSlotAccountSignerMock } = vi
uploadRoot: "/tmp/playground-cli-test-mirror-does-not-exist",
fileCount: 3,
})),
+ prepareLocalDirectoryMock: vi.fn(() => ({
+ uploadRoot: "/tmp/playground-cli-test-local-does-not-exist",
+ fileCount: 5,
+ })),
+ // Stand-in repo root: the runner walks up from the typed --path to the
+ // enclosing git repo so the project README (not the build dir) is inlined.
+ findProjectRootMock: vi.fn((dir: string) => `${dir}/__repo_root__`),
ensureSlotAccountSignerMock: vi.fn(),
+ publishToPlaygroundMock: vi.fn<(arg: unknown) => Promise<{ metadataCid: string }>>(
+ async () => ({ metadataCid: "bafymeta" }),
+ ),
}));
vi.mock("../deploy/storage.js", () => ({ runStorageDeploy: runStorageDeployMock }));
+vi.mock("../deploy/playground.js", () => ({ publishToPlayground: publishToPlaygroundMock }));
vi.mock("./mirror.js", () => ({ mirrorSite: mirrorSiteMock }));
+vi.mock("./local.js", () => ({
+ prepareLocalDirectory: prepareLocalDirectoryMock,
+ findProjectRoot: findProjectRootMock,
+}));
vi.mock("@parity/product-sdk-terminal/host", () => ({
createSlotAccountSigner: vi.fn(),
ensureSlotAccountSigner: ensureSlotAccountSignerMock,
@@ -103,13 +125,14 @@ describe("runDecentralize — Bulletin storage signer", () => {
beforeEach(() => {
runStorageDeployMock.mockClear();
mirrorSiteMock.mockClear();
+ prepareLocalDirectoryMock.mockClear();
ensureSlotAccountSignerMock.mockReset();
ensureSlotAccountSignerMock.mockResolvedValue(slotSigner);
});
it("phone mode threads the slot key as storageSigner — chunks never phone-sign", async () => {
await runDecentralize({
- siteUrl: "https://example.com",
+ source: { kind: "url", url: "https://example.com" },
label: "my-site",
fullDomain: "my-site.dot",
mode: "phone",
@@ -135,7 +158,7 @@ describe("runDecentralize — Bulletin storage signer", () => {
it("dev mode pins the dev mnemonic + dev storage signer and never touches the slot key", async () => {
await runDecentralize({
- siteUrl: "https://example.com",
+ source: { kind: "url", url: "https://example.com" },
label: "my-site",
fullDomain: "my-site.dot",
mode: "dev",
@@ -154,6 +177,125 @@ describe("runDecentralize — Bulletin storage signer", () => {
expect(arg.auth.storageSignerAddress).toBe(DEV_PUBLISH_ADDRESS);
expect(ensureSlotAccountSignerMock).not.toHaveBeenCalled();
});
+
+ it("local path skips the mirror and uploads the prepared directory", async () => {
+ const events: DecentralizeLogEvent[] = [];
+ await runDecentralize({
+ source: { kind: "path", directory: "./dist" },
+ label: "my-site",
+ fullDomain: "my-site.dot",
+ mode: "dev",
+ userSigner: null,
+ env: "paseo-next-v2",
+ onEvent: (ev) => events.push(ev),
+ });
+
+ expect(mirrorSiteMock).not.toHaveBeenCalled();
+ expect(prepareLocalDirectoryMock).toHaveBeenCalledWith("./dist");
+ const arg = runStorageDeployMock.mock.calls[0][0] as unknown as { content: string };
+ expect(arg.content).toBe("/tmp/playground-cli-test-local-does-not-exist");
+ // The path branch emits local-done and none of the mirror events.
+ expect(events.some((e) => e.kind === "local-done")).toBe(true);
+ expect(events.some((e) => e.kind.startsWith("mirror-"))).toBe(false);
+ });
+
+ it("local path in phone mode still routes storage through the slot key", async () => {
+ // Signer routing is source-independent: chunks must never phone-sign
+ // regardless of where the site content came from.
+ await runDecentralize({
+ source: { kind: "path", directory: "./dist" },
+ label: "my-site",
+ fullDomain: "my-site.dot",
+ mode: "phone",
+ userSigner: sessionSigner,
+ env: "paseo-next-v2",
+ });
+
+ const arg = runStorageDeployMock.mock.calls[0][0] as unknown as {
+ auth: { signerAddress?: string; storageSigner?: unknown };
+ };
+ expect(arg.auth.signerAddress).toBe("5Fake");
+ expect(arg.auth.storageSigner).toBe(slotSigner);
+ });
+});
+
+describe("runDecentralize — playground publish metadata", () => {
+ beforeEach(() => {
+ runStorageDeployMock.mockClear();
+ mirrorSiteMock.mockClear();
+ prepareLocalDirectoryMock.mockClear();
+ publishToPlaygroundMock.mockClear();
+ ensureSlotAccountSignerMock.mockReset();
+ ensureSlotAccountSignerMock.mockResolvedValue({ publicKey: new Uint8Array(32) } as any);
+ });
+
+ type PublishArg = {
+ repositoryUrl: string | null;
+ isModdable?: boolean;
+ cwd?: string;
+ };
+
+ it("path source threads the preflighted repo URL + cwd (README root) through", async () => {
+ const outcome = await runDecentralize({
+ source: { kind: "path", directory: "./dist" },
+ label: "my-site",
+ fullDomain: "my-site.dot",
+ mode: "dev",
+ userSigner: null,
+ publishToPlayground: true,
+ repositoryUrl: "https://github.com/acme/site",
+ env: "paseo-next-v2",
+ });
+
+ expect(publishToPlaygroundMock).toHaveBeenCalledTimes(1);
+ const arg = publishToPlaygroundMock.mock.calls[0][0] as PublishArg;
+ expect(arg.repositoryUrl).toBe("https://github.com/acme/site");
+ expect(arg.isModdable).toBe(true);
+ // cwd is the resolved git repo root (walked up from the typed --path),
+ // not the build dir — publishToPlayground inlines the *project* README
+ // as the app's detail page, matching how the moddable origin resolves.
+ expect(findProjectRootMock).toHaveBeenCalledWith("./dist");
+ expect(arg.cwd).toBe("./dist/__repo_root__");
+ expect(outcome.metadataCid).toBe("bafymeta");
+ });
+
+ it("path source without a repo URL still inlines the README but is not moddable", async () => {
+ await runDecentralize({
+ source: { kind: "path", directory: "./dist" },
+ label: "my-site",
+ fullDomain: "my-site.dot",
+ mode: "dev",
+ userSigner: null,
+ publishToPlayground: true,
+ env: "paseo-next-v2",
+ });
+
+ const arg = publishToPlaygroundMock.mock.calls[0][0] as PublishArg;
+ expect(arg.repositoryUrl).toBeNull();
+ expect(arg.isModdable).toBe(false);
+ expect(arg.cwd).toBe("./dist/__repo_root__");
+ });
+
+ it("url source records no repository, no moddable bit, and no project root", async () => {
+ // Mirrored sites have no git source: even if a caller smuggled a repo
+ // URL in, the contract for url mode is null/false/undefined — pinned
+ // here without the smuggling (callers can't reach the option in url
+ // mode through the CLI surface).
+ await runDecentralize({
+ source: { kind: "url", url: "https://example.com" },
+ label: "my-site",
+ fullDomain: "my-site.dot",
+ mode: "dev",
+ userSigner: null,
+ publishToPlayground: true,
+ env: "paseo-next-v2",
+ });
+
+ const arg = publishToPlaygroundMock.mock.calls[0][0] as PublishArg;
+ expect(arg.repositoryUrl).toBeNull();
+ expect(arg.isModdable).toBe(false);
+ expect(arg.cwd).toBeUndefined();
+ });
});
describe("runDecentralize — large-site warning", () => {
@@ -182,7 +324,7 @@ describe("runDecentralize — large-site warning", () => {
}>);
const events: DecentralizeLogEvent[] = [];
return runDecentralize({
- siteUrl: "https://example.com",
+ source: { kind: "url", url: "https://example.com" },
label: "my-site",
fullDomain: "my-site.dot",
mode: "dev",
diff --git a/src/utils/decentralize/run.ts b/src/utils/decentralize/run.ts
index ddc87be6..4186349a 100644
--- a/src/utils/decentralize/run.ts
+++ b/src/utils/decentralize/run.ts
@@ -14,7 +14,8 @@
// limitations under the License.
/**
- * Pure runner for `dot decentralize` — mirrors a live site, uploads it via
+ * Pure runner for `dot decentralize` — takes a site (mirrored from a live
+ * URL, or an already-built local directory via `--path`), uploads it via
* `runStorageDeploy` (Bulletin chunked store + DotNS register), and
* optionally publishes a minimal AppInfo entry to the playground registry.
*
@@ -47,8 +48,16 @@ import {
} from "../deploy/signingProxy.js";
import { runStorageDeploy } from "../deploy/storage.js";
import type { ResolvedSigner } from "../signer.js";
+import { findProjectRoot, prepareLocalDirectory } from "./local.js";
import { mirrorSite } from "./mirror.js";
+/**
+ * What the site content comes from: a live URL (mirrored with wget into a
+ * temp dir) or an already-built local directory (`--path`, uploaded in
+ * place — never deleted). Both converge on `runStorageDeploy({ content })`.
+ */
+export type DecentralizeSource = { kind: "url"; url: string } | { kind: "path"; directory: string };
+
/**
* Emit a "this is a large site" warning once the mirror crosses this many
* downloaded files. wget runs with `--no-verbose`, so it prints roughly one
@@ -67,6 +76,10 @@ export type DecentralizeLogEvent =
// while — Ctrl+C to cancel" warning.
| { kind: "mirror-large"; fileCount: number }
| { kind: "mirror-done"; fileCount: number; directory: string }
+ // `--path` flow: local directory validated and ready to upload. Mirrors
+ // `mirror-done`'s shape; no start/line/large events precede it (there is
+ // no download to wait for).
+ | { kind: "local-done"; fileCount: number; directory: string }
| { kind: "storage-start"; fullDomain: string }
| { kind: "storage-event"; event: DeployLogEvent }
| { kind: "storage-done"; cid: string }
@@ -96,7 +109,7 @@ export function describeDeployEvent(event: DeployLogEvent): string | null {
}
export interface RunDecentralizeOptions {
- siteUrl: string;
+ source: DecentralizeSource;
label: string;
fullDomain: string;
/**
@@ -114,9 +127,10 @@ export interface RunDecentralizeOptions {
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.
+ * publishes a minimal AppInfo entry to the playground registry. For
+ * `path` sources the directory's README.md (if any) is inlined as the
+ * app's detail page; URL sources have no project root so no README is
+ * recorded.
*/
publishToPlayground?: boolean;
/**
@@ -125,6 +139,15 @@ export interface RunDecentralizeOptions {
* deploy's `--tag`; values come from `PLAYGROUND_TAGS`.
*/
tag?: string | null;
+ /**
+ * Public GitHub URL to record in the playground metadata so others can
+ * `playground mod` the app. Callers preflight it (`resolveRepositoryUrl`
+ * — git origin exists, public, GitHub) before passing it in; the runner
+ * just threads it through. Only meaningful for `path` sources — mirrored
+ * URL sites have no git source, so URL-mode callers always pass
+ * null/omit, and `isModdable` stays false.
+ */
+ repositoryUrl?: string | null;
env: Env;
onEvent?: (event: DecentralizeLogEvent) => void;
}
@@ -144,7 +167,7 @@ export interface DecentralizeOutcome {
export async function runDecentralize(
options: RunDecentralizeOptions,
): Promise {
- const { siteUrl, label, fullDomain, mode, userSigner, env, onEvent } = options;
+ const { source, label, fullDomain, mode, userSigner, env, onEvent } = options;
const wantPlayground = options.publishToPlayground === true;
// Compose the storage + publish identities through deploy's single
@@ -190,31 +213,48 @@ export async function runDecentralize(
// wrap below can't see them.
const allowancePrompt = createApprovalPrompt(counter, emitSigning);
+ // Set ONLY by the url branch — it's the temp dir the `finally` cleanup
+ // deletes. The path branch must leave it null: the upload root there is
+ // the user's own directory.
let mirrorDir: string | null = null;
try {
- onEvent?.({ kind: "mirror-start", url: siteUrl });
- // Count wget output lines (≈ one per saved file under `--no-verbose`)
- // so we can warn once when the mirror turns out to be large.
- let mirrorLineCount = 0;
- let largeSiteWarned = false;
- const mirror = await mirrorSite({
- url: siteUrl,
- onLine: (line) => {
- onEvent?.({ kind: "mirror-line", line });
- mirrorLineCount += 1;
- if (!largeSiteWarned && mirrorLineCount >= LARGE_SITE_FILE_THRESHOLD) {
- largeSiteWarned = true;
- onEvent?.({ kind: "mirror-large", fileCount: mirrorLineCount });
- }
- },
- });
- mirrorDir = mirror.directory;
- onEvent?.({
- kind: "mirror-done",
- fileCount: mirror.fileCount,
- directory: mirror.uploadRoot,
- });
+ let uploadRoot: string;
+ if (source.kind === "url") {
+ onEvent?.({ kind: "mirror-start", url: source.url });
+ // Count wget output lines (≈ one per saved file under `--no-verbose`)
+ // so we can warn once when the mirror turns out to be large.
+ let mirrorLineCount = 0;
+ let largeSiteWarned = false;
+ const mirror = await mirrorSite({
+ url: source.url,
+ onLine: (line) => {
+ onEvent?.({ kind: "mirror-line", line });
+ mirrorLineCount += 1;
+ if (!largeSiteWarned && mirrorLineCount >= LARGE_SITE_FILE_THRESHOLD) {
+ largeSiteWarned = true;
+ onEvent?.({ kind: "mirror-large", fileCount: mirrorLineCount });
+ }
+ },
+ });
+ mirrorDir = mirror.directory;
+ // Upload from the resolved index.html parent, NOT from
+ // `mirror.directory`. See `findIndexHtmlRoot` in mirror.ts.
+ uploadRoot = mirror.uploadRoot;
+ onEvent?.({
+ kind: "mirror-done",
+ fileCount: mirror.fileCount,
+ directory: mirror.uploadRoot,
+ });
+ } else {
+ const local = prepareLocalDirectory(source.directory);
+ uploadRoot = local.uploadRoot;
+ onEvent?.({
+ kind: "local-done",
+ fileCount: local.fileCount,
+ directory: local.uploadRoot,
+ });
+ }
// Bulletin storage chunks must sign with the local BulletInAllowance
// slot key, never the phone signer — chunk txs blow the phone's
@@ -228,9 +268,7 @@ export async function runDecentralize(
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,
+ content: 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
@@ -275,18 +313,26 @@ export async function runDecentralize(
}
: setup.publishSigner;
+ // Preflighted by the caller; null/omitted for mirrored URL sites
+ // (no git source) and for path publishes that declined moddable.
+ const repositoryUrl = options.repositoryUrl ?? null;
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,
+ repositoryUrl,
tag: options.tag ?? null,
+ // Path sources have a real project root — its README.md (if
+ // any) becomes the app's playground detail page. Resolve the
+ // repo root from the typed dir (which is usually a build output
+ // like ./dist whose README sits one level up), so the README
+ // and the moddable `repository` metadata share one anchor. URL
+ // sources upload a temp mirror with no project root.
+ cwd: source.kind === "path" ? findProjectRoot(source.directory) : undefined,
env,
isPrivate: false,
- isModdable: false,
+ isModdable: repositoryUrl !== null,
isDevSigner: setup.publishSigner.source === "dev",
onLogEvent: (event) => onEvent?.({ kind: "playground-event", event }),
onAllowancePrompt: allowancePrompt,