Skip to content

Commit f6a4cc2

Browse files
enable moddable for decentralized from path
1 parent 091e73c commit f6a4cc2

9 files changed

Lines changed: 541 additions & 231 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"playground-cli": minor
3+
---
4+
5+
`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 its README.md as the app's playground detail page, and the TUI now says so up front at the publish prompt. Mirrored URL sites are unchanged (no git source — never moddable, no README).

src/commands/decentralize/DecentralizeScreen.tsx

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
type DecentralizeOutcome,
4949
type DecentralizeSource,
5050
} from "../../utils/decentralize/run.js";
51+
import { ModdableErrorStage, ModdablePreflightStage } from "../deploy/ModdableStages.js";
5152
import {
5253
pickNextStage,
5354
validateDomainInput,
@@ -82,6 +83,13 @@ export interface DecentralizeScreenProps {
8283
* publish prompt is shown.
8384
*/
8485
initialPublishToPlayground: boolean | null;
86+
/**
87+
* Pre-set when `--moddable` was passed on the CLI: skips the moddable
88+
* prompt and drives straight into the git-origin preflight (only if the
89+
* user ends up in the path + publish flow — URL mode ignores it). `null`
90+
* means the prompt is shown.
91+
*/
92+
initialModdable: boolean | null;
8593
onDone: (result: DecentralizeResult) => void;
8694
}
8795

@@ -92,6 +100,7 @@ export function DecentralizeScreen({
92100
explicitSigner,
93101
sessionSigner,
94102
initialPublishToPlayground,
103+
initialModdable,
95104
onDone,
96105
}: DecentralizeScreenProps) {
97106
// A caller-provided site URL pre-selects the URL flow (vestigial today —
@@ -114,6 +123,8 @@ export function DecentralizeScreen({
114123
const [publishToPlayground, setPublishToPlayground] = useState<boolean | null>(
115124
initialPublishToPlayground,
116125
);
126+
const [moddable, setModdable] = useState<boolean | null>(initialModdable);
127+
const [repositoryUrl, setRepositoryUrl] = useState<string | null>(null);
117128

118129
const [stage, setStage] = useState<Stage>(() =>
119130
pickNextStage({
@@ -124,6 +135,8 @@ export function DecentralizeScreen({
124135
domainLabel: null,
125136
domainRaw: initialDot,
126137
publishToPlayground: initialPublishToPlayground,
138+
moddable: initialModdable,
139+
repositoryUrl: null,
127140
}),
128141
);
129142

@@ -136,6 +149,8 @@ export function DecentralizeScreen({
136149
domainLabel: string | null;
137150
domainRaw: string | null;
138151
publishToPlayground: boolean | null;
152+
moddable: boolean | null;
153+
repositoryUrl: string | null;
139154
}> = {},
140155
) => {
141156
setStage(
@@ -150,10 +165,21 @@ export function DecentralizeScreen({
150165
next.publishToPlayground !== undefined
151166
? next.publishToPlayground
152167
: publishToPlayground,
168+
moddable: next.moddable !== undefined ? next.moddable : moddable,
169+
repositoryUrl:
170+
next.repositoryUrl !== undefined ? next.repositoryUrl : repositoryUrl,
153171
}),
154172
);
155173
};
156174

175+
// Single "user declined moddable" transition, shared by the remix prompt's
176+
// "no" answer and the setup-error menu's "continue without moddable", so
177+
// the two paths can't drift apart. Mirrors deploy's helper of the same name.
178+
const declineModdable = () => {
179+
setModdable(false);
180+
advance({ moddable: false });
181+
};
182+
157183
// Compose the active signer for downstream stages. Memoised so the
158184
// ResolvedSigner identity stays stable across re-renders (the dev branch
159185
// would otherwise produce a fresh `createDevPublishSigner()` instance on
@@ -316,24 +342,82 @@ export function DecentralizeScreen({
316342
)}
317343

318344
{stage.kind === "prompt-publish" && (
345+
<>
346+
{sourceKind === "path" && (
347+
<Callout tone="accent" title="Your app detail page">
348+
<Text>
349+
If you publish, the README.md in your directory becomes your app's
350+
detail page on the playground. Make sure it's up to date.
351+
</Text>
352+
</Callout>
353+
)}
354+
<Select<boolean>
355+
label="publish to the playground registry?"
356+
options={[
357+
{
358+
value: false,
359+
label: "no",
360+
hint: "just register the .dot name (DotNS only)",
361+
},
362+
{
363+
value: true,
364+
label: "yes",
365+
hint: "list the site in the playground apps tab",
366+
},
367+
]}
368+
onSelect={(choice) => {
369+
setPublishToPlayground(choice);
370+
advance({ publishToPlayground: choice });
371+
}}
372+
/>
373+
</>
374+
)}
375+
376+
{stage.kind === "prompt-moddable" && (
319377
<Select<boolean>
320-
label="publish to the playground registry?"
378+
label="let others remix (mod) this app?"
321379
options={[
322-
{
323-
value: false,
324-
label: "no",
325-
hint: "just register the .dot name (DotNS only)",
326-
},
327380
{
328381
value: true,
329382
label: "yes",
330-
hint: "list the mirrored site in the playground apps tab",
383+
hint: "record my public GitHub repo so others can `playground mod` it",
384+
},
385+
{
386+
value: false,
387+
label: "no",
388+
hint: "keep my source private",
331389
},
332390
]}
333-
onSelect={(choice) => {
334-
setPublishToPlayground(choice);
335-
advance({ publishToPlayground: choice });
391+
initialIndex={0}
392+
onSelect={(yes) => {
393+
if (yes) {
394+
setModdable(true);
395+
setStage({ kind: "moddable-preflight" });
396+
} else {
397+
declineModdable();
398+
}
399+
}}
400+
/>
401+
)}
402+
403+
{stage.kind === "moddable-preflight" && (
404+
<ModdablePreflightStage
405+
// git resolves `origin` from any subdirectory of the repo,
406+
// so the typed path works even when it's a build output dir.
407+
projectDir={localPath!}
408+
onResolved={(url) => {
409+
setRepositoryUrl(url);
410+
advance({ moddable: true, repositoryUrl: url });
336411
}}
412+
onError={(msg) => setStage({ kind: "moddable-error", message: msg })}
413+
/>
414+
)}
415+
416+
{stage.kind === "moddable-error" && (
417+
<ModdableErrorStage
418+
message={stage.message}
419+
onContinueWithoutModdable={declineModdable}
420+
onExit={() => onDone({ kind: "cancel" })}
337421
/>
338422
)}
339423

@@ -347,6 +431,7 @@ export function DecentralizeScreen({
347431
signer={activeSigner!}
348432
signerMode={signerMode!}
349433
publishToPlayground={publishToPlayground === true}
434+
repositoryUrl={repositoryUrl}
350435
onConfirm={() => setStage({ kind: "running" })}
351436
onCancel={() => onDone({ kind: "cancel" })}
352437
/>
@@ -360,6 +445,7 @@ export function DecentralizeScreen({
360445
mode={signerMode!}
361446
userSigner={explicitSigner ?? sessionSigner}
362447
publishToPlayground={publishToPlayground === true}
448+
repositoryUrl={repositoryUrl}
363449
env={env}
364450
onComplete={(outcome) => setStage({ kind: "done", outcome })}
365451
onFailed={(message) => setStage({ kind: "error", message })}
@@ -473,6 +559,7 @@ function ConfirmStage({
473559
signer,
474560
signerMode,
475561
publishToPlayground,
562+
repositoryUrl,
476563
onConfirm,
477564
onCancel,
478565
}: {
@@ -485,6 +572,8 @@ function ConfirmStage({
485572
signer: ResolvedSigner;
486573
signerMode: SignerMode;
487574
publishToPlayground: boolean;
575+
/** Resolved public GitHub URL when moddable was accepted; null otherwise. */
576+
repositoryUrl: string | null;
488577
onConfirm: () => void;
489578
onCancel: () => void;
490579
}) {
@@ -524,6 +613,13 @@ function ConfirmStage({
524613
value={publishToPlayground ? "publish to apps tab" : "skip"}
525614
tone={publishToPlayground ? "accent" : "muted"}
526615
/>
616+
{source.kind === "path" && publishToPlayground && (
617+
<Row
618+
label="moddable"
619+
value={repositoryUrl ? `yes · ${repositoryUrl}` : "no"}
620+
tone={repositoryUrl ? "accent" : "muted"}
621+
/>
622+
)}
527623
{availabilityNote && <Row label="note" value={availabilityNote} tone="warning" />}
528624
{largeLocal && (
529625
<Row
@@ -572,6 +668,7 @@ function RunningStage({
572668
mode,
573669
userSigner,
574670
publishToPlayground,
671+
repositoryUrl,
575672
env,
576673
onComplete,
577674
onFailed,
@@ -582,6 +679,8 @@ function RunningStage({
582679
mode: SignerMode;
583680
userSigner: ResolvedSigner | null;
584681
publishToPlayground: boolean;
682+
/** Preflighted public GitHub URL when moddable was accepted; null otherwise. */
683+
repositoryUrl: string | null;
585684
env: Env;
586685
onComplete: (outcome: DecentralizeOutcome) => void;
587686
onFailed: (message: string) => void;
@@ -627,6 +726,7 @@ function RunningStage({
627726
mode,
628727
userSigner,
629728
publishToPlayground,
729+
repositoryUrl,
630730
env,
631731
onEvent: (event) => {
632732
switch (event.kind) {

src/commands/decentralize/index.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@
2727
* Headless flow runs when `--site` or `--path` is provided (preserves the
2828
* existing `dot decentralize --suri=//Bob` demo-service contract). Without
2929
* either, the command mounts an Ink TUI that prompts for source (URL or
30-
* local directory) → URL/path → signer → domain → publish? before kicking
31-
* off the same upload pipeline. The publish-to-playground step delegates to
32-
* deploy's `publishToPlayground` helper.
30+
* local directory) → URL/path → signer → domain → publish? → moddable?
31+
* (path mode only) before kicking off the same upload pipeline. The
32+
* publish-to-playground step delegates to deploy's `publishToPlayground`
33+
* helper.
3334
*/
3435

3536
import { Command, Option } from "commander";
@@ -48,6 +49,11 @@ import {
4849
type DecentralizeSource,
4950
} from "../../utils/decentralize/run.js";
5051
import { destroyConnection } from "../../utils/connection.js";
52+
import {
53+
ensureGitInstalled,
54+
ModdablePreflightError,
55+
resolveRepositoryUrl,
56+
} from "../../utils/deploy/moddable.js";
5157
import type { SignerMode } from "../../utils/deploy/signerMode.js";
5258
import { onProcessShutdown } from "../../utils/process-guard.js";
5359

@@ -63,6 +69,13 @@ interface DecentralizeOpts {
6369
* in headless" — i.e. opt-in publish.
6470
*/
6571
playground?: boolean;
72+
/**
73+
* Record the path directory's public GitHub origin in the playground
74+
* metadata so others can `playground mod` the app. Path mode only
75+
* (`.conflicts("site")` — a mirrored URL has no git source) and requires
76+
* `--playground` in headless mode. `undefined` ⇒ ask in the TUI.
77+
*/
78+
moddable?: boolean;
6679
}
6780

6881
export const decentralizeCommand = new Command("decentralize")
@@ -101,6 +114,13 @@ export const decentralizeCommand = new Command("decentralize")
101114
"After upload, also publish a minimal AppInfo entry to the playground registry " +
102115
"(visible in the playground-app's Apps tab). Off by default.",
103116
)
117+
.addOption(
118+
new Option(
119+
"--moddable",
120+
"Record the public GitHub origin of --path's repo so others can " +
121+
"`playground mod` it. Requires --path and --playground. Off by default.",
122+
).conflicts("site"),
123+
)
104124
.action(async (opts: DecentralizeOpts) =>
105125
runCliCommand("decentralize", { hardExit: true }, async () => {
106126
const env: Env = resolveLegacyEnv(opts.env);
@@ -130,6 +150,38 @@ async function runHeadless({
130150
// (prepareLocalDirectory is cheap and pure fs).
131151
if (opts.path) prepareLocalDirectory(opts.path);
132152

153+
// Moddable preflight, same fail-fast rationale: resolve the public
154+
// GitHub origin (git walks up from the --path directory) before any
155+
// signer/chain work. `ModdablePreflightError`'s headless message
156+
// already names the fix, so it propagates as-is. `--site` is blocked
157+
// by commander's `.conflicts()`, so `opts.path` is set here.
158+
let repositoryUrl: string | null = null;
159+
if (opts.moddable) {
160+
if (opts.playground !== true) {
161+
throw new Error(
162+
"--moddable requires --playground — the repo URL is recorded in the " +
163+
"playground metadata, which is only published with --playground.",
164+
);
165+
}
166+
await ensureGitInstalled();
167+
try {
168+
repositoryUrl = await resolveRepositoryUrl({
169+
cwd: opts.path!,
170+
onLog: (line) => process.stdout.write(` ${line}\n`),
171+
});
172+
} catch (err) {
173+
// The headless message in moddable.ts names deploy's
174+
// `--no-moddable` escape hatch, which this command doesn't
175+
// have — use the surface-neutral copy + the right remedy.
176+
if (err instanceof ModdablePreflightError) {
177+
throw new Error(
178+
`${err.interactiveMessage} Or omit --moddable to publish without source.`,
179+
);
180+
}
181+
throw err;
182+
}
183+
}
184+
133185
signer = await withSpan("cli.decentralize.signer", "resolve signer", () =>
134186
resolveSigner({ suri: opts.suri }),
135187
);
@@ -166,6 +218,7 @@ async function runHeadless({
166218
mode,
167219
userSigner: signer,
168220
publishToPlayground: opts.playground === true,
221+
repositoryUrl,
169222
env,
170223
onEvent: (ev) => {
171224
switch (ev.kind) {
@@ -272,6 +325,7 @@ async function runInteractive({
272325
explicitSigner: preflight.explicitSigner,
273326
sessionSigner: preflight.sessionSigner,
274327
initialPublishToPlayground: opts.playground === true ? true : null,
328+
initialModdable: opts.moddable === true ? true : null,
275329
onDone: (result) => {
276330
if (settled) return;
277331
settled = true;

0 commit comments

Comments
 (0)