diff --git a/.changeset/signer-prompt-phone-default.md b/.changeset/signer-prompt-phone-default.md new file mode 100644 index 00000000..e6b4c104 --- /dev/null +++ b/.changeset/signer-prompt-phone-default.md @@ -0,0 +1,10 @@ +--- +"playground-cli": patch +--- + +Sync the `playground decentralize` prompts with `playground deploy`: + +- The signer pickers in both commands list your phone signer first and start the cursor on it (when logged in). The "dev signer earns no XP" warning now appears below the options, only while the dev signer is highlighted, and disappears when you move back to the phone signer. +- `playground decentralize` now shows the same magenta help boxes as deploy above its signer, domain, publish, and (new) tags prompts, and its intro box uses the same accent style. +- The "publish to the playground registry?" prompt now lists "yes" first and selects it by default. +- `playground decentralize` gained a category-tag step and a `--tag` flag (whitelisted to the playground tags, requires `--playground`), matching deploy; the chosen tag is written to the published app metadata and shown in the confirm summary. diff --git a/.changeset/update-banner-wording.md b/.changeset/update-banner-wording.md new file mode 100644 index 00000000..350ca145 --- /dev/null +++ b/.changeset/update-banner-wording.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Clarify the "update available" banner: it now reads `Run pg update to update the CLI.` (previously "playground update to upgrade") so the suggested command matches the wording. diff --git a/src/commands/decentralize/DecentralizeScreen.tsx b/src/commands/decentralize/DecentralizeScreen.tsx index 8d451043..c355cff4 100644 --- a/src/commands/decentralize/DecentralizeScreen.tsx +++ b/src/commands/decentralize/DecentralizeScreen.tsx @@ -27,16 +27,24 @@ import { Hint, Input, type MarkKind, + PromptInfo, Row, Section, Select, - type SelectOption, } from "../../utils/ui/theme/index.js"; import { PhoneApprovalCallout } from "../../utils/ui/theme/PhoneApprovalCallout.js"; import { getNetworkLabel, type Env } from "../../config.js"; import { VERSION_LABEL } from "../../utils/version.js"; import type { ResolvedSigner } from "../../utils/signer.js"; import { createDevPublishSigner, type SignerMode } from "../../utils/deploy/signerMode.js"; +import { + DEV_SIGNER_NO_XP_TITLE, + DEV_SIGNER_NO_XP_BODY, + shouldShowDevNoXpWarning, +} from "../deploy/signerNotice.js"; +import { SIGNER_HELP, DOMAIN_HELP, PUBLISH_HELP, TAGS_HELP } from "../deploy/promptHelp.js"; +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 { FREE_DOMAIN_SUFFIX_NOTE } from "../../utils/decentralize/randomName.js"; @@ -72,6 +80,11 @@ export interface DecentralizeScreenProps { * publish prompt is shown. */ initialPublishToPlayground: boolean | null; + /** + * Pre-set to the `--tag` value when passed (skips the tag prompt). + * `undefined` means the tag prompt is shown when publishing. + */ + initialTag?: string; onDone: (result: DecentralizeResult) => void; } @@ -82,11 +95,18 @@ export function DecentralizeScreen({ explicitSigner, sessionSigner, initialPublishToPlayground, + initialTag, onDone, }: DecentralizeScreenProps) { const [siteUrl, setSiteUrl] = useState(initialSiteUrl); // 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 + // the "dev signer earns no XP" warning (shown only while dev is highlighted + // and a session exists). Initialised to match the default cursor position. + const [highlightedSigner, setHighlightedSigner] = useState( + sessionSigner ? "phone" : "dev", + ); const [domainRaw, setDomainRaw] = useState(initialDot); const [domainLabel, setDomainLabel] = useState(null); const [fullDomain, setFullDomain] = useState(null); @@ -97,6 +117,9 @@ export function DecentralizeScreen({ const [publishToPlayground, setPublishToPlayground] = useState( initialPublishToPlayground, ); + // 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 [stage, setStage] = useState(() => pickNextStage({ @@ -105,6 +128,7 @@ export function DecentralizeScreen({ domainLabel: null, domainRaw: initialDot, publishToPlayground: initialPublishToPlayground, + tag: initialTag, }), ); @@ -115,6 +139,7 @@ export function DecentralizeScreen({ domainLabel: string | null; domainRaw: string | null; publishToPlayground: boolean | null; + tag: string | null; }> = {}, ) => { setStage( @@ -127,6 +152,9 @@ export function DecentralizeScreen({ next.publishToPlayground !== undefined ? next.publishToPlayground : publishToPlayground, + // `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, }), ); }; @@ -154,7 +182,7 @@ export function DecentralizeScreen({ {stage.kind === "prompt-url" && ( <> - + 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 @@ -174,37 +202,54 @@ export function DecentralizeScreen({ )} {stage.kind === "prompt-signer" && ( - - label="signer" - options={signerOptions(sessionSigner)} - onSelect={(mode) => { - if (mode === "phone" && !sessionSigner) { - setStage({ - kind: "error", - message: - 'No session found — run "playground login" to log in, then re-run, or pick the dev signer.', - }); - return; - } - setSignerMode(mode); - advance({ signerMode: mode }); - }} - /> + + + + label="signer" + options={decentralizeSignerOptions(sessionSigner != null)} + initialIndex={decentralizeSignerInitialIndex(sessionSigner != null)} + onHighlight={setHighlightedSigner} + onSelect={(mode) => { + if (mode === "phone" && !sessionSigner) { + setStage({ + kind: "error", + message: + 'No session found — run "playground login" to log in, then re-run, or pick the dev signer.', + }); + return; + } + setSignerMode(mode); + advance({ signerMode: mode }); + }} + /> + {/* Below the options (mirroring the phone-approval notices) + and only while the dev option is highlighted with a + session present, so the "no XP" trade-off shows exactly + when the user is about to pick the dev signer. */} + {shouldShowDevNoXpWarning(sessionSigner != null, highlightedSigner) && ( + + {DEV_SIGNER_NO_XP_BODY} + + )} + )} {stage.kind === "prompt-domain" && ( - { - setDomainError(null); - setDomainRaw(value); - advance({ domainRaw: value }); - }} - /> + + + { + setDomainError(null); + setDomainRaw(value); + advance({ domainRaw: value }); + }} + /> + )} {stage.kind === "validate-domain" && ( @@ -235,25 +280,49 @@ export function DecentralizeScreen({ )} {stage.kind === "prompt-publish" && ( - - label="publish to the playground registry?" - options={[ - { - value: false, - label: "no", - hint: "just register the .dot name (DotNS only)", - }, - { - value: true, - label: "yes", - hint: "list the mirrored site in the playground apps tab", - }, - ]} - onSelect={(choice) => { - setPublishToPlayground(choice); - advance({ publishToPlayground: choice }); - }} - /> + + + + label="publish to the playground registry?" + options={[ + { + value: true, + label: "yes", + hint: "list the mirrored site in the playground apps tab", + }, + { + value: false, + label: "no", + hint: "just register the .dot name (DotNS only)", + }, + ]} + initialIndex={0} + onSelect={(choice) => { + setPublishToPlayground(choice); + advance({ publishToPlayground: choice }); + }} + /> + + )} + + {stage.kind === "prompt-tags" && ( + + + + label="tag this app?" + options={[ + ...PLAYGROUND_TAGS.map((t) => ({ + value: t as string | null, + label: t, + })), + { value: null, label: "skip", hint: "publish without a tag" }, + ]} + onSelect={(t) => { + setTag(t); + advance({ tag: t }); + }} + /> + )} {stage.kind === "confirm" && ( @@ -265,6 +334,7 @@ export function DecentralizeScreen({ signer={activeSigner!} signerMode={signerMode!} publishToPlayground={publishToPlayground === true} + tag={publishToPlayground === true ? (tag ?? null) : null} onConfirm={() => setStage({ kind: "running" })} onCancel={() => onDone({ kind: "cancel" })} /> @@ -278,6 +348,7 @@ export function DecentralizeScreen({ mode={signerMode!} userSigner={explicitSigner ?? sessionSigner} publishToPlayground={publishToPlayground === true} + tag={publishToPlayground === true ? (tag ?? null) : null} env={env} onComplete={(outcome) => setStage({ kind: "done", outcome })} onFailed={(message) => setStage({ kind: "error", message })} @@ -301,23 +372,6 @@ export function DecentralizeScreen({ ); } -function signerOptions(sessionSigner: ResolvedSigner | null): SelectOption[] { - return [ - { - value: "dev", - label: "dev signer", - hint: "fast, signs locally with the polkadot-app-deploy default account", - }, - { - value: "phone", - label: "your phone signer", - hint: sessionSigner - ? "signed with your logged-in account" - : "requires `playground login` first", - }, - ]; -} - // ── Validate-domain stage ──────────────────────────────────────────────────── function ValidateDomainStage({ @@ -390,6 +444,7 @@ function ConfirmStage({ signer, signerMode, publishToPlayground, + tag, onConfirm, onCancel, }: { @@ -400,6 +455,7 @@ function ConfirmStage({ signer: ResolvedSigner; signerMode: SignerMode; publishToPlayground: boolean; + tag: string | null; onConfirm: () => void; onCancel: () => void; }) { @@ -418,6 +474,12 @@ function ConfirmStage({ value={publishToPlayground ? "publish to apps tab" : "skip"} tone={publishToPlayground ? "accent" : "muted"} /> + {/* Surface the chosen tag before the irreversible publish, like + deploy's confirm summary. Only shown when publishing — the + tag is otherwise irrelevant. */} + {publishToPlayground && ( + + )} {availabilityNote && } {autoGenerated && {FREE_DOMAIN_SUFFIX_NOTE}} @@ -461,6 +523,7 @@ function RunningStage({ mode, userSigner, publishToPlayground, + tag, env, onComplete, onFailed, @@ -471,6 +534,7 @@ function RunningStage({ mode: SignerMode; userSigner: ResolvedSigner | null; publishToPlayground: boolean; + tag: string | null; env: Env; onComplete: (outcome: DecentralizeOutcome) => void; onFailed: (message: string) => void; @@ -516,6 +580,7 @@ function RunningStage({ mode, userSigner, publishToPlayground, + tag, env, onEvent: (event) => { switch (event.kind) { diff --git a/src/commands/decentralize/index.test.ts b/src/commands/decentralize/index.test.ts new file mode 100644 index 00000000..3314d48a --- /dev/null +++ b/src/commands/decentralize/index.test.ts @@ -0,0 +1,38 @@ +// 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. + +import { describe, it, expect } from "vitest"; +import { assertTagRequiresPlayground } from "./index.js"; + +describe("assertTagRequiresPlayground", () => { + it("rejects --tag without --playground", () => { + expect(() => assertTagRequiresPlayground({ tag: "defi", playground: false })).toThrow( + /--tag requires --playground/, + ); + expect(() => assertTagRequiresPlayground({ tag: "defi" })).toThrow( + /--tag requires --playground/, + ); + }); + + it("allows a tag when publishing to the playground", () => { + expect(() => assertTagRequiresPlayground({ tag: "defi", playground: true })).not.toThrow(); + }); + + it("allows no tag regardless of the --playground flag", () => { + expect(() => assertTagRequiresPlayground({ playground: false })).not.toThrow(); + expect(() => assertTagRequiresPlayground({ playground: true })).not.toThrow(); + expect(() => assertTagRequiresPlayground({})).not.toThrow(); + }); +}); diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index d6959a72..a9eb24a5 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -44,6 +44,7 @@ import { } from "../../utils/decentralize/run.js"; import { destroyConnection } from "../../utils/connection.js"; import type { SignerMode } from "../../utils/deploy/signerMode.js"; +import { PLAYGROUND_TAGS } from "../../utils/deploy/tags.js"; import { onProcessShutdown } from "../../utils/process-guard.js"; interface DecentralizeOpts { @@ -57,6 +58,22 @@ interface DecentralizeOpts { * in headless" — i.e. opt-in publish. */ playground?: boolean; + /** Playground category tag (from PLAYGROUND_TAGS). Requires --playground. */ + tag?: string; +} + +/** + * A `--tag` is only meaningful alongside `--playground` (no metadata is + * published otherwise). Mirrors deploy's `assertPublishFlagsConsistent`. + * Exported for unit testing. + */ +export function assertTagRequiresPlayground(opts: { + tag?: string; + playground?: boolean; +}): void { + if (opts.tag && opts.playground !== true) { + throw new Error("--tag requires --playground (no metadata is published without it)."); + } } export const decentralizeCommand = new Command("decentralize") @@ -88,6 +105,12 @@ export const decentralizeCommand = new Command("decentralize") "After upload, also publish a minimal AppInfo entry to the playground registry " + "(visible in the playground-app's Apps tab). Off by default.", ) + .addOption( + new Option( + "--tag ", + "Tag the published app so people can filter for it in the playground. Requires --playground.", + ).choices([...PLAYGROUND_TAGS]), + ) .action(async (opts: DecentralizeOpts) => runCliCommand("decentralize", { hardExit: true }, async () => { const env: Env = resolveLegacyEnv(opts.env); @@ -108,6 +131,8 @@ async function runHeadless({ env: Env; opts: DecentralizeOpts; }): Promise { + assertTagRequiresPlayground(opts); + let signer: ResolvedSigner | null = null; try { @@ -139,6 +164,7 @@ async function runHeadless({ mode, userSigner: signer, publishToPlayground: opts.playground === true, + tag: opts.tag ?? null, env, onEvent: (ev) => { switch (ev.kind) { @@ -244,6 +270,7 @@ async function runInteractive({ explicitSigner: preflight.explicitSigner, sessionSigner: preflight.sessionSigner, initialPublishToPlayground: opts.playground === true ? true : null, + initialTag: opts.tag, onDone: (result) => { if (settled) return; settled = true; diff --git a/src/commands/decentralize/signerPrompt.test.ts b/src/commands/decentralize/signerPrompt.test.ts new file mode 100644 index 00000000..41171205 --- /dev/null +++ b/src/commands/decentralize/signerPrompt.test.ts @@ -0,0 +1,42 @@ +// 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. + +import { describe, it, expect } from "vitest"; +import { decentralizeSignerOptions, decentralizeSignerInitialIndex } from "./signerPrompt.js"; + +describe("decentralizeSignerOptions", () => { + it("leads with the phone signer, matching `playground deploy`", () => { + expect(decentralizeSignerOptions(true).map((o) => o.value)).toEqual(["phone", "dev"]); + }); + + it("always shows both options, even without a session", () => { + expect(decentralizeSignerOptions(false).map((o) => o.value)).toEqual(["phone", "dev"]); + }); + + it("points the user at `playground login` in the phone hint when not logged in", () => { + const [phone] = decentralizeSignerOptions(false); + expect(phone.hint).toContain("playground login"); + }); +}); + +describe("decentralizeSignerInitialIndex", () => { + it("defaults the cursor to the phone signer (index 0) when logged in", () => { + expect(decentralizeSignerInitialIndex(true)).toBe(0); + }); + + it("defaults the cursor to the dev signer (index 1) when not logged in, so Enter never hits the login error", () => { + expect(decentralizeSignerInitialIndex(false)).toBe(1); + }); +}); diff --git a/src/commands/decentralize/signerPrompt.ts b/src/commands/decentralize/signerPrompt.ts new file mode 100644 index 00000000..2ff7836a --- /dev/null +++ b/src/commands/decentralize/signerPrompt.ts @@ -0,0 +1,51 @@ +// 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. + +import type { SignerMode } from "../../utils/deploy/signerMode.js"; +import type { SelectOption } from "../../utils/ui/theme/Select.js"; + +/** + * Signer options for the interactive `playground decentralize` picker. The + * phone signer leads, matching `playground deploy`. Unlike deploy, the phone + * option stays visible without a session (its hint points the user at + * `playground login`, and selecting it surfaces a login error), so both + * options are always present here. + */ +export function decentralizeSignerOptions(hasSession: boolean): SelectOption[] { + return [ + { + value: "phone", + label: "your phone signer", + hint: hasSession + ? "signed with your logged-in account" + : "requires `playground login` first", + }, + { + value: "dev", + label: "dev signer", + hint: "fast, signs locally with the polkadot-app-deploy default account", + }, + ]; +} + +/** + * Default cursor position for the decentralize signer picker. Because both + * options are always shown, default to the one the user can actually use: the + * phone signer (index 0) when logged in, otherwise the dev signer (index 1). + * Pressing Enter on the default therefore never lands on the login error. + */ +export function decentralizeSignerInitialIndex(hasSession: boolean): number { + return hasSession ? 0 : 1; +} diff --git a/src/commands/decentralize/state.test.ts b/src/commands/decentralize/state.test.ts index 31a3d6d7..1b831876 100644 --- a/src/commands/decentralize/state.test.ts +++ b/src/commands/decentralize/state.test.ts @@ -89,7 +89,9 @@ describe("pickNextStage", () => { ).toEqual({ kind: "confirm" }); }); - it("also lands on confirm when publish was pre-answered via --playground", () => { + 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", @@ -98,7 +100,24 @@ describe("pickNextStage", () => { domainRaw: "myapp", publishToPlayground: true, }), - ).toEqual({ kind: "confirm" }); + ).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" }); + } }); it("treats an empty-string domainRaw as 'asked-already, use auto'", () => { diff --git a/src/commands/decentralize/state.ts b/src/commands/decentralize/state.ts index 1c02fdc4..e3f5e0f9 100644 --- a/src/commands/decentralize/state.ts +++ b/src/commands/decentralize/state.ts @@ -31,6 +31,7 @@ export type Stage = | { kind: "prompt-domain" } | { kind: "validate-domain"; raw: string } | { kind: "prompt-publish" } + | { kind: "prompt-tags" } | { kind: "confirm" } | { kind: "running" } | { kind: "done"; outcome: DecentralizeOutcome } @@ -55,13 +56,22 @@ export interface PickStageInput { * `--playground` so the prompt is skipped. */ publishToPlayground: boolean | null; + /** + * Category tag for the playground listing (tri-state, mirroring deploy): + * `undefined` ⇒ not asked yet (the tag prompt runs when publishing); + * `null` ⇒ explicitly skipped (untagged); a string ⇒ a chosen tag. Pre-set + * to a string when `--tag` was passed so the prompt is skipped. Only + * consulted when publishing — omitted entirely when not. + */ + tag?: string | null; } /** * Decide which prompt stage to show next given the inputs collected so far. - * URL → signer → domain → validate-domain → publish? → confirm. Each missing - * piece surfaces its prompt; once everything is filled the `confirm` stage - * gates the actual run. + * 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). * * `domainRaw` exists so the screen can distinguish "user hasn't been * asked yet" from "user typed input but validation hasn't finished". @@ -74,6 +84,9 @@ export function pickNextStage(input: PickStageInput): Stage { 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). + if (input.publishToPlayground && input.tag === undefined) return { kind: "prompt-tags" }; return { kind: "confirm" }; } diff --git a/src/commands/deploy/DeployScreen.tsx b/src/commands/deploy/DeployScreen.tsx index b850589e..542a01cf 100644 --- a/src/commands/deploy/DeployScreen.tsx +++ b/src/commands/deploy/DeployScreen.tsx @@ -25,6 +25,7 @@ import { Section, Hint, Callout, + PromptInfo, PhoneApprovalCallout, Sparkline, Select, @@ -69,6 +70,8 @@ import { NO_SESSION_NOTICE_BODY, DEV_SIGNER_NO_XP_TITLE, DEV_SIGNER_NO_XP_BODY, + deploySignerOptions, + shouldShowDevNoXpWarning, } from "./signerNotice.js"; import { BUILD_HELP, @@ -80,7 +83,6 @@ import { DOMAIN_HELP, BUILD_DIR_HINT, DOMAIN_HINT, - type PromptBox, } from "./promptHelp.js"; import { ContractPipelineStatusAdapter } from "../contractPipelineStatus.js"; import { ContractDeployStatusView, precomputeContractDeployDisplay } from "../contractDeployUi.js"; @@ -150,14 +152,6 @@ interface Resolved { * `promptHelp.ts`. The `accent` tone marks it as informational (distinct from * the yellow `warning` notices). */ -function PromptInfo({ box }: { box: PromptBox }) { - return ( - - {box.body} - - ); -} - /** One-line dim hint above a trivial text input (domain, build directory). */ function PromptHint({ text }: { text: string }) { return ( @@ -191,6 +185,11 @@ export function DeployScreen({ !hasSession && initialMode === "phone" ? null : initialMode; const effectiveInitialSkipBuild = initialDeployContracts === true ? false : initialSkipBuild; const [mode, setMode] = useState(effectiveInitialMode); + // Which signer option the cursor is currently on in the prompt-signer Select. + // Drives the "dev signer earns no XP" warning, which only matters while the + // dev option is highlighted. Defaults to "phone" (the top, default-cursor + // option when a session exists) so the warning starts hidden. + const [highlightedSigner, setHighlightedSigner] = useState("phone"); const [deployContracts, setDeployContracts] = useState(initialDeployContracts); const [buildDir, setBuildDir] = useState(initialBuildDir); const [domain, setDomain] = useState(initialDomain); @@ -335,42 +334,24 @@ export function DeployScreen({ )} - {/* Deliberately placed just above the Select (the decision - point), after the neutral SIGNER_HELP info box, so the - "no XP" trade-off is the last thing read before picking. - Only shown with a session, when the phone alternative - actually exists. */} - {hasSession && ( - - {DEV_SIGNER_NO_XP_BODY} - - )} label="who signs the upload?" - options={[ - { - value: "dev" as SignerMode, - label: "dev signer", - hint: "fast, no phone needed", - }, - // The phone signer is only offered with a paired - // session; otherwise the notice above explains how - // to enable it. - ...(hasSession - ? [ - { - value: "phone" as SignerMode, - label: "your phone signer", - hint: "signs with your own account", - }, - ] - : []), - ]} + options={deploySignerOptions(hasSession)} + onHighlight={setHighlightedSigner} onSelect={(m) => { setMode(m); advance(skipBuild, m); }} /> + {/* Placed below the options (mirroring the phone-approval + notices) so the "no XP" trade-off appears right as the + cursor lands on the dev signer and disappears again when + the user moves back to the phone signer. */} + {shouldShowDevNoXpWarning(hasSession, highlightedSigner) && ( + + {DEV_SIGNER_NO_XP_BODY} + + )} )} diff --git a/src/commands/deploy/signerNotice.test.ts b/src/commands/deploy/signerNotice.test.ts index c55672a9..e6025cec 100644 --- a/src/commands/deploy/signerNotice.test.ts +++ b/src/commands/deploy/signerNotice.test.ts @@ -19,6 +19,8 @@ import { NO_SESSION_NOTICE_BODY, DEV_SIGNER_NO_XP_TITLE, DEV_SIGNER_NO_XP_BODY, + deploySignerOptions, + shouldShowDevNoXpWarning, } from "./signerNotice.js"; describe("dev-signer XP notice", () => { @@ -49,3 +51,32 @@ describe("dev-signer XP notice", () => { } }); }); + +describe("deploySignerOptions", () => { + it("leads with the phone signer (the default cursor position) when logged in", () => { + const opts = deploySignerOptions(true); + expect(opts.map((o) => o.value)).toEqual(["phone", "dev"]); + // The default cursor is index 0, so it lands on the phone signer. + expect(opts[0].value).toBe("phone"); + }); + + it("offers only the dev signer when there is no session", () => { + const opts = deploySignerOptions(false); + expect(opts.map((o) => o.value)).toEqual(["dev"]); + }); +}); + +describe("shouldShowDevNoXpWarning", () => { + it("shows only while the dev option is highlighted with a session present", () => { + expect(shouldShowDevNoXpWarning(true, "dev")).toBe(true); + }); + + it("hides on the phone option (the user moved back off the dev signer)", () => { + expect(shouldShowDevNoXpWarning(true, "phone")).toBe(false); + }); + + it("never shows without a session (no phone alternative to switch to)", () => { + expect(shouldShowDevNoXpWarning(false, "dev")).toBe(false); + expect(shouldShowDevNoXpWarning(false, "phone")).toBe(false); + }); +}); diff --git a/src/commands/deploy/signerNotice.ts b/src/commands/deploy/signerNotice.ts index d046316f..f6516479 100644 --- a/src/commands/deploy/signerNotice.ts +++ b/src/commands/deploy/signerNotice.ts @@ -13,6 +13,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +import type { SignerMode } from "../../utils/deploy/signerMode.js"; +import type { SelectOption } from "../../utils/ui/theme/Select.js"; + /** * Shown when a deploy starts without a logged-in mobile session. * @@ -50,3 +53,34 @@ export const DEV_SIGNER_NO_XP_BODY = export const NO_SESSION_HEADLESS_ERROR = "Mobile (phone) signing needs a logged-in session. " + 'Run "playground login" to pair your phone, or use "--signer dev" for a dev deploy.'; + +/** + * The signer options for the interactive `playground deploy` picker. The phone + * signer leads (and so is the default cursor position) when a session exists; + * without one it isn't offered at all and {@link NO_SESSION_NOTICE_BODY} + * explains how to enable it. + */ +export function deploySignerOptions(hasSession: boolean): SelectOption[] { + const phone: SelectOption = { + value: "phone", + label: "your phone signer", + hint: "signs with your own account", + }; + const dev: SelectOption = { + value: "dev", + label: "dev signer", + hint: "fast, no phone needed", + }; + return hasSession ? [phone, dev] : [dev]; +} + +/** + * Whether to show the {@link DEV_SIGNER_NO_XP_TITLE} warning. The "no XP" + * trade-off only matters while the dev option is highlighted AND a phone + * alternative actually exists to switch to (i.e. the user is logged in), so the + * warning appears as the cursor lands on the dev signer and disappears again on + * the way back to the phone signer. Shared by `deploy` and `decentralize`. + */ +export function shouldShowDevNoXpWarning(hasSession: boolean, highlighted: SignerMode): boolean { + return hasSession && highlighted === "dev"; +} diff --git a/src/utils/decentralize/run.ts b/src/utils/decentralize/run.ts index dd19c2f8..ddc87be6 100644 --- a/src/utils/decentralize/run.ts +++ b/src/utils/decentralize/run.ts @@ -119,6 +119,12 @@ export interface RunDecentralizeOptions { * GitHub) and `isModdable` is forced to false. */ publishToPlayground?: boolean; + /** + * Single playground category tag for the listing. `null`/omitted publishes + * untagged. Only consulted when `publishToPlayground` is true. Mirrors + * deploy's `--tag`; values come from `PLAYGROUND_TAGS`. + */ + tag?: string | null; env: Env; onEvent?: (event: DecentralizeLogEvent) => void; } @@ -277,6 +283,7 @@ export async function runDecentralize( // Mirrored sites have no git source — `repository` is omitted // from the metadata JSON and `is_moddable` is forced false. repositoryUrl: null, + tag: options.tag ?? null, env, isPrivate: false, isModdable: false, diff --git a/src/utils/ui/theme/PromptInfo.tsx b/src/utils/ui/theme/PromptInfo.tsx new file mode 100644 index 00000000..d2ea24b8 --- /dev/null +++ b/src/utils/ui/theme/PromptInfo.tsx @@ -0,0 +1,31 @@ +// 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. + +import { Text } from "ink"; +import { Callout } from "./Callout.js"; + +/** + * The magenta (accent) help box shown above an interactive prompt. Used by + * `playground deploy` and `playground decentralize` to explain each choice + * before the user makes it. `box` is structurally a `PromptBox` + * (`{ title, body }`) — the copy lives in each command's `promptHelp.ts`. + */ +export function PromptInfo({ box }: { box: { title: string; body: string } }) { + return ( + + {box.body} + + ); +} diff --git a/src/utils/ui/theme/Select.tsx b/src/utils/ui/theme/Select.tsx index ad4cb33e..7ba46b80 100644 --- a/src/utils/ui/theme/Select.tsx +++ b/src/utils/ui/theme/Select.tsx @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Box, Text, useInput } from "ink"; import { COLOR, GLYPH, LAYOUT } from "./tokens.js"; @@ -28,12 +28,28 @@ export interface SelectProps { options: SelectOption[]; initialIndex?: number; onSelect: (value: T) => void; + /** Fires with the highlighted value on mount and whenever the cursor moves + (before Enter confirms). Lets callers reveal context for the focused option. */ + onHighlight?: (value: T) => void; } /** Keyboard picker: ↑/↓ move, Enter confirms. Replaces the ad-hoc SignerPrompt / YesNoPrompt shapes. */ -export function Select({ label, options, initialIndex = 0, onSelect }: SelectProps) { +export function Select({ + label, + options, + initialIndex = 0, + onSelect, + onHighlight, +}: SelectProps) { const [index, setIndex] = useState(Math.min(Math.max(initialIndex, 0), options.length - 1)); + useEffect(() => { + onHighlight?.(options[index].value); + // Re-fire only when the highlighted index changes; options/onHighlight + // are stable for the lifetime of a given prompt. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [index]); + useInput((_input, key) => { if (key.upArrow || key.leftArrow) { setIndex((i) => (i - 1 + options.length) % options.length); diff --git a/src/utils/ui/theme/index.tsx b/src/utils/ui/theme/index.tsx index 4ce75067..e34cd690 100644 --- a/src/utils/ui/theme/index.tsx +++ b/src/utils/ui/theme/index.tsx @@ -40,6 +40,7 @@ export { Select, type SelectOption, type SelectProps } from "./Select.js"; export { Input, type InputProps } from "./Input.js"; export { LogTail, type LogTailProps } from "./LogTail.js"; export { Callout } from "./Callout.js"; +export { PromptInfo } from "./PromptInfo.js"; export { PhoneApprovalCallout, type PhoneApprovalCalloutProps, diff --git a/src/utils/version-check.test.ts b/src/utils/version-check.test.ts index 85992b0d..45ba06fb 100644 --- a/src/utils/version-check.test.ts +++ b/src/utils/version-check.test.ts @@ -114,11 +114,11 @@ describe("shouldSkip", () => { }); describe("formatBanner", () => { - it("includes both versions with v-prefixes and the playground update hint", () => { + it("includes both versions with v-prefixes and the pg update hint", () => { const out = formatBanner("0.16.14", "0.16.15"); expect(out).toContain("v0.16.14"); expect(out).toContain("v0.16.15"); - expect(out).toContain("playground update"); + expect(out).toContain("pg update"); }); }); @@ -164,7 +164,7 @@ describe("startVersionCheck", () => { expect(banner).not.toBeNull(); expect(banner).toContain("v0.16.14"); expect(banner).toContain("v0.17.0"); - expect(banner).toContain("playground update"); + expect(banner).toContain("pg update"); }); it("returns null when the network call fails", async () => { diff --git a/src/utils/version-check.ts b/src/utils/version-check.ts index 0c5eefb2..23f9a450 100644 --- a/src/utils/version-check.ts +++ b/src/utils/version-check.ts @@ -116,7 +116,7 @@ export function formatBanner(currentVersion: string, latestVersion: string): str // visual treatment would quickly become noise. return ( `\n ${ANSI_YELLOW}${GLYPH.warn}${ANSI_RESET} Update available: ${current} → ${latest}\n` + - ` Run ${ANSI_BOLD}playground update${ANSI_RESET} to upgrade.\n` + ` Run ${ANSI_BOLD}pg update${ANSI_RESET} to update the CLI.\n` ); }