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