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,