Skip to content

Commit ed0b14d

Browse files
Merge pull request #370 from paritytech/gs-decentralize-local-path
feat(decentralize): support deploying a local directory via --path
2 parents 3069ce7 + 258c238 commit ed0b14d

17 files changed

Lines changed: 1304 additions & 277 deletions
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 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).

.changeset/sharp-llamas-design.md

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+
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.

.changeset/wild-pears-shake.md

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 <dir>` 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.

src/commands/decentralize/DecentralizeScreen.tsx

Lines changed: 249 additions & 30 deletions
Large diffs are not rendered by default.

src/commands/decentralize/index.ts

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,23 @@
1414
// limitations under the License.
1515

1616
/**
17-
* `dot decentralize` — point at a live static site, get back a .dot URL.
17+
* `dot decentralize` — point at a live static site (or a local build
18+
* directory), get back a .dot URL.
1819
*
1920
* dot decentralize # interactive
2021
* dot decentralize --site=shawntabrizi.github.io # headless
2122
* dot decentralize --site=foo.com --dot=bar # headless, explicit name
2223
* dot decentralize --site=foo.com --suri=//Bob # headless, dev signer
2324
* dot decentralize --site=foo.com --playground # also publish to playground
25+
* dot decentralize --path=./dist # headless, local directory
2426
*
25-
* Headless flow runs when `--site` is provided (preserves the existing
26-
* `dot decentralize --suri=//Bob` demo-service contract). Without `--site`,
27-
* the command mounts an Ink TUI that prompts for URL → signer → domain →
28-
* publish? before kicking off the same upload pipeline. The publish-to-
29-
* playground step delegates to deploy's `publishToPlayground` helper.
27+
* Headless flow runs when `--site` or `--path` is provided (preserves the
28+
* existing `dot decentralize --suri=//Bob` demo-service contract). Without
29+
* either, the command mounts an Ink TUI that prompts for source (URL or
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.
3034
*/
3135

3236
import { Command, Option } from "commander";
@@ -37,18 +41,26 @@ import { errorMessage, withSpan } from "../../telemetry.js";
3741
import { DEFAULT_ENV, ENV_FLAG_CHOICES, type Env, resolveLegacyEnv } from "../../config.js";
3842
import { resolveSigner, type ResolvedSigner, SignerNotAvailableError } from "../../utils/signer.js";
3943
import { resolveDomain } from "../../utils/decentralize/domain.js";
44+
import { prepareLocalDirectory } from "../../utils/decentralize/local.js";
4045
import {
4146
describeDeployEvent,
4247
runDecentralize,
4348
type DecentralizeOutcome,
49+
type DecentralizeSource,
4450
} from "../../utils/decentralize/run.js";
4551
import { destroyConnection } from "../../utils/connection.js";
52+
import {
53+
ensureGitInstalled,
54+
ModdablePreflightError,
55+
resolveRepositoryUrl,
56+
} from "../../utils/deploy/moddable.js";
4657
import type { SignerMode } from "../../utils/deploy/signerMode.js";
4758
import { PLAYGROUND_TAGS } from "../../utils/deploy/tags.js";
4859
import { onProcessShutdown } from "../../utils/process-guard.js";
4960

5061
interface DecentralizeOpts {
5162
site?: string;
63+
path?: string;
5264
dot?: string;
5365
env: string;
5466
suri?: string;
@@ -60,6 +72,13 @@ interface DecentralizeOpts {
6072
playground?: boolean;
6173
/** Playground category tag (from PLAYGROUND_TAGS). Requires --playground. */
6274
tag?: string;
75+
/**
76+
* Record the path directory's public GitHub origin in the playground
77+
* metadata so others can `playground mod` the app. Path mode only
78+
* (`.conflicts("site")` — a mirrored URL has no git source) and requires
79+
* `--playground` in headless mode. `undefined` ⇒ ask in the TUI.
80+
*/
81+
moddable?: boolean;
6382
}
6483

6584
/**
@@ -78,12 +97,19 @@ export function assertTagRequiresPlayground(opts: {
7897

7998
export const decentralizeCommand = new Command("decentralize")
8099
.description(
81-
"Mirror a live static site to Polkadot Bulletin and register a .dot name pointing at it",
100+
"Mirror a live static site (or upload a local build directory) to Polkadot Bulletin " +
101+
"and register a .dot name pointing at it",
82102
)
83103
.option(
84104
"--site <url>",
85105
"URL of the static site to clone (http/https). Omit to launch the interactive TUI.",
86106
)
107+
.addOption(
108+
new Option(
109+
"--path <dir>",
110+
"Local directory containing a built static site (e.g. ./dist). Alternative to --site.",
111+
).conflicts("site"),
112+
)
87113
.option(
88114
"--dot <name>",
89115
"DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.",
@@ -111,10 +137,17 @@ export const decentralizeCommand = new Command("decentralize")
111137
"Tag the published app so people can filter for it in the playground. Requires --playground.",
112138
).choices([...PLAYGROUND_TAGS]),
113139
)
140+
.addOption(
141+
new Option(
142+
"--moddable",
143+
"Record the public GitHub origin of --path's repo so others can " +
144+
"`playground mod` it. Requires --path and --playground. Off by default.",
145+
).conflicts("site"),
146+
)
114147
.action(async (opts: DecentralizeOpts) =>
115148
runCliCommand("decentralize", { hardExit: true }, async () => {
116149
const env: Env = resolveLegacyEnv(opts.env);
117-
if (opts.site) {
150+
if (opts.site || opts.path) {
118151
await runHeadless({ env, opts });
119152
} else {
120153
await runInteractive({ env, opts });
@@ -136,16 +169,60 @@ async function runHeadless({
136169
let signer: ResolvedSigner | null = null;
137170

138171
try {
172+
// Fail fast on a bad --path before any signer/network work —
173+
// otherwise the user waits out the domain availability check only to
174+
// learn the directory doesn't exist. runDecentralize re-validates
175+
// (prepareLocalDirectory is cheap and pure fs).
176+
if (opts.path) prepareLocalDirectory(opts.path);
177+
178+
// Moddable preflight, same fail-fast rationale: resolve the public
179+
// GitHub origin (git walks up from the --path directory) before any
180+
// signer/chain work. `ModdablePreflightError`'s headless message
181+
// already names the fix, so it propagates as-is. `--site` is blocked
182+
// by commander's `.conflicts()`, so `opts.path` is set here.
183+
let repositoryUrl: string | null = null;
184+
if (opts.moddable) {
185+
if (opts.playground !== true) {
186+
throw new Error(
187+
"--moddable requires --playground — the repo URL is recorded in the " +
188+
"playground metadata, which is only published with --playground.",
189+
);
190+
}
191+
await ensureGitInstalled();
192+
try {
193+
repositoryUrl = await resolveRepositoryUrl({
194+
cwd: opts.path!,
195+
onLog: (line) => process.stdout.write(` ${line}\n`),
196+
});
197+
} catch (err) {
198+
// The headless message in moddable.ts names deploy's
199+
// `--no-moddable` escape hatch, which this command doesn't
200+
// have — use the surface-neutral copy + the right remedy.
201+
if (err instanceof ModdablePreflightError) {
202+
throw new Error(
203+
`${err.interactiveMessage} Or omit --moddable to publish without source.`,
204+
);
205+
}
206+
throw err;
207+
}
208+
}
209+
139210
signer = await withSpan("cli.decentralize.signer", "resolve signer", () =>
140211
resolveSigner({ suri: opts.suri }),
141212
);
142213

143214
process.stdout.write(`\n▸ Signing as ${signer.address} (${signer.source})\n`);
144215

216+
// The action gates headless on `opts.site || opts.path` and commander's
217+
// `.conflicts()` rejects passing both, so exactly one is set here.
218+
const source: DecentralizeSource = opts.path
219+
? { kind: "path", directory: opts.path }
220+
: { kind: "url", url: opts.site! };
221+
145222
const { label, fullDomain } = await resolveDomain({
146223
env,
147224
providedDot: opts.dot,
148-
siteUrl: opts.site!,
225+
source,
149226
signer,
150227
onMessage: (line) => process.stdout.write(`${line}\n`),
151228
});
@@ -155,16 +232,19 @@ async function runHeadless({
155232
const mode: SignerMode = signer.source === "session" ? "phone" : "dev";
156233

157234
process.stdout.write(
158-
`\n▸ Mirroring ${opts.site}… (large sites take a few minutes — press Ctrl+C to cancel)\n`,
235+
source.kind === "url"
236+
? `\n▸ Mirroring ${source.url}… (large sites take a few minutes — press Ctrl+C to cancel)\n`
237+
: `\n▸ Preparing ${source.directory}…\n`,
159238
);
160239
const outcome = await runDecentralize({
161-
siteUrl: opts.site!,
240+
source,
162241
label,
163242
fullDomain,
164243
mode,
165244
userSigner: signer,
166245
publishToPlayground: opts.playground === true,
167246
tag: opts.tag ?? null,
247+
repositoryUrl,
168248
env,
169249
onEvent: (ev) => {
170250
switch (ev.kind) {
@@ -177,6 +257,7 @@ async function runHeadless({
177257
);
178258
break;
179259
case "mirror-done":
260+
case "local-done":
180261
process.stdout.write(
181262
` → ${ev.fileCount} files in ${ev.directory}\n` +
182263
`\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`,
@@ -271,6 +352,7 @@ async function runInteractive({
271352
sessionSigner: preflight.sessionSigner,
272353
initialPublishToPlayground: opts.playground === true ? true : null,
273354
initialTag: opts.tag,
355+
initialModdable: opts.moddable === true ? true : null,
274356
onDone: (result) => {
275357
if (settled) return;
276358
settled = true;

0 commit comments

Comments
 (0)