diff --git a/.changeset/deploy-skip-build.md b/.changeset/deploy-skip-build.md new file mode 100644 index 0000000..216b50a --- /dev/null +++ b/.changeset/deploy-skip-build.md @@ -0,0 +1,5 @@ +--- +"playground-cli": minor +--- + +`dot deploy` now asks whether to run the build step before deploying, defaulting to "yes" so the common case is still a single Enter press. Pass `--no-build` to skip the build non-interactively (useful when you've already built the project and just want to re-upload existing artifacts from `buildDir`). The confirm screen and headless summary both show whether the run will rebuild or reuse existing artifacts. diff --git a/src/commands/deploy/DeployScreen.tsx b/src/commands/deploy/DeployScreen.tsx index 182d4b7..eab0e6b 100644 --- a/src/commands/deploy/DeployScreen.tsx +++ b/src/commands/deploy/DeployScreen.tsx @@ -37,11 +37,13 @@ export interface DeployScreenInputs { buildDir: string | null; mode: SignerMode | null; publishToPlayground: boolean | null; + skipBuild: boolean | null; userSigner: ResolvedSigner | null; onDone: (outcome: DeployOutcome | null) => void; } type Stage = + | { kind: "prompt-build" } | { kind: "prompt-signer" } | { kind: "prompt-buildDir" } | { kind: "prompt-domain" } @@ -57,6 +59,7 @@ interface Resolved { buildDir: string; domain: string; publishToPlayground: boolean; + skipBuild: boolean; } export function DeployScreen({ @@ -65,6 +68,7 @@ export function DeployScreen({ buildDir: initialBuildDir, mode: initialMode, publishToPlayground: initialPublish, + skipBuild: initialSkipBuild, userSigner, onDone, }: DeployScreenInputs) { @@ -72,13 +76,20 @@ export function DeployScreen({ const [buildDir, setBuildDir] = useState(initialBuildDir); const [domain, setDomain] = useState(initialDomain); const [publishToPlayground, setPublishToPlayground] = useState(initialPublish); + const [skipBuild, setSkipBuild] = useState(initialSkipBuild); const [domainError, setDomainError] = useState(null); // Captured from the availability check; feeds `resolveSignerSetup` so // the summary card shows the correct phone-approval count (register + // PoP upgrade = 4 DotNS taps, vs register alone = 3, vs update = 1). const [plan, setPlan] = useState(null); const [stage, setStage] = useState(() => - pickInitialStage(initialMode, initialBuildDir, initialDomain, initialPublish), + pickInitialStage( + initialSkipBuild, + initialMode, + initialBuildDir, + initialDomain, + initialPublish, + ), ); // Passed down to RunningStage; read back on completion for the sparkline. @@ -87,20 +98,27 @@ export function DeployScreen({ const finalChunkTimingsRef = useRef([]); const advance = ( + nextSkipBuild: boolean | null = skipBuild, nextMode: SignerMode | null = mode, nextBuildDir: string | null = buildDir, nextDomain: string | null = domain, nextPublish: boolean | null = publishToPlayground, ) => { - const s = pickNextStage(nextMode, nextBuildDir, nextDomain, nextPublish); + const s = pickNextStage(nextSkipBuild, nextMode, nextBuildDir, nextDomain, nextPublish); setStage(s); }; const resolved = useMemo(() => { - if (mode === null || buildDir === null || domain === null || publishToPlayground === null) + if ( + mode === null || + buildDir === null || + domain === null || + publishToPlayground === null || + skipBuild === null + ) return null; - return { mode, buildDir, domain, publishToPlayground }; - }, [mode, buildDir, domain, publishToPlayground]); + return { mode, buildDir, domain, publishToPlayground, skipBuild }; + }, [mode, buildDir, domain, publishToPlayground, skipBuild]); // Dynamic terminal tab title: subtitle becomes the domain once we know it. const headerSubtitle = resolved?.domain ?? domain ?? undefined; @@ -114,6 +132,21 @@ export function DeployScreen({ right={VERSION_LABEL} /> + {stage.kind === "prompt-build" && ( + + label="build before deploy?" + options={[ + { value: false, label: "yes", hint: "rebuild the project" }, + { value: true, label: "no", hint: "use existing build in buildDir" }, + ]} + initialIndex={0} + onSelect={(skip) => { + setSkipBuild(skip); + advance(skip); + }} + /> + )} + {stage.kind === "prompt-signer" && ( label="signer" @@ -131,7 +164,7 @@ export function DeployScreen({ ]} onSelect={(m) => { setMode(m); - advance(m); + advance(skipBuild, m); }} /> )} @@ -142,7 +175,7 @@ export function DeployScreen({ initial={DEFAULT_BUILD_DIR} onSubmit={(v) => { setBuildDir(v); - advance(mode, v); + advance(skipBuild, mode, v); }} /> )} @@ -174,7 +207,7 @@ export function DeployScreen({ onAvailable={(result) => { setDomain(result.fullDomain); setPlan(result.plan); - advance(mode, buildDir, result.fullDomain); + advance(skipBuild, mode, buildDir, result.fullDomain); }} onUnavailable={(reason) => { setDomainError(reason); @@ -193,7 +226,7 @@ export function DeployScreen({ initialIndex={0} onSelect={(yes) => { setPublishToPlayground(yes); - advance(mode, buildDir, domain, yes); + advance(skipBuild, mode, buildDir, domain, yes); }} /> )} @@ -248,20 +281,23 @@ export function DeployScreen({ // ── Stage pickers ──────────────────────────────────────────────────────────── function pickInitialStage( + skipBuild: boolean | null, mode: SignerMode | null, buildDir: string | null, domain: string | null, publish: boolean | null, ): Stage { - return pickNextStage(mode, buildDir, domain, publish); + return pickNextStage(skipBuild, mode, buildDir, domain, publish); } function pickNextStage( + skipBuild: boolean | null, mode: SignerMode | null, buildDir: string | null, domain: string | null, publish: boolean | null, ): Stage { + if (skipBuild === null) return { kind: "prompt-build" }; if (mode === null) return { kind: "prompt-signer" }; if (buildDir === null) return { kind: "prompt-buildDir" }; if (domain === null) return { kind: "prompt-domain" }; @@ -369,6 +405,7 @@ function ConfirmStage({ mode: inputs.mode, domain: inputs.domain.replace(/\.dot$/, "") + ".dot", buildDir: inputs.buildDir, + skipBuild: inputs.skipBuild, publishToPlayground: inputs.publishToPlayground, approvals: "approvals" in setup ? setup.approvals : [], }); @@ -465,7 +502,10 @@ function RunningStage({ onError: (message: string) => void; }) { const initialPhases: Record = { - build: { status: "pending" }, + build: { + status: inputs.skipBuild ? "complete" : "pending", + detail: inputs.skipBuild ? "skipped" : undefined, + }, "storage-and-dotns": { status: "pending" }, playground: { status: inputs.publishToPlayground ? "pending" : "complete", @@ -518,6 +558,7 @@ function RunningStage({ const outcome = await runDeploy({ projectDir, buildDir: inputs.buildDir, + skipBuild: inputs.skipBuild, domain: inputs.domain, mode: inputs.mode, publishToPlayground: inputs.publishToPlayground, diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 56155c2..0bafee0 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -31,6 +31,12 @@ interface DeployOpts { domain?: string; buildDir?: string; playground?: boolean; + /** + * Commander's auto-negated boolean: defaults to `true`; `--no-build` flips it to `false`. + * We never check for `undefined` here since commander always provides a boolean when + * a `--no-foo` option is declared. + */ + build?: boolean; env?: Env; /** Project root. Hidden — defaults to cwd. */ dir?: string; @@ -46,6 +52,7 @@ export const deployCommand = new Command("deploy") "--buildDir ", `Directory containing build artifacts (default: ${DEFAULT_BUILD_DIR})`, ) + .option("--no-build", "Skip the build step and deploy existing artifacts in buildDir") .option("--playground", "Publish to the playground registry") .option("--suri ", "Secret URI for the user signer (e.g. //Alice for dev)") .addOption( @@ -216,6 +223,7 @@ async function runHeadless(ctx: { const publishToPlayground = Boolean(ctx.opts.playground); const domain = ctx.opts.domain as string; const buildDir = ctx.opts.buildDir as string; + const skipBuild = ctx.opts.build === false; // Check availability BEFORE we build + upload, so CI fails fast on a // Reserved / already-taken name without wasting a chunk upload. @@ -239,6 +247,7 @@ async function runHeadless(ctx: { mode, domain: availability.fullDomain, buildDir, + skipBuild, publishToPlayground, approvals: setup.approvals, }); @@ -247,6 +256,7 @@ async function runHeadless(ctx: { const outcome = await runDeploy({ projectDir: ctx.projectDir, buildDir, + skipBuild, domain, mode, publishToPlayground, @@ -275,6 +285,9 @@ function runInteractive(ctx: { mode: (ctx.opts.signer as SignerMode | undefined) ?? null, publishToPlayground: ctx.opts.playground !== undefined ? Boolean(ctx.opts.playground) : null, + // Only pre-fill when the user explicitly asked to skip via `--no-build`; + // otherwise show the prompt so they can hit Enter on the default "yes". + skipBuild: ctx.opts.build === false ? true : null, userSigner: ctx.userSigner, onDone: (outcome: DeployOutcome | null) => { if (settled) return; diff --git a/src/commands/deploy/summary.test.ts b/src/commands/deploy/summary.test.ts index 6c9642c..e85cc2d 100644 --- a/src/commands/deploy/summary.test.ts +++ b/src/commands/deploy/summary.test.ts @@ -7,6 +7,7 @@ describe("buildSummaryView", () => { mode: "dev", domain: "my-app.dot", buildDir: "dist", + skipBuild: false, publishToPlayground: false, approvals: [], }); @@ -20,6 +21,7 @@ describe("buildSummaryView", () => { mode: "dev", domain: "my-app.dot", buildDir: "dist", + skipBuild: false, publishToPlayground: true, approvals: [{ phase: "playground", label: "Publish to Playground registry" }], }); @@ -32,6 +34,7 @@ describe("buildSummaryView", () => { mode: "phone", domain: "my-app.dot", buildDir: "dist", + skipBuild: false, publishToPlayground: true, approvals: [ { phase: "dotns", label: "Reserve domain (DotNS commitment)" }, @@ -48,6 +51,28 @@ describe("buildSummaryView", () => { "4. Publish to Playground registry", ]); }); + + it("Build row reflects the skipBuild flag", () => { + const rebuild = buildSummaryView({ + mode: "dev", + domain: "my-app.dot", + buildDir: "dist", + skipBuild: false, + publishToPlayground: false, + approvals: [], + }); + expect(rebuild.rows.find((r) => r.label === "Build")?.value).toBe("rebuild first"); + + const skip = buildSummaryView({ + mode: "dev", + domain: "my-app.dot", + buildDir: "dist", + skipBuild: true, + publishToPlayground: false, + approvals: [], + }); + expect(skip.rows.find((r) => r.label === "Build")?.value).toBe("skip (use existing)"); + }); }); describe("renderSummaryText", () => { @@ -57,6 +82,7 @@ describe("renderSummaryText", () => { mode: "dev", domain: "my-app.dot", buildDir: "dist", + skipBuild: false, publishToPlayground: false, approvals: [], }), @@ -70,6 +96,7 @@ describe("renderSummaryText", () => { mode: "phone", domain: "x.dot", buildDir: "dist", + skipBuild: false, publishToPlayground: false, approvals: [ { phase: "dotns", label: "Reserve domain" }, diff --git a/src/commands/deploy/summary.ts b/src/commands/deploy/summary.ts index 2eb7b39..1a22ca4 100644 --- a/src/commands/deploy/summary.ts +++ b/src/commands/deploy/summary.ts @@ -10,6 +10,7 @@ export interface SummaryInputs { mode: SignerMode; domain: string; buildDir: string; + skipBuild: boolean; publishToPlayground: boolean; approvals: DeployApproval[]; } @@ -31,6 +32,7 @@ export function buildSummaryView(input: SummaryInputs): SummaryView { headline: `Deploying ${input.domain}`, rows: [ { label: "Signer", value: MODE_LABEL[input.mode] }, + { label: "Build", value: input.skipBuild ? "skip (use existing)" : "rebuild first" }, { label: "Build dir", value: input.buildDir }, { label: "Publish",