From dede259187d9244892a5407609a21eaed1a47ec6 Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Fri, 17 Apr 2026 17:27:12 +0100 Subject: [PATCH] Add dot build + dot deploy with two signer modes and playground publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `dot build` and a fully-rewritten `dot deploy` plus the supporting infrastructure (config module, process-guard, signer-mode matrix, signing proxy, availability preflight, CAR-upload wrapper, playground registry publisher, Ink TUI). Key behaviours -------------- * `dot build` auto-detects the package manager (pnpm/yarn/bun/npm from the lockfile) and runs the `build` script, falling back to vite/next/tsc when no script is defined. * `dot deploy` prompts for signer (dev/phone), build dir, domain, and publish-to-playground, or runs fully headless when all four flags are passed. After inputs resolve, the TUI announces exactly how many phone approvals will be needed and what each is for. * Dev mode uploads + DotNS use bulletin-deploy's default pool mnemonic (0 phone taps for the heavy work). Phone mode signs DotNS with the user's session signer (3 taps). Playground publish is always signed by the user so `registry.publish`'s `env::caller()` records them as the app owner. * Domain availability is checked before build: `classifyName` catches Reserved names; `checkOwnership(label, ss58ToH160(user))` correctly identifies "owned by you → update" vs "owned by someone else → taken". Defense in depth ---------------- * SIGINT/SIGTERM/SIGHUP + unhandledRejection handlers run cleanup and force-exit within 3 s. * `unref`'d hard-exit timer fires if a stray WebSocket keeps the event loop alive after the main flow returns. * 4 GB RSS watchdog aborts on runaway growth. `DOT_MEMORY_TRACE=1` streams per-sample memory stats. * `BULLETIN_DEPLOY_TELEMETRY=0` forced at CLI entry (Sentry off by default). * Bun compiled-binary stdin warm-up listener (required for Ink `useInput`). Non-obvious choices ------------------- * `bulletin-deploy` pinned to `0.6.9-rc.6`; the `latest` dist-tag still points at 0.6.8 which has a WS heartbeat bug. Don't revert to `latest`. * `jsMerkle: true` intentionally NOT passed to `bulletin-deploy` — the pure-JS merkleizer produces CARs missing their DAG-PB structural blocks (verified against a real deployed CAR). Kubo binary path is used instead; `dot init` installs `ipfs`. * Playground metadata upload uses a dedicated Bulletin client with a 300 s WS heartbeat, not the shared `getConnection()` (which uses polkadot-api's 40 s default and tears down mid-tx). * `DeployLogParser` deliberately emits events ONLY for phase banners and `[N/M]` chunk progress — catch-all `info` events were a measurable contributor to 20+ GB heap pressure during long deploys. Ships with 122 tests across 15 files covering the orchestrator event flow, build detection, availability, playground publish, signing proxy, progress parser, and the summary view. All gated by `tsc --noEmit` + `biome format`. --- .changeset/deploy-and-build.md | 24 + CLAUDE.md | 14 +- README.md | 44 +- package.json | 3 +- pnpm-lock.yaml | 13 +- src/commands/build.ts | 23 +- src/commands/deploy.ts | 14 - src/commands/deploy/DeployScreen.tsx | 692 ++++++++++++++++++++++++++ src/commands/deploy/index.ts | 335 +++++++++++++ src/commands/deploy/summary.test.ts | 85 ++++ src/commands/deploy/summary.ts | 56 +++ src/config.ts | 57 +++ src/index.ts | 33 +- src/utils/auth.ts | 11 +- src/utils/build/detect.test.ts | 128 +++++ src/utils/build/detect.ts | 158 ++++++ src/utils/build/index.ts | 17 + src/utils/build/runner.ts | 114 +++++ src/utils/deploy/availability.test.ts | 198 ++++++++ src/utils/deploy/availability.ts | 155 ++++++ src/utils/deploy/index.ts | 44 ++ src/utils/deploy/playground.test.ts | 127 +++++ src/utils/deploy/playground.ts | 165 ++++++ src/utils/deploy/progress.test.ts | 86 ++++ src/utils/deploy/progress.ts | 95 ++++ src/utils/deploy/run.test.ts | 217 ++++++++ src/utils/deploy/run.ts | 237 +++++++++ src/utils/deploy/signerMode.ts | 95 ++++ src/utils/deploy/signingProxy.ts | 73 +++ src/utils/deploy/storage.ts | 123 +++++ src/utils/process-guard.ts | 170 +++++++ 31 files changed, 3564 insertions(+), 42 deletions(-) create mode 100644 .changeset/deploy-and-build.md delete mode 100644 src/commands/deploy.ts create mode 100644 src/commands/deploy/DeployScreen.tsx create mode 100644 src/commands/deploy/index.ts create mode 100644 src/commands/deploy/summary.test.ts create mode 100644 src/commands/deploy/summary.ts create mode 100644 src/config.ts create mode 100644 src/utils/build/detect.test.ts create mode 100644 src/utils/build/detect.ts create mode 100644 src/utils/build/index.ts create mode 100644 src/utils/build/runner.ts create mode 100644 src/utils/deploy/availability.test.ts create mode 100644 src/utils/deploy/availability.ts create mode 100644 src/utils/deploy/index.ts create mode 100644 src/utils/deploy/playground.test.ts create mode 100644 src/utils/deploy/playground.ts create mode 100644 src/utils/deploy/progress.test.ts create mode 100644 src/utils/deploy/progress.ts create mode 100644 src/utils/deploy/run.test.ts create mode 100644 src/utils/deploy/run.ts create mode 100644 src/utils/deploy/signerMode.ts create mode 100644 src/utils/deploy/signingProxy.ts create mode 100644 src/utils/deploy/storage.ts create mode 100644 src/utils/process-guard.ts diff --git a/.changeset/deploy-and-build.md b/.changeset/deploy-and-build.md new file mode 100644 index 0000000..5e2caad --- /dev/null +++ b/.changeset/deploy-and-build.md @@ -0,0 +1,24 @@ +--- +"playground-cli": minor +--- + +- New `dot build` command — auto-detects pnpm/yarn/bun/npm from the project's lockfile and runs the `build` script. Falls back to direct vite/next/tsc invocation when no build script is defined. +- New interactive `dot deploy` flow. Prompts in order: signer (`dev` default / `phone`), build directory (default `dist/`), domain, and publish-to-playground (y/n). After inputs are chosen the TUI shows a dynamic summary card announcing exactly how many phone approvals will be requested and what each one is for. +- Two signer modes for deploy: + - `--signer dev` — `0` phone approvals if you don't publish to Playground, `1` if you do. Upload and DotNS are done with shared dev keys. + - `--signer phone` — `3` approvals (DotNS commitment, finalize, setContenthash) + `1` for Playground publish if enabled. +- Flags: `--signer`, `--domain`, `--buildDir`, `--playground`, `--suri`, `--env`. Passing all four of `--signer`, `--domain`, `--buildDir`, and `--playground` runs non-interactively. +- Publishing to the Playground registry is always signed by the user, so the contract records their address as the app owner. This is what drives the playground-app "my apps" view. +- Domain availability preflight — after you type a domain we hit DotNS's `classifyName` + `checkOwnership` (view calls, no phone taps) so names reserved for governance or already registered by a different account are caught BEFORE we build and upload. Headless mode fails fast with the reason; interactive mode shows the reason inline and lets you type a different name without restarting. +- Re-deploying the same domain now works. The availability check used to fall back to bulletin-deploy's default dev mnemonic for the ownership comparison, so a domain owned by the user's own phone signer came back as `taken` — blocking every legitimate content update. The caller now passes their SS58 address, we derive the H160 via `@polkadot-apps/address::ss58ToH160`, and `checkOwnership(label, userH160)` returns `owned: true` when the user is the owner → we surface it as an `available` with the note "Already owned by you — will update the existing deployment.". +- All chain URLs, contract addresses, and the `testnet`/`mainnet` switch consolidated into a single `src/config.ts`. +- Deploy SDK is importable from `src/utils/deploy` without pulling in React/Ink so WebContainer consumers (RevX) can drive their own UI off the same event stream. +- Workaround for Bun compiled-binary TTY stdin bug that prevented `useInput`-driven TUIs from receiving keystrokes or Ctrl+C. A no-op `readable` listener is attached at CLI entry as a warm-up. +- Bumped `bulletin-deploy` from 0.6.7 to 0.6.9-rc.4. Fixes `WS halt (3)` during chunk upload (heartbeat bumped from 40s to 300s to exceed the 60s chunk timeout) and eliminates nonce-hopping on retries that used to duplicate chunk storage and trigger txpool readiness timeouts. Pin is deliberately on the RC tag — the `latest` npm tag still points at the broken 0.6.8. +- Fixed runaway memory use (observed 20+ GB) during long deploys. The TUI was calling `setState` on every build-log and bulletin-deploy console line; verbose frameworks and retry storms produced enough React update backpressure to balloon the process. Info updates are now coalesced to ≤10/sec and capped at 160 chars. +- Fixed `Contract execution would revert` failure in the Playground publish step. The metadata-JSON upload was routed through `bulletin-deploy.deploy()`, which unconditionally runs a second DotNS `register()` + `setContenthash()` on a randomly generated `test-domain-` label — that's what was reverting. We now upload the metadata via `@polkadot-apps/bulletin::upload()` (pure `TransactionStorage.store`, no DotNS) and only invoke DotNS for the user's real domain. The user's phone signer is now correctly driven when `registry.publish()` fires, so the "Check your phone" panel appears as expected. +- Fixed `WS halt (3)` recurrence after switching the metadata upload to `@polkadot-apps/bulletin`. That path went through the shared `@polkadot-apps/chain-client` Bulletin WS, which uses polkadot-api's 40 s default heartbeat — shorter than a single `TransactionStorage.store` submission. The upload now uses a dedicated Bulletin client built with `heartbeatTimeout: 300 s` and destroyed immediately after (same value `bulletin-deploy` uses for its own clients). +- Added a multi-layer process-guard (`src/utils/process-guard.ts`) to eliminate zombie `dot` processes that had been observed accumulating to 25+ GB of RSS and triggering OS swap-death. (1) SIGINT/SIGTERM/SIGHUP and `unhandledRejection` all run cleanup hooks and force-exit within 3 s; (2) after the deploy's main flow returns, an `unref`'d hard-exit timer kills the process if a leaked WebSocket keeps the event loop alive past a grace period; (3) a 4 GB absolute RSS watchdog aborts the deploy before the machine swaps to death; (4) `BULLETIN_DEPLOY_TELEMETRY` is defaulted to `"0"` so Sentry can no longer buffer breadcrumbs; (5) the stdin warmup listener is `unref`'d so it doesn't hold the loop open on exit. Set `DOT_MEMORY_TRACE=1` to stream per-sample memory stats (RSS / heap / external) when diagnosing a real leak. +- Bumped `bulletin-deploy` from 0.6.9-rc.4 to 0.6.9-rc.6 (picks up DotNS commit-reveal + commitment-age fixes). +- Cut the log-event firehose: `DeployLogParser` now only emits events for phase banners and `[N/M]` chunk progress — NOT for every info prose line bulletin-deploy prints. Previously every line allocated an event object + traversed the orchestrator→TUI pipeline, compounding heap pressure during long chunk uploads. +- Fixed deployed sites returning `{"message":"404: Not found"}` in Polkadot Desktop. Bulletin-deploy's pure-JS merkleizer (`jsMerkle: true` path) produces CARs containing only the raw leaf blocks — the DAG-PB directory/file structural nodes are silently dropped by `blockstore-core/memory`'s `getAll()` iterator. Desktop fetches the CAR, sees the declared root CID, finds no block for it in the CAR, parses zero files, renders 404. We now leave `jsMerkle` off so bulletin-deploy uses the Kubo binary path (`ipfs add -r ...`) which produces a complete, parseable CAR. `dot init` installs `ipfs`, so this works out of the box. Note: this temporarily regresses the RevX WebContainer story for the main storage upload — we'll flip `jsMerkle: true` back on once the upstream merkleizer is fixed to collect all blocks, not just leaves. diff --git a/CLAUDE.md b/CLAUDE.md index 41e9fe7..c82e0aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,9 +7,21 @@ Refer to the **Contributing** and **Architecture Highlights** sections of [READM These are things that aren't self-evident from reading the code and have bitten us before: - **Do not upgrade `polkadot-api` or `@polkadot-api/sdk-ink`** past the current pins without also bumping `@polkadot-apps/chain-client`. Newer versions break the internal `PolkadotClient` shape that `chain-client` still relies on. -- **The mobile app wraps `signRaw` data with ``**, which breaks transaction signing. Our `src/utils/signer.ts` exists specifically to route transactions through `signPayload` instead. Delete this file once `@polkadot-apps/terminal` ships a fix — nothing else. +- **The mobile app wraps `signRaw` data with ``**, which breaks transaction signing. Our `src/utils/session-signer-patch.ts` exists specifically to route transactions through `signPayload` instead. Delete this file once `@polkadot-apps/terminal` ships a fix — nothing else. - **`getSessionSigner()` returns an adapter that keeps the Node event loop alive**. Every caller must invoke the returned `destroy()` when done. If you add a new top-level command that signs on behalf of the user, wire up the cleanup or the process will hang after the work is done. - **`dot init` auto-runs at the end of `install.sh`**. If the init fails, the exit code is surfaced so CI runs don't silently pass. +- **All chain URLs / contract addresses live in `src/config.ts`**. Never inline a websocket URL or an `0x…` address anywhere else — when mainnet launches we'll be flipping one switch, not grepping the tree. +- **Deploy delegates to `bulletin-deploy` for everything storage-related** (chunking, retries, pool accounts, nonce fallback, DAG-PB, DotNS commit-reveal). We intentionally do NOT reimplement any of that here. The one thing we own is `registry.publish()` — because the contract records `env::caller()` as app owner and that needs to be the user, not a shared dev key. See `src/utils/deploy/playground.ts`. +- **Do NOT call `bulletin-deploy.deploy()` just to store a metadata JSON.** `deploy()` unconditionally runs a DotNS `register()` + `setContenthash()` for whatever name you hand it — and for `domainName: null` it invents a `test-domain-` label and registers THAT. That second DotNS pass reverts cryptically (`Contract execution would revert: 0x…`). For plain storage of the playground metadata we use `@polkadot-apps/bulletin::upload()` → it submits `TransactionStorage.store` directly and returns the CID. No DotNS side-trip. +- **Build a dedicated Bulletin client with `heartbeatTimeout: 300_000` for the metadata upload.** The shared client from `getConnection()` uses `@polkadot-apps/chain-client`, which calls `getWsProvider(rpcs)` with no options → polkadot-api's 40 s default heartbeat. A single `TransactionStorage.store` round-trip can exceed 40 s and the socket tears down as `WS halt (3)`. `bulletin-deploy` sidesteps this with its own 300 s heartbeat; we mirror that with a one-off client in `src/utils/deploy/playground.ts` that we destroy immediately after the upload. +- **`dot deploy` does NOT pass `jsMerkle: true` to `bulletin-deploy` right now.** bulletin-deploy's pure-JS merkleizer produces CARs that only contain raw leaves — the DAG-PB directory/file blocks are silently dropped by `blockstore-core/memory`'s `getAll()` under `rawLeaves: true` + `wrapWithDirectory: true`. Proof: a real deployed CAR we fetched from `paseo-ipfs.polkadot.io` contained 157 raw blocks and zero DAG-PB, with the declared root absent → polkadot-desktop parses zero files → sites show `{"message":"404: Not found"}`. Until the upstream merkleizer is fixed we rely on the Kubo binary path (the default), which is reliable. `dot init` installs `ipfs`, so this Just Works for anyone who ran setup. **Trade-off**: this temporarily breaks the RevX WebContainer story for the main storage upload — flip `jsMerkle: true` back once bulletin-deploy fixes `merkleizeJS` to collect all blocks, not just leaves. +- **Signer mode selection lives in one file** (`src/utils/deploy/signerMode.ts`). The mainnet rewrite is a single-file swap; keep that boundary clean. +- **`src/utils/deploy/*` and `src/utils/build/*` must not import React or Ink.** They form the SDK surface that RevX consumes from a WebContainer. TUI code lives in `src/commands/*/`. +- **Bun compiled-binary stdin quirk** — Ink's `useInput` silently drops every keystroke (arrows, Enter, Ctrl+C) in `bun build --compile` binaries unless `process.stdin.on('readable', …)` is touched before Ink's `render()`. We install a no-op `readable` listener at the top of `src/index.ts` as a warm-up. Do NOT remove it until Bun's compiled-binary TTY stdin behaves like Node's. Symptom if this breaks: TUI renders but nothing responds, including Ctrl+C. +- **`bulletin-deploy` is pinned to an RC, not `latest`.** The `latest` npm dist-tag points at 0.6.8, which has a WebSocket heartbeat bug (default 40s < chunk timeout 60s) that tears down uploads mid-flight as `WS halt (3)`. The fix lives in 0.6.9-rc.x under the `rc` dist-tag. Keep us pinned to an explicit `0.6.9-rc.N` until 0.6.9 stable ships. Do NOT revert `bulletin-deploy` to `"latest"` in package.json — that silently downgrades us back to the broken version. +- **Throttle TUI info updates** — bulletin-deploy logs per-chunk and builds (vite/next) stream thousands of lines/sec. Calling `setState` on every log event floods React's reconciler with so much backpressure the process can balloon past 20 GB and freeze the OS. `RunningStage` coalesces "latest info" updates to ≤10/sec via a ref + timer and caps line length at 160 chars. Any new hot-path event sink should do the same; don't hook raw per-line streams directly into Ink state. +- **Process-guard safety net** (`src/utils/process-guard.ts`) — deploy pipelines open several long-lived WebSockets + child processes and any one of them can keep the event loop alive after the TUI visibly finishes, turning `dot` into a zombie that accumulates retry buffers indefinitely (seen climbing past 25 GB). We defend in depth: (1) `installSignalHandlers()` catches SIGINT/TERM/HUP + `unhandledRejection` and forces cleanup + exit within 3 s; (2) `scheduleHardExit()` installs an `unref`'d timer that kills the process if the event loop doesn't drain within a grace period; (3) `startMemoryWatchdog()` aborts if RSS exceeds 4 GB — a generous cap because legit deploys on Bun SEA binaries routinely touch 1–1.5 GB from runtime-metadata decoding + Bun's JSC heap + Ink yoga. Do NOT re-add a per-window growth detector: we tried 300 MB / 3 s and it false-positived on the single-burst metadata-loading spike, aborting deploys that would have succeeded. Set `DOT_MEMORY_TRACE=1` to stream per-sample RSS/heap/external stats — useful when diagnosing a real leak report. `BULLETIN_DEPLOY_TELEMETRY` is also forced to `"0"` at CLI entry — Sentry buffers breadcrumbs in-memory. Any new long-running command should register a cleanup hook via `onProcessShutdown()`. +- **Parser MUST NOT emit an event per log line.** `DeployLogParser.feed()` is called for every console line bulletin-deploy prints — hundreds per deploy on the happy path, thousands if retries fire. We intentionally emit events ONLY for phase-banner matches and `[N/M]` chunk progress. Everything else returns `null`. Adding a catch-all `info` emit turns the parser into a firehose that allocates ~200 bytes × thousands of lines, and was a measurable contributor to chunk-upload memory pressure. ## Repo conventions diff --git a/README.md b/README.md index 2655b22..8813d84 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,34 @@ Flags: Self-update from the latest GitHub release. Detects your OS/arch, downloads the corresponding `dot--` asset, verifies HOME is set, and atomically replaces the running binary (write-to-staging-then-rename so the running process is never served a half-written file). -### `dot deploy` (stub) +### `dot build` -Will build and publish an app + its contracts. Currently accepts and prints its flags: +Auto-detects the project's package manager (pnpm / yarn / bun / npm from the lockfile) and runs the `build` npm script. If no `build` script is defined, falls back to a framework invocation (`vite build`, `next build`, `tsc`) based on what's installed. -- `--contracts` — include contract build & deploy -- `--skip-frontend` — skip frontend build & deploy -- `--domain ` — DNS name override (else read from `package.json`) -- `--playground` — publish to the playground registry -- `--env ` — `testnet` (default) or `mainnet` -- `-y, --yes` — skip interactive prompts +Flags: + +- `--dir ` — project directory (defaults to the current working directory). + +### `dot deploy` + +Builds the project, uploads the output to Bulletin, registers a `.dot` domain via DotNS, and optionally publishes the app to the Playground registry (so it shows up in the user's "my apps" list). + +Flags: + +- `--signer ` — `dev` (fast, uses shared dev keys for upload + DotNS — 0 or 1 phone approval) or `phone` (signs DotNS + publish with your logged-in account — 3 or 4 phone approvals). Interactive prompt if omitted. +- `--domain ` — DotNS label (with or without the `.dot` suffix). Interactive prompt if omitted. +- `--buildDir ` — directory holding the built artifacts (default `dist/`). Interactive prompt if omitted. +- `--playground` — publish to the playground registry so the app appears under "my apps". Interactive prompt (default: no) if omitted. +- `--suri ` — override signer with a dev secret URI (e.g. `//Alice`). Useful for CI. +- `--env ` — `testnet` (default) or `mainnet` (not yet supported). + +Passing all four of `--signer`, `--domain`, `--buildDir`, and `--playground` runs in fully non-interactive mode. Any absent flag is filled in by the TUI prompt. + +**Requirement**: the `ipfs` CLI (Kubo) must be on `PATH`. `dot init` installs it; if you skipped init you can install it manually (`brew install ipfs` or follow [docs.ipfs.tech/install](https://docs.ipfs.tech/install/)). This is a temporary requirement while `bulletin-deploy`'s pure-JS merkleizer has a bug that makes the browser fallback unusable. + +The publish step is always signed by the user so the registry contract records their address as the app owner — this is what drives the Playground "my apps" view. -### `dot mod` / `dot build` (stubs) +### `dot mod` (stub) Planned. No behaviour yet. @@ -108,9 +124,17 @@ pnpm format:check # check only - `@polkadot-apps/*` are pinned to `latest` intentionally — they are our own packages and we want the lockfile to track head. - `@polkadot-api/sdk-ink` is pinned to `^0.6.2` and `polkadot-api` to `^1.23.3` because `chain-client` currently embeds an internal `PolkadotClient` shape that breaks with newer versions. Bump together with `chain-client` only. +- `bulletin-deploy` is pinned to an explicit `0.6.9-rc.N` — not `latest`. The `latest` npm dist-tag still points at 0.6.8, which has a WebSocket heartbeat bug (40s default < 60s chunk timeout) that tears down chunk uploads as `WS halt (3)`. Move to 0.6.9 once it ships stable; until then bump the RC pin (published under the `rc` npm dist-tag) to pick up further fixes. ## Architecture Highlights -- **Signer shim** (`src/utils/signer.ts`) — the default session signer from `@polkadot-apps/terminal` uses `signRaw`, which the Polkadot mobile app wraps with `` (producing a `BadProof` on-chain). We delegate to `getPolkadotSignerFromPjs` from `polkadot-api/pjs-signer`, which formats the payload as polkadot.js `SignerPayloadJSON` — exactly what the mobile's `SignPayloadJsonInteractor` consumes. This file can be removed once `@polkadot-apps/terminal` defaults to `signPayload`. +- **Single config module** (`src/config.ts`) — all chain URLs, contract addresses, dapp identifiers and the `testnet`/`mainnet` switch live here. Nothing else in the tree should hard-code an endpoint or address. +- **Signer shim** (`src/utils/session-signer-patch.ts`) — the default session signer from `@polkadot-apps/terminal` uses `signRaw`, which the Polkadot mobile app wraps with `` (producing a `BadProof` on-chain). We delegate to `getPolkadotSignerFromPjs` from `polkadot-api/pjs-signer`, which formats the payload as polkadot.js `SignerPayloadJSON` — exactly what the mobile's `SignPayloadJsonInteractor` consumes. This file can be removed once `@polkadot-apps/terminal` defaults to `signPayload`. +- **Unified signer resolution** (`src/utils/signer.ts`) — one `resolveSigner({ suri? })` call returns a `ResolvedSigner` whether the user is authenticated via QR session or a dev `//Alice`-style URI. Every command threads the result through to its operations instead of branching on source. - **Connection singleton** (`src/utils/connection.ts`) — stores the promise (not the resolved client) so concurrent callers share a single WebSocket. Has a 30s timeout and preserves the underlying error via `Error.cause` for debugging. - **Session lifecycle** (`src/utils/auth.ts`) — `getSessionSigner()` returns an explicit `destroy()` handle. Callers MUST call it (typically from a `useEffect` cleanup) — the host-papp adapter keeps the Node event loop alive. +- **Deploy SDK / CLI split** (`src/utils/deploy/` + `src/commands/deploy/`) — the CLI command is a thin Commander + Ink wrapper around a pure `runDeploy()` orchestrator. The orchestrator avoids React/Ink so WebContainer consumers (e.g. RevX) can drive their own UI off the same event stream. +- **Signer-mode isolation** (`src/utils/deploy/signerMode.ts`) — decides which signer each deploy phase uses (pool mnemonic vs user's phone) in one place so the mainnet rewrite can be a single-file swap. +- **Bulletin delegation** — all storage-side hardening (pool management, chunk retry, nonce fallback, DAG-PB verification, DotNS commit-reveal) stays inside `bulletin-deploy`. We call `deploy(..., { jsMerkle: true })` so the flow stays binary-free and runs unchanged in a WebContainer. +- **Signing proxy** (`src/utils/deploy/signingProxy.ts`) — wraps the user's `PolkadotSigner` to emit `sign-request`/`-complete`/`-error` lifecycle events. The TUI renders these as "📱 Check your phone" panels with live step counts. +- **Playground publish is ours** (`src/utils/deploy/playground.ts`) — we deliberately do NOT use `bulletin-deploy`'s `--playground` flag. We call the registry contract from `src/utils/registry.ts` with the user's signer so the contract records their `env::caller()` as the owner — required for the Playground app's "my apps" view. diff --git a/package.json b/package.json index 866bb45..b5c10df 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,12 @@ "@polkadot-apps/bulletin": "latest", "@polkadot-apps/chain-client": "latest", "@polkadot-apps/contracts": "latest", + "@polkadot-apps/descriptors": "latest", "@polkadot-apps/keys": "latest", "@polkadot-apps/terminal": "latest", "@polkadot-apps/tx": "latest", "@polkadot-apps/utils": "latest", - "bulletin-deploy": "latest", + "bulletin-deploy": "0.6.9-rc.6", "commander": "^12.0.0", "ink": "^5.2.1", "polkadot-api": "^1.23.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34f768e..8ef9445 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@polkadot-apps/contracts': specifier: latest version: 0.3.2(@novasamatech/host-api@0.6.17)(@polkadot-api/ink-contracts@0.6.1)(postcss@8.5.10)(rxjs@7.8.2)(typescript@5.9.3) + '@polkadot-apps/descriptors': + specifier: latest + version: 1.0.1(polkadot-api@1.23.3(postcss@8.5.10)(rxjs@7.8.2)) '@polkadot-apps/keys': specifier: latest version: 0.4.4(postcss@8.5.10)(rxjs@7.8.2) @@ -36,8 +39,8 @@ importers: specifier: latest version: 0.4.0 bulletin-deploy: - specifier: latest - version: 0.6.7(@polkadot-api/ink-contracts@0.6.1)(@polkadot/util@13.5.9)(postcss@8.5.10)(rxjs@7.8.2)(typescript@5.9.3) + specifier: 0.6.9-rc.6 + version: 0.6.9-rc.6(@polkadot-api/ink-contracts@0.6.1)(@polkadot/util@13.5.9)(postcss@8.5.10)(rxjs@7.8.2)(typescript@5.9.3) commander: specifier: ^12.0.0 version: 12.1.0 @@ -1599,8 +1602,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bulletin-deploy@0.6.7: - resolution: {integrity: sha512-RyXF5EpdzcuZIWpTMzqcNgmfOvIT4xd1kJiDAYnbpbpIvNrQumGWTKh/3aactUm7DCd4tTC5YscAW2Ej5x0qRQ==} + bulletin-deploy@0.6.9-rc.6: + resolution: {integrity: sha512-oErhG1uMmdlqFR5Zt4NYJ7BIoscbVwri+u8w4wVWHe4rqgLSd6lXgZ9dwSRAXPcCVsYI88Wt2FB3r9OeVtEiag==} engines: {node: '>=22'} hasBin: true @@ -5010,7 +5013,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bulletin-deploy@0.6.7(@polkadot-api/ink-contracts@0.6.1)(@polkadot/util@13.5.9)(postcss@8.5.10)(rxjs@7.8.2)(typescript@5.9.3): + bulletin-deploy@0.6.9-rc.6(@polkadot-api/ink-contracts@0.6.1)(@polkadot/util@13.5.9)(postcss@8.5.10)(rxjs@7.8.2)(typescript@5.9.3): dependencies: '@dotdm/cdm': 0.5.4(@polkadot-api/ink-contracts@0.6.1)(postcss@8.5.10)(rxjs@7.8.2)(typescript@5.9.3) '@ipld/car': 5.4.3 diff --git a/src/commands/build.ts b/src/commands/build.ts index f784f46..0d8ec3f 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -1,7 +1,24 @@ import { Command } from "commander"; +import { runBuild, loadDetectInput, detectBuildConfig } from "../utils/build/index.js"; export const buildCommand = new Command("build") - .description("Detect and build all contracts and frontend") - .action(async () => { - console.log("TODO: build"); + .description("Auto-detect and run the project's build") + .option("--dir ", "Project directory", process.cwd()) + .action(async (opts: { dir: string }) => { + try { + const config = detectBuildConfig(loadDetectInput(opts.dir)); + process.stdout.write(`\n> ${config.description}\n\n`); + + const result = await runBuild({ + cwd: opts.dir, + config, + onData: (line) => process.stdout.write(`${line}\n`), + }); + + process.stdout.write(`\n✔ Build succeeded → ${result.outputDir}\n`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`\n✖ ${msg}\n`); + process.exit(1); + } }); diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts deleted file mode 100644 index 461c0db..0000000 --- a/src/commands/deploy.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Command } from "commander"; - -export const deployCommand = new Command("deploy") - .description("Build and deploy contracts and frontend") - .option("--suri ", "Signer secret URI (e.g. //Alice for dev)") - .option("--contracts", "Include contract build & deploy") - .option("--skip-frontend", "Skip frontend build & deploy") - .option("--domain ", "App domain (overrides package.json)") - .option("--playground", "Publish to the playground registry") - .option("--env ", "Target environment: testnet or mainnet", "testnet") - .option("-y, --yes", "Skip interactive prompts") - .action(async (opts) => { - console.log("TODO: deploy", opts); - }); diff --git a/src/commands/deploy/DeployScreen.tsx b/src/commands/deploy/DeployScreen.tsx new file mode 100644 index 0000000..b1dbb8c --- /dev/null +++ b/src/commands/deploy/DeployScreen.tsx @@ -0,0 +1,692 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { Spinner, Done, Failed, Warning } from "../../utils/ui/index.js"; +import { + runDeploy, + resolveSignerSetup, + checkDomainAvailability, + formatAvailability, + type AvailabilityResult, + type DeployEvent, + type DeployOutcome, + type DeployPhase, + type SignerMode, + type DeployApproval, + type SigningEvent, +} from "../../utils/deploy/index.js"; +import { buildSummaryView } from "./summary.js"; +import type { ResolvedSigner } from "../../utils/signer.js"; +import { DEFAULT_BUILD_DIR } from "../../config.js"; + +export interface DeployScreenInputs { + projectDir: string; + domain: string | null; + buildDir: string | null; + mode: SignerMode | null; + publishToPlayground: boolean | null; + userSigner: ResolvedSigner | null; + onDone: (outcome: DeployOutcome | null) => void; +} + +type Stage = + | { kind: "prompt-signer" } + | { kind: "prompt-buildDir" } + | { kind: "prompt-domain" } + | { kind: "validate-domain"; domain: string } + | { kind: "prompt-publish" } + | { kind: "confirm" } + | { kind: "running" } + | { kind: "done"; outcome: DeployOutcome } + | { kind: "error"; message: string }; + +interface Resolved { + mode: SignerMode; + buildDir: string; + domain: string; + publishToPlayground: boolean; +} + +export function DeployScreen({ + projectDir, + domain: initialDomain, + buildDir: initialBuildDir, + mode: initialMode, + publishToPlayground: initialPublish, + userSigner, + onDone, +}: DeployScreenInputs) { + const [mode, setMode] = useState(initialMode); + const [buildDir, setBuildDir] = useState(initialBuildDir); + const [domain, setDomain] = useState(initialDomain); + const [publishToPlayground, setPublishToPlayground] = useState(initialPublish); + const [domainError, setDomainError] = useState(null); + const [stage, setStage] = useState(() => + pickInitialStage(initialMode, initialBuildDir, initialDomain, initialPublish), + ); + + const advance = ( + nextMode: SignerMode | null = mode, + nextBuildDir: string | null = buildDir, + nextDomain: string | null = domain, + nextPublish: boolean | null = publishToPlayground, + ) => { + const s = pickNextStage(nextMode, nextBuildDir, nextDomain, nextPublish); + setStage(s); + }; + + // Used only once inputs are fully resolved; read by the `running` stage. + const resolved = useMemo(() => { + if (mode === null || buildDir === null || domain === null || publishToPlayground === null) + return null; + return { mode, buildDir, domain, publishToPlayground }; + }, [mode, buildDir, domain, publishToPlayground]); + + return ( + + {stage.kind === "prompt-signer" && ( + { + setMode(m); + advance(m); + }} + /> + )} + {stage.kind === "prompt-buildDir" && ( + { + setBuildDir(v); + advance(mode, v); + }} + /> + )} + {stage.kind === "prompt-domain" && ( + + /^[a-z0-9][a-z0-9-]*(\.dot)?$/i.test(v.trim()) + ? null + : "Use lowercase letters, digits, and dashes." + } + onSubmit={(v) => { + const trimmed = v.trim(); + setDomain(trimmed); + setDomainError(null); + setStage({ kind: "validate-domain", domain: trimmed }); + }} + /> + )} + {stage.kind === "validate-domain" && ( + { + setDomain(result.fullDomain); + advance(mode, buildDir, result.fullDomain); + }} + onUnavailable={(reason) => { + setDomainError(reason); + setStage({ kind: "prompt-domain" }); + }} + /> + )} + {stage.kind === "prompt-publish" && ( + { + setPublishToPlayground(yes); + advance(mode, buildDir, domain, yes); + }} + /> + )} + {stage.kind === "confirm" && resolved && ( + setStage({ kind: "running" })} + onCancel={() => { + onDone(null); + }} + /> + )} + {stage.kind === "running" && resolved && ( + { + setStage({ kind: "done", outcome }); + onDone(outcome); + }} + onError={(message) => { + setStage({ kind: "error", message }); + onDone(null); + }} + /> + )} + {stage.kind === "done" && } + {stage.kind === "error" && ( + + + + + Deploy failed + + + + {stage.message} + + + )} + + ); +} + +// ── Stage pickers ──────────────────────────────────────────────────────────── + +function pickInitialStage( + mode: SignerMode | null, + buildDir: string | null, + domain: string | null, + publish: boolean | null, +): Stage { + return pickNextStage(mode, buildDir, domain, publish); +} + +function pickNextStage( + mode: SignerMode | null, + buildDir: string | null, + domain: string | null, + publish: boolean | null, +): Stage { + if (mode === null) return { kind: "prompt-signer" }; + if (buildDir === null) return { kind: "prompt-buildDir" }; + if (domain === null) return { kind: "prompt-domain" }; + if (publish === null) return { kind: "prompt-publish" }; + return { kind: "confirm" }; +} + +// ── Prompt components ──────────────────────────────────────────────────────── + +function SignerPrompt({ onSelect }: { onSelect: (mode: SignerMode) => void }) { + const [index, setIndex] = useState(0); + const options: Array<{ mode: SignerMode; label: string; hint: string }> = [ + { mode: "dev", label: "Dev signer", hint: "Fast. 0 phone taps for upload." }, + { mode: "phone", label: "Your phone signer", hint: "Signed with your logged-in account." }, + ]; + + useInput((_input, key) => { + if (key.upArrow) setIndex((i) => (i - 1 + options.length) % options.length); + if (key.downArrow) setIndex((i) => (i + 1) % options.length); + if (key.return) onSelect(options[index].mode); + }); + + return ( + + Signer — use ↑/↓ then Enter + {options.map((opt, i) => ( + + {i === index ? "▸" : " "} + + {opt.label} + + — {opt.hint} + + ))} + + ); +} + +function TextPrompt({ + label, + initial, + prefill, + externalError, + validate, + onSubmit, +}: { + label: string; + initial: string; + prefill?: string; + externalError?: string | null; + validate?: (value: string) => string | null; + onSubmit: (value: string) => void; +}) { + const [value, setValue] = useState(prefill ?? initial); + const [error, setError] = useState(null); + + useInput((input, key) => { + if (key.return) { + const final = value.trim() || initial; + if (validate) { + const msg = validate(final); + if (msg) { + setError(msg); + return; + } + } + onSubmit(final); + return; + } + if (key.backspace || key.delete) { + setValue((v) => v.slice(0, -1)); + setError(null); + return; + } + if (key.ctrl || key.meta) return; + // Accept printable characters. + if (input && input.length > 0 && input >= " " && input !== "\t") { + setValue((v) => v + input); + setError(null); + } + }); + + const shownError = error ?? externalError ?? null; + return ( + + + {label} + {initial ? ` [${initial}]` : ""} + + + + {value} + + + {shownError && {shownError}} + + ); +} + +function ValidateDomainStage({ + domain, + ownerSs58Address, + onAvailable, + onUnavailable, +}: { + domain: string; + ownerSs58Address: string | undefined; + onAvailable: (result: AvailabilityResult & { status: "available" }) => void; + onUnavailable: (reason: string) => void; +}) { + const [status, setStatus] = useState<"checking" | "done" | "error">("checking"); + const [message, setMessage] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const result = await checkDomainAvailability(domain, { ownerSs58Address }); + if (cancelled) return; + if (result.status === "available") { + setStatus("done"); + setMessage(formatAvailability(result)); + // Short hold so the user can read any note (e.g. "PoP will + // be set up automatically") before the next prompt mounts. + setTimeout( + () => { + if (!cancelled) onAvailable(result); + }, + result.note ? 1200 : 300, + ); + } else { + const reason = formatAvailability(result); + setStatus("error"); + setMessage(reason); + setTimeout(() => { + if (!cancelled) onUnavailable(reason); + }, 600); + } + } catch (err) { + if (cancelled) return; + const msg = err instanceof Error ? err.message : String(err); + setStatus("error"); + setMessage(`Availability check failed: ${msg}`); + setTimeout(() => { + if (!cancelled) onUnavailable(msg); + }, 600); + } + })(); + return () => { + cancelled = true; + }; + }, [domain]); + + return ( + + + {status === "checking" ? : status === "done" ? : } + + {status === "checking" ? `Checking availability of ${domain}…` : message} + + + + ); +} + +function YesNoPrompt({ + label, + initial, + onSubmit, +}: { + label: string; + initial: boolean; + onSubmit: (yes: boolean) => void; +}) { + const [yes, setYes] = useState(initial); + + useInput((input, key) => { + if (key.leftArrow || key.rightArrow || input === "y" || input === "n") { + setYes((prev) => (input === "y" ? true : input === "n" ? false : !prev)); + } + if (key.return) onSubmit(yes); + }); + + return ( + + {label} (y/n, ←/→ to toggle) + + + {yes ? "▸ Yes" : " Yes"} + + + {!yes ? "▸ No" : " No"} + + + + ); +} + +// ── Confirm stage ──────────────────────────────────────────────────────────── + +function ConfirmStage({ + inputs, + userSigner, + onProceed, + onCancel, +}: { + inputs: Resolved; + userSigner: ResolvedSigner | null; + onProceed: () => void; + onCancel: () => void; +}) { + const setup = useMemo(() => { + try { + return resolveSignerSetup({ + mode: inputs.mode, + userSigner, + publishToPlayground: inputs.publishToPlayground, + }); + } catch (err) { + return { + approvals: [] as DeployApproval[], + error: err instanceof Error ? err.message : String(err), + }; + } + }, [inputs, userSigner]); + + const view = buildSummaryView({ + mode: inputs.mode, + domain: inputs.domain.replace(/\.dot$/, "") + ".dot", + buildDir: inputs.buildDir, + publishToPlayground: inputs.publishToPlayground, + approvals: "approvals" in setup ? setup.approvals : [], + }); + + useInput((_input, key) => { + if (key.return) onProceed(); + if (key.escape) onCancel(); + }); + + return ( + + {view.headline} + + {view.rows.map((row) => ( + + {row.label.padEnd(10)} + {row.value} + + ))} + + + {view.totalApprovals === 0 ? ( + No phone approvals required. + ) : ( + <> + Phone approvals required: {view.totalApprovals} + {view.approvalLines.map((line) => ( + + {" "} + {line} + + ))} + + )} + + + Press Enter to deploy, Esc to cancel. + + {"error" in setup && setup.error && ( + + + {setup.error} + + )} + + ); +} + +// ── Running stage ──────────────────────────────────────────────────────────── + +interface PhaseState { + status: "pending" | "running" | "complete" | "error"; + detail?: string; +} + +const PHASE_ORDER: DeployPhase[] = ["build", "storage-and-dotns", "playground", "done"]; +const PHASE_TITLE: Record = { + build: "Build", + "storage-and-dotns": "Upload + DotNS", + playground: "Publish to Playground", + done: "Done", +}; + +function RunningStage({ + projectDir, + inputs, + userSigner, + onFinish, + onError, +}: { + projectDir: string; + inputs: Resolved; + userSigner: ResolvedSigner | null; + onFinish: (outcome: DeployOutcome) => void; + onError: (message: string) => void; +}) { + const initialPhases: Record = { + build: { status: "pending" }, + "storage-and-dotns": { status: "pending" }, + playground: { + status: inputs.publishToPlayground ? "pending" : "complete", + detail: inputs.publishToPlayground ? undefined : "skipped", + }, + done: { status: "pending" }, + }; + const [phases, setPhases] = useState(initialPhases); + const [signingPrompt, setSigningPrompt] = useState(null); + const [latestInfo, setLatestInfo] = useState(null); + + // ── Throttled info updates ────────────────────────────────────────── + // Verbose builds (vite / next) and bulletin-deploy's per-chunk logs + // can fire hundreds of "build-log" / "info" events per second. Calling + // setLatestInfo on every one floods React's update queue and — on long + // deploys — builds up enough backpressure to spike memory into the + // gigabytes. Users only ever see the most recent line anyway, so we + // coalesce updates to ~10 per second via a ref-based sink. + const pendingInfoRef = useRef(null); + const infoTimerRef = useRef(null); + const INFO_THROTTLE_MS = 100; + const INFO_MAX_LEN = 160; + const queueInfo = (line: string) => { + const truncated = line.length > INFO_MAX_LEN ? `${line.slice(0, INFO_MAX_LEN - 1)}…` : line; + pendingInfoRef.current = truncated; + if (infoTimerRef.current === null) { + infoTimerRef.current = setTimeout(() => { + if (pendingInfoRef.current !== null) { + setLatestInfo(pendingInfoRef.current); + pendingInfoRef.current = null; + } + infoTimerRef.current = null; + }, INFO_THROTTLE_MS); + } + }; + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const outcome = await runDeploy({ + projectDir, + buildDir: inputs.buildDir, + domain: inputs.domain, + mode: inputs.mode, + publishToPlayground: inputs.publishToPlayground, + userSigner, + onEvent: (event) => handleEvent(event), + }); + if (!cancelled) onFinish(outcome); + } catch (err) { + if (!cancelled) { + const message = err instanceof Error ? err.message : String(err); + onError(message); + } + } + })(); + + function handleEvent(event: DeployEvent) { + if (event.kind === "phase-start") { + setPhases((p) => ({ ...p, [event.phase]: { status: "running" } })); + } else if (event.kind === "phase-complete") { + setPhases((p) => ({ ...p, [event.phase]: { status: "complete" } })); + } else if (event.kind === "build-log") { + queueInfo(event.line); + } else if (event.kind === "build-detected") { + queueInfo(`> ${event.config.description}`); + } else if (event.kind === "storage-event") { + if (event.event.kind === "chunk-progress") { + queueInfo(`Uploading chunk ${event.event.current}/${event.event.total}`); + } else if (event.event.kind === "info") { + queueInfo(event.event.message); + } + } else if (event.kind === "signing") { + if (event.event.kind === "sign-request") { + setSigningPrompt(event.event); + } else if (event.event.kind === "sign-complete") { + setSigningPrompt(null); + } else if (event.event.kind === "sign-error") { + setSigningPrompt(null); + queueInfo(`Signing rejected: ${event.event.message}`); + } + } else if (event.kind === "error") { + setPhases((p) => ({ + ...p, + [event.phase]: { status: "error", detail: event.message }, + })); + } + } + + return () => { + cancelled = true; + if (infoTimerRef.current !== null) { + clearTimeout(infoTimerRef.current); + infoTimerRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {PHASE_ORDER.filter((p) => p !== "done").map((phase) => { + const state = phases[phase]; + return ( + + {state.status === "running" && } + {state.status === "complete" && } + {state.status === "error" && } + {state.status === "pending" && } + {PHASE_TITLE[phase]} + {state.detail && — {state.detail}} + + ); + })} + {latestInfo && ( + + {truncate(latestInfo, 120)} + + )} + {signingPrompt && signingPrompt.kind === "sign-request" && ( + + + 📱 Check your phone + + + Approve step {signingPrompt.step} of {signingPrompt.total}:{" "} + {signingPrompt.label} + + + )} + + ); +} + +// ── Final result ───────────────────────────────────────────────────────────── + +function FinalResult({ outcome }: { outcome: DeployOutcome }) { + return ( + + + + + Deploy complete + + + + + + + {outcome.ipfsCid && } + {outcome.metadataCid && ( + + )} + + + ); +} + +function LabelValue({ label, value }: { label: string; value: string }) { + return ( + + {label.padEnd(12)} + {value} + + ); +} + +function truncate(s: string, n: number): string { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts new file mode 100644 index 0000000..1877ea5 --- /dev/null +++ b/src/commands/deploy/index.ts @@ -0,0 +1,335 @@ +import React from "react"; +import { resolve } from "node:path"; +import { Command, Option } from "commander"; +import { render } from "ink"; +import { DeployScreen } from "./DeployScreen.js"; +import { renderSummaryText } from "./summary.js"; +import { resolveSigner, SignerNotAvailableError, type ResolvedSigner } from "../../utils/signer.js"; +import { getConnection, destroyConnection } from "../../utils/connection.js"; +import { checkMapping } from "../../utils/account/mapping.js"; +import { checkAllowance, LOW_TX_THRESHOLD } from "../../utils/account/allowance.js"; +import { + onProcessShutdown, + scheduleHardExit, + startMemoryWatchdog, +} from "../../utils/process-guard.js"; +import { + runDeploy, + resolveSignerSetup, + checkDomainAvailability, + formatAvailability, + type SignerMode, + type DeployOutcome, + type DeployEvent, +} from "../../utils/deploy/index.js"; +import { buildSummaryView } from "./summary.js"; +import { DEFAULT_BUILD_DIR, type Env } from "../../config.js"; + +interface DeployOpts { + suri?: string; + signer?: SignerMode; + domain?: string; + buildDir?: string; + playground?: boolean; + env?: Env; + /** Project root. Hidden — defaults to cwd. */ + dir?: string; +} + +export const deployCommand = new Command("deploy") + .description( + "Build the project, upload to Bulletin, register a .dot domain, and optionally publish to Playground", + ) + .addOption(new Option("--signer ", "Signer mode").choices(["dev", "phone"])) + .option("--domain ", "DotNS domain (e.g. my-app or my-app.dot)") + .option( + "--buildDir ", + `Directory containing build artifacts (default: ${DEFAULT_BUILD_DIR})`, + ) + .option("--playground", "Publish to the playground registry") + .option("--suri ", "Secret URI for the user signer (e.g. //Alice for dev)") + .addOption( + new Option("--env ", "Target environment") + .choices(["testnet", "mainnet"]) + .default("testnet"), + ) + .option("--dir ", "Project directory", process.cwd()) + .action(async (opts: DeployOpts) => { + const projectDir = resolve(opts.dir ?? process.cwd()); + const env: Env = (opts.env as Env) ?? "testnet"; + + // Start the memory watchdog FIRST so it's in place even if a preflight + // path starts leaking. It'll abort the process with a clear error if + // RSS crosses 2 GB, protecting the machine from swap-death. + const stopWatchdog = startMemoryWatchdog(); + + let userSigner: ResolvedSigner | null = null; + + // Guarantee cleanup runs even if the main flow never returns — e.g., + // a leaked WebSocket keeps the event loop alive. The signal handlers + // in process-guard will invoke this on SIGINT/TERM/HUP too. + const cleanupOnce = (() => { + let ran = false; + return () => { + if (ran) return; + ran = true; + try { + userSigner?.destroy(); + } catch {} + try { + destroyConnection(); + } catch {} + stopWatchdog(); + }; + })(); + onProcessShutdown(cleanupOnce); + + try { + userSigner = await preflight({ env, suri: opts.suri, mode: opts.signer }); + } catch (err) { + process.stderr.write(`\n✖ ${formatError(err)}\n`); + cleanupOnce(); + scheduleHardExit(1); + return; + } + + try { + const nonInteractive = isFullySpecified(opts); + if (nonInteractive) { + await runHeadless({ projectDir, env, userSigner, opts }); + } else { + await runInteractive({ projectDir, env, userSigner, opts }); + } + } catch (err) { + process.stderr.write(`\n✖ ${formatError(err)}\n`); + process.exitCode = 1; + } finally { + cleanupOnce(); + } + + // Hard-exit safety net: after cleanup, if a stray WebSocket or + // subscription is still keeping the event loop alive, we exit anyway + // rather than hanging with a giant heap. + const exitCode = typeof process.exitCode === "number" ? process.exitCode : 0; + scheduleHardExit(exitCode); + }); + +// ── Preflight ──────────────────────────────────────────────────────────────── + +/** + * Make sure we can actually deploy before spending the user's time on prompts: + * - user has a signer (either --suri dev or a QR session), + * - their account is mapped in Revive (needed for any EVM call), + * - their Bulletin storage allowance isn't about to be exhausted. + * + * Dev mode without --playground doesn't need a signer at all — we skip the + * check in that case so a brand-new user can do `dot deploy --signer dev` out + * of the box. + */ +async function preflight(opts: { + env: Env; + suri?: string; + mode?: SignerMode; +}): Promise { + // If the user explicitly asked for dev mode with no --playground and no + // --suri, we don't need a signer at all. + const mayNeedSigner = opts.mode !== "dev" || opts.suri !== undefined; + if (!mayNeedSigner) return null; + + let signer: ResolvedSigner; + try { + signer = await resolveSigner({ suri: opts.suri }); + } catch (err) { + if (err instanceof SignerNotAvailableError) { + // Dev mode: we can still run without a signer as long as --playground + // wasn't asked for. The caller validates that separately. + if (opts.mode === "dev") return null; + throw err; + } + throw err; + } + + // Dev accounts don't need a mapping/allowance check — Alice & friends are + // already set up on the test chains. Only gate on real session accounts. + if (signer.source !== "session") return signer; + + const client = await getConnection(); + + // Mapping is always required — the playground registry publish + any + // DotNS signing go through EVM contract calls, which need the user's + // SS58 to be mapped to an H160 via `Revive::map_account`. So we always + // check mapping, in both dev and phone modes. + const mapped = await checkMapping(client, signer.address); + if (!mapped) { + signer.destroy(); + throw new Error( + 'Account is not mapped in Revive. Run "dot init" first to finish account setup.', + ); + } + + // Bulletin storage allowance is ONLY consumed when the user's signer is + // used to submit `TransactionStorage.store` — that is, in phone mode. + // In dev mode, bulletin-deploy uploads chunks via its own pool mnemonic + // and the user's allowance isn't touched. Gating dev-mode deploys on + // the user's allowance is a false block. + if (opts.mode !== "dev") { + const allowance = await checkAllowance(client, signer.address); + if (!allowance.authorized || allowance.remainingTxs < LOW_TX_THRESHOLD) { + signer.destroy(); + throw new Error( + 'Bulletin storage allowance is exhausted. Run "dot init" to refresh it.', + ); + } + } + + return signer; +} + +// ── Dispatch ───────────────────────────────────────────────────────────────── + +function isFullySpecified(opts: DeployOpts): boolean { + return ( + typeof opts.signer === "string" && + typeof opts.domain === "string" && + typeof opts.buildDir === "string" && + typeof opts.playground === "boolean" + ); +} + +async function runHeadless(ctx: { + projectDir: string; + env: Env; + userSigner: ResolvedSigner | null; + opts: DeployOpts; +}) { + const mode = ctx.opts.signer as SignerMode; + const publishToPlayground = Boolean(ctx.opts.playground); + const domain = ctx.opts.domain as string; + const buildDir = ctx.opts.buildDir as string; + + // Check availability BEFORE we build + upload, so CI fails fast on a + // Reserved / already-taken name without wasting a chunk upload. + process.stdout.write(`\nChecking availability of ${domain.replace(/\.dot$/, "") + ".dot"}…\n`); + const availability = await checkDomainAvailability(domain, { + env: ctx.env, + ownerSs58Address: ctx.userSigner?.address, + }); + if (availability.status !== "available") { + throw new Error(formatAvailability(availability)); + } + process.stdout.write(`✔ ${formatAvailability(availability)}\n`); + + const setup = resolveSignerSetup({ + mode, + userSigner: ctx.userSigner, + publishToPlayground, + }); + const view = buildSummaryView({ + mode, + domain: availability.fullDomain, + buildDir, + publishToPlayground, + approvals: setup.approvals, + }); + process.stdout.write("\n" + renderSummaryText(view) + "\n"); + + const outcome = await runDeploy({ + projectDir: ctx.projectDir, + buildDir, + domain, + mode, + publishToPlayground, + userSigner: ctx.userSigner, + env: ctx.env, + onEvent: (event) => logHeadlessEvent(event), + }); + + printFinalResult(outcome); +} + +function runInteractive(ctx: { + projectDir: string; + env: Env; + userSigner: ResolvedSigner | null; + opts: DeployOpts; +}): Promise { + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + const app = render( + React.createElement(DeployScreen, { + projectDir: ctx.projectDir, + domain: ctx.opts.domain ?? null, + buildDir: ctx.opts.buildDir ?? null, + mode: (ctx.opts.signer as SignerMode | undefined) ?? null, + publishToPlayground: + ctx.opts.playground !== undefined ? Boolean(ctx.opts.playground) : null, + userSigner: ctx.userSigner, + onDone: (outcome: DeployOutcome | null) => { + if (settled) return; + settled = true; + app.unmount(); + if (outcome === null) { + process.exitCode = 1; + rejectPromise(new Error("Deploy was cancelled or failed.")); + } else { + resolvePromise(); + } + }, + }), + ); + + // `waitUntilExit()` resolves when the Ink app unmounts and rejects on + // render errors. Either resolution could happen WITHOUT `onDone` + // firing — e.g. Ink's error boundary unmounting on a render throw — + // in which case the outer promise would hang forever. Force-settle + // if we see the app go down unexpectedly. + app.waitUntilExit() + .then(() => { + if (!settled) { + settled = true; + process.exitCode = 1; + rejectPromise(new Error("TUI closed unexpectedly before the deploy finished.")); + } + }) + .catch((err) => { + if (!settled) { + settled = true; + rejectPromise(err); + } + }); + }); +} + +// ── Output helpers ─────────────────────────────────────────────────────────── + +function logHeadlessEvent(event: DeployEvent) { + if (event.kind === "phase-start") { + process.stdout.write(`▸ ${event.phase}…\n`); + } else if (event.kind === "phase-complete") { + process.stdout.write(`✔ ${event.phase}\n`); + } else if (event.kind === "build-log") { + process.stdout.write(` ${event.line}\n`); + } else if (event.kind === "storage-event" && event.event.kind === "chunk-progress") { + process.stdout.write(` chunk ${event.event.current}/${event.event.total}\n`); + } else if (event.kind === "signing" && event.event.kind === "sign-request") { + process.stdout.write( + ` 📱 Approve on your phone: ${event.event.label} (${event.event.step}/${event.event.total})\n`, + ); + } else if (event.kind === "error") { + process.stderr.write(` ✖ ${event.phase}: ${event.message}\n`); + } +} + +function printFinalResult(outcome: DeployOutcome) { + process.stdout.write(`\n✔ Deploy complete\n\n`); + process.stdout.write(` URL ${outcome.appUrl}\n`); + process.stdout.write(` Domain ${outcome.fullDomain}\n`); + process.stdout.write(` App CID ${outcome.appCid}\n`); + if (outcome.ipfsCid) process.stdout.write(` IPFS CID ${outcome.ipfsCid}\n`); + if (outcome.metadataCid) process.stdout.write(` Metadata CID ${outcome.metadataCid}\n`); + process.stdout.write("\n"); +} + +function formatError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/commands/deploy/summary.test.ts b/src/commands/deploy/summary.test.ts new file mode 100644 index 0000000..6c9642c --- /dev/null +++ b/src/commands/deploy/summary.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { buildSummaryView, renderSummaryText } from "./summary.js"; + +describe("buildSummaryView", () => { + it("dev mode without playground has zero approvals", () => { + const view = buildSummaryView({ + mode: "dev", + domain: "my-app.dot", + buildDir: "dist", + publishToPlayground: false, + approvals: [], + }); + expect(view.totalApprovals).toBe(0); + expect(view.approvalLines).toEqual([]); + expect(view.rows.find((r) => r.label === "Publish")?.value).toBe("DotNS only"); + }); + + it("dev mode with playground has exactly one approval", () => { + const view = buildSummaryView({ + mode: "dev", + domain: "my-app.dot", + buildDir: "dist", + publishToPlayground: true, + approvals: [{ phase: "playground", label: "Publish to Playground registry" }], + }); + expect(view.totalApprovals).toBe(1); + expect(view.approvalLines[0]).toMatch(/Publish to Playground registry/); + }); + + it("phone mode with playground has four approvals numbered 1-4", () => { + const view = buildSummaryView({ + mode: "phone", + domain: "my-app.dot", + buildDir: "dist", + publishToPlayground: true, + approvals: [ + { phase: "dotns", label: "Reserve domain (DotNS commitment)" }, + { phase: "dotns", label: "Finalize domain (DotNS register)" }, + { phase: "dotns", label: "Link content (DotNS setContenthash)" }, + { phase: "playground", label: "Publish to Playground registry" }, + ], + }); + expect(view.totalApprovals).toBe(4); + expect(view.approvalLines).toEqual([ + "1. Reserve domain (DotNS commitment)", + "2. Finalize domain (DotNS register)", + "3. Link content (DotNS setContenthash)", + "4. Publish to Playground registry", + ]); + }); +}); + +describe("renderSummaryText", () => { + it("renders 'No phone approvals required.' when empty", () => { + const text = renderSummaryText( + buildSummaryView({ + mode: "dev", + domain: "my-app.dot", + buildDir: "dist", + publishToPlayground: false, + approvals: [], + }), + ); + expect(text).toContain("No phone approvals required."); + }); + + it("lists numbered approvals when non-empty", () => { + const text = renderSummaryText( + buildSummaryView({ + mode: "phone", + domain: "x.dot", + buildDir: "dist", + publishToPlayground: false, + approvals: [ + { phase: "dotns", label: "Reserve domain" }, + { phase: "dotns", label: "Finalize domain" }, + { phase: "dotns", label: "Link content" }, + ], + }), + ); + expect(text).toContain("Phone approvals required: 3"); + expect(text).toContain("1. Reserve domain"); + expect(text).toContain("3. Link content"); + }); +}); diff --git a/src/commands/deploy/summary.ts b/src/commands/deploy/summary.ts new file mode 100644 index 0000000..2eb7b39 --- /dev/null +++ b/src/commands/deploy/summary.ts @@ -0,0 +1,56 @@ +/** + * Pure helpers that compute the human-readable summary the TUI shows after + * the user answers every prompt. Kept separate from the Ink component so + * unit tests don't need React in the module graph. + */ + +import type { SignerMode, DeployApproval } from "../../utils/deploy/index.js"; + +export interface SummaryInputs { + mode: SignerMode; + domain: string; + buildDir: string; + publishToPlayground: boolean; + approvals: DeployApproval[]; +} + +export interface SummaryView { + headline: string; + rows: Array<{ label: string; value: string }>; + approvalLines: string[]; + totalApprovals: number; +} + +const MODE_LABEL: Record = { + dev: "Dev signer (no phone taps for upload)", + phone: "Your phone signer", +}; + +export function buildSummaryView(input: SummaryInputs): SummaryView { + return { + headline: `Deploying ${input.domain}`, + rows: [ + { label: "Signer", value: MODE_LABEL[input.mode] }, + { label: "Build dir", value: input.buildDir }, + { + label: "Publish", + value: input.publishToPlayground ? "Playground + your apps" : "DotNS only", + }, + ], + approvalLines: input.approvals.map((a, i) => `${i + 1}. ${a.label}`), + totalApprovals: input.approvals.length, + }; +} + +/** Plain-text renderer — used for the non-interactive (`--signer … --domain … --buildDir … --playground …`) mode. */ +export function renderSummaryText(view: SummaryView): string { + const rows = view.rows.map((r) => ` ${r.label.padEnd(10)} ${r.value}`).join("\n"); + const approvals = + view.totalApprovals === 0 + ? " No phone approvals required." + : [ + ` Phone approvals required: ${view.totalApprovals}`, + ...view.approvalLines.map((a) => ` ${a}`), + ].join("\n"); + return `${view.headline}\n\n${rows}\n\n${approvals}\n`; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e5a4a3b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,57 @@ +/** + * Single source of truth for environment-dependent values: RPC endpoints, + * contract addresses, dapp identifiers, and feature defaults. + * + * When mainnet launches we will add a second profile here and thread an + * `env` value through the commands. Until then only `testnet` is supported + * and every consumer should import from this module rather than inlining + * URLs or addresses elsewhere. + */ + +export type Env = "testnet" | "mainnet"; + +export const DEFAULT_ENV: Env = "testnet"; + +export interface ChainConfig { + /** WebSocket endpoint for Paseo Asset Hub (Revive contracts live here). */ + assetHubRpc: string; + /** WebSocket endpoint for Paseo Bulletin (immutable IPFS storage). */ + bulletinRpc: string; + /** WebSocket endpoints for the People chain (SSO / session discovery). */ + peopleEndpoints: string[]; + /** Playground registry contract on Asset Hub. Backing store for myApps. */ + playgroundRegistryAddress: `0x${string}`; + /** Viewer URL shown to users after a successful deploy. */ + appViewerOrigin: string; +} + +const TESTNET: ChainConfig = { + assetHubRpc: "wss://asset-hub-paseo-rpc.n.dwellir.com", + bulletinRpc: "wss://paseo-bulletin-rpc.polkadot.io", + peopleEndpoints: ["wss://paseo-people-next-rpc.polkadot.io"], + playgroundRegistryAddress: "0x279585Cb8E8971e34520A3ebbda3E0C4D77C3d97", + appViewerOrigin: "https://dot.li", +}; + +export function getChainConfig(env: Env = DEFAULT_ENV): ChainConfig { + if (env === "mainnet") { + throw new Error( + "`--env mainnet` is not yet supported. Use `--env testnet` (default) while mainnet launch is pending.", + ); + } + return TESTNET; +} + +/** Identifier the terminal adapter reports during SSO. Kept stable so mobile pairings persist across releases. */ +export const DAPP_ID = "dot-cli"; + +/** + * Runtime metadata the terminal adapter fetches to render transactions on the + * mobile wallet. Hosted on a gist today; intentionally a URL rather than a + * pinned file so it can be rotated without a CLI release. + */ +export const TERMINAL_METADATA_URL = + "https://gist.githubusercontent.com/ReinhardHatko/1967dd3f4afe78683cc0ba14d6ec8744/raw/c1625eb7ed7671b7e09a3fa2a25998dde33c70b8/metadata.json"; + +/** Default build output directory — matches Vite and the interactive prompt default. */ +export const DEFAULT_BUILD_DIR = "dist"; diff --git a/src/index.ts b/src/index.ts index 9bc4811..3feb306 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,8 +5,39 @@ import pkg from "../package.json" with { type: "json" }; import { initCommand } from "./commands/init/index.js"; import { modCommand } from "./commands/mod.js"; import { buildCommand } from "./commands/build.js"; -import { deployCommand } from "./commands/deploy.js"; +import { deployCommand } from "./commands/deploy/index.js"; import { updateCommand } from "./commands/update.js"; +import { installSignalHandlers } from "./utils/process-guard.js"; + +// ── Bun compiled-binary stdin workaround ───────────────────────────────────── +// When `dot` is shipped via `bun build --compile`, Ink's internal +// `stdin.addListener('readable', …)` does NOT receive events until something +// else has already touched `process.stdin.on('readable', …)` first. Symptom: +// every useInput-driven TUI locks up — no arrow keys, no Enter, no Ctrl+C. +// +// Attaching a no-op `readable` listener here warms the stream up so Ink's +// own listener fires normally. Harmless under `bun run` and Node. +// Remove once Bun's compiled-binary TTY stdin behaves like Node's out of the +// box. +if (process.stdin.isTTY) { + process.stdin.on("readable", () => {}); + // Don't let the listener itself hold the event loop open on exit. + process.stdin.unref(); +} + +// Opt out of bulletin-deploy's Sentry telemetry unless the user has +// explicitly opted in. Sentry buffers breadcrumbs + spans in-memory while +// it tries to reach its endpoint — on a flaky or long-running deploy this +// has been observed to balloon the process. Users can re-enable by setting +// `BULLETIN_DEPLOY_TELEMETRY=1` before invoking `dot deploy`. +if (process.env.BULLETIN_DEPLOY_TELEMETRY === undefined) { + process.env.BULLETIN_DEPLOY_TELEMETRY = "0"; +} + +// Install SIGINT/SIGTERM/SIGHUP + unhandledRejection handlers so a force-quit +// or a stray async error can't turn `dot` into a zombie that grows memory +// indefinitely. +installSignalHandlers(); const program = new Command() .name("dot") diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 87e4743..6030e4f 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -19,19 +19,16 @@ import { } from "@polkadot-apps/terminal"; import { createTxSigner } from "./session-signer-patch.js"; import type { PolkadotSigner } from "polkadot-api"; - -const DEFAULT_METADATA_URL = - "https://gist.githubusercontent.com/ReinhardHatko/1967dd3f4afe78683cc0ba14d6ec8744/raw/c1625eb7ed7671b7e09a3fa2a25998dde33c70b8/metadata.json"; -const DEFAULT_PEOPLE_ENDPOINTS = ["wss://paseo-people-next-rpc.polkadot.io"]; +import { DAPP_ID, TERMINAL_METADATA_URL, getChainConfig } from "../config.js"; /** How long we wait for the statement store to publish the pairing QR. */ const QR_TIMEOUT_MS = 60_000; function createAdapter(): TerminalAdapter { return createTerminalAdapter({ - appId: "dot-cli", - metadataUrl: DEFAULT_METADATA_URL, - endpoints: DEFAULT_PEOPLE_ENDPOINTS, + appId: DAPP_ID, + metadataUrl: TERMINAL_METADATA_URL, + endpoints: getChainConfig().peopleEndpoints, }); } diff --git a/src/utils/build/detect.test.ts b/src/utils/build/detect.test.ts new file mode 100644 index 0000000..b105c56 --- /dev/null +++ b/src/utils/build/detect.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { + detectBuildConfig, + detectPackageManager, + BuildDetectError, + type DetectInput, +} from "./detect.js"; + +function input(overrides: Partial = {}): DetectInput { + return { + packageJson: null, + lockfiles: new Set(), + configFiles: new Set(), + ...overrides, + }; +} + +describe("detectPackageManager", () => { + it("defaults to npm when no lockfile is present", () => { + expect(detectPackageManager(new Set())).toBe("npm"); + }); + + it("picks pnpm over yarn when both lockfiles are present", () => { + // Mixed lockfiles happen in practice during migrations; we pick the one + // most likely to be currently maintained. + expect(detectPackageManager(new Set(["pnpm-lock.yaml", "yarn.lock"]))).toBe("pnpm"); + }); + + it("picks yarn when only yarn.lock is present", () => { + expect(detectPackageManager(new Set(["yarn.lock"]))).toBe("yarn"); + }); + + it("picks bun when only bun.lockb is present", () => { + expect(detectPackageManager(new Set(["bun.lockb"]))).toBe("bun"); + }); +}); + +describe("detectBuildConfig", () => { + it("prefers an explicit build script via the detected PM", () => { + const cfg = detectBuildConfig( + input({ + packageJson: { scripts: { build: "vite build" } }, + lockfiles: new Set(["pnpm-lock.yaml"]), + }), + ); + expect(cfg.cmd).toBe("pnpm"); + expect(cfg.args).toEqual(["run", "build"]); + expect(cfg.description).toBe("pnpm run build"); + expect(cfg.defaultOutputDir).toBe("dist"); + }); + + it("passes npm even without any lockfile", () => { + const cfg = detectBuildConfig( + input({ + packageJson: { scripts: { build: "tsc" } }, + }), + ); + expect(cfg.cmd).toBe("npm"); + expect(cfg.defaultOutputDir).toBe("dist"); + }); + + it("infers .next output dir when the build script invokes next", () => { + const cfg = detectBuildConfig( + input({ + packageJson: { scripts: { build: "next build" } }, + lockfiles: new Set(["yarn.lock"]), + }), + ); + expect(cfg.defaultOutputDir).toBe(".next"); + expect(cfg.cmd).toBe("yarn"); + }); + + it("falls back to vite exec when only vite.config.ts is present", () => { + const cfg = detectBuildConfig( + input({ + packageJson: { dependencies: { vite: "^5.0.0" } }, + lockfiles: new Set(["bun.lockb"]), + configFiles: new Set(["vite.config.ts"]), + }), + ); + expect(cfg.cmd).toBe("bunx"); + expect(cfg.args).toEqual(["vite", "build"]); + expect(cfg.description).toBe("bun exec vite build"); + expect(cfg.defaultOutputDir).toBe("dist"); + }); + + it("falls back to next exec when only next.config.js is present", () => { + const cfg = detectBuildConfig( + input({ + packageJson: { devDependencies: { next: "^14.0.0" } }, + lockfiles: new Set(["pnpm-lock.yaml"]), + configFiles: new Set(["next.config.js"]), + }), + ); + expect(cfg.cmd).toBe("pnpm"); + expect(cfg.args).toEqual(["exec", "next", "build"]); + expect(cfg.defaultOutputDir).toBe(".next"); + }); + + it("falls back to tsc when typescript + tsconfig.json are present", () => { + const cfg = detectBuildConfig( + input({ + packageJson: { devDependencies: { typescript: "^5.0.0" } }, + configFiles: new Set(["tsconfig.json"]), + }), + ); + expect(cfg.cmd).toBe("npx"); + expect(cfg.args).toEqual(["tsc", "-p", "tsconfig.json"]); + }); + + it("throws BuildDetectError when no strategy matches", () => { + expect(() => detectBuildConfig(input({ packageJson: { scripts: {} } }))).toThrow( + BuildDetectError, + ); + }); + + it("throws when typescript is installed but tsconfig.json is missing", () => { + // tsc without a tsconfig is almost certainly not what the user wants — + // prefer the clear error over guessing. + expect(() => + detectBuildConfig( + input({ + packageJson: { devDependencies: { typescript: "^5.0.0" } }, + }), + ), + ).toThrow(BuildDetectError); + }); +}); diff --git a/src/utils/build/detect.ts b/src/utils/build/detect.ts new file mode 100644 index 0000000..273e4fd --- /dev/null +++ b/src/utils/build/detect.ts @@ -0,0 +1,158 @@ +/** + * Pure build-config detection — given a project tree snapshot, decide which + * command to run and where the output will land. No I/O here so unit tests + * stay trivial; the caller is responsible for reading package.json and + * listing lockfiles. + */ + +import { DEFAULT_BUILD_DIR } from "../../config.js"; + +export type PackageManager = "pnpm" | "yarn" | "bun" | "npm"; + +/** Files we inspect on disk to infer the package manager. */ +export const PM_LOCKFILES: Record = { + pnpm: "pnpm-lock.yaml", + yarn: "yarn.lock", + bun: "bun.lockb", + npm: "package-lock.json", +}; + +export interface BuildConfig { + /** Binary + args to spawn. */ + cmd: string; + args: string[]; + /** Human-readable description of which route we took ("pnpm run build", "npx vite build", …). */ + description: string; + /** Best guess at where the built artifacts will land, relative to the project root. */ + defaultOutputDir: string; +} + +export interface DetectInput { + /** Parsed package.json contents (object after JSON.parse), or null if missing. */ + packageJson: { + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + } | null; + /** Set of lockfile basenames that exist in the project root. */ + lockfiles: Set; + /** Set of additional config-file basenames (e.g. vite.config.ts). */ + configFiles: Set; +} + +export class BuildDetectError extends Error { + constructor(message: string) { + super(message); + this.name = "BuildDetectError"; + } +} + +/** Pick a package manager from the lockfiles present. Defaults to npm. */ +export function detectPackageManager(lockfiles: Set): PackageManager { + if (lockfiles.has(PM_LOCKFILES.pnpm)) return "pnpm"; + if (lockfiles.has(PM_LOCKFILES.yarn)) return "yarn"; + if (lockfiles.has(PM_LOCKFILES.bun)) return "bun"; + return "npm"; +} + +/** Frameworks we can invoke directly (via the PM's exec runner) if no `build` script is defined. */ +const FRAMEWORK_HINTS: Array<{ + name: string; + matches: (input: DetectInput) => boolean; + /** Command forwarded to the PM's `exec` / `dlx` runner. */ + execCommand: string[]; + defaultOutputDir: string; +}> = [ + { + name: "vite", + matches: (i) => + i.configFiles.has("vite.config.ts") || + i.configFiles.has("vite.config.js") || + i.configFiles.has("vite.config.mjs") || + hasDep(i.packageJson, "vite"), + execCommand: ["vite", "build"], + defaultOutputDir: "dist", + }, + { + name: "next", + matches: (i) => + i.configFiles.has("next.config.js") || + i.configFiles.has("next.config.mjs") || + i.configFiles.has("next.config.ts") || + hasDep(i.packageJson, "next"), + execCommand: ["next", "build"], + defaultOutputDir: ".next", + }, + { + name: "tsc", + matches: (i) => i.configFiles.has("tsconfig.json") && hasDep(i.packageJson, "typescript"), + execCommand: ["tsc", "-p", "tsconfig.json"], + defaultOutputDir: DEFAULT_BUILD_DIR, + }, +]; + +function hasDep(pkg: DetectInput["packageJson"], name: string): boolean { + if (!pkg) return false; + return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]); +} + +const PM_RUN: Record = { + pnpm: ["pnpm", "run"], + yarn: ["yarn", "run"], + bun: ["bun", "run"], + npm: ["npm", "run"], +}; + +const PM_EXEC: Record = { + pnpm: ["pnpm", "exec"], + yarn: ["yarn"], + bun: ["bunx"], + npm: ["npx"], +}; + +/** + * Pick a build command given the detected project state. + * + * Preference order: + * 1. An explicit `build` npm script, invoked through the detected PM. + * 2. A known framework (vite / next / tsc), invoked through the PM's exec runner. + * 3. Throw — we don't know how to build. + */ +export function detectBuildConfig(input: DetectInput): BuildConfig { + const pm = detectPackageManager(input.lockfiles); + const buildScript = input.packageJson?.scripts?.build; + + if (buildScript) { + const [cmd, ...args] = PM_RUN[pm]; + return { + cmd, + args: [...args, "build"], + description: `${pm} run build`, + defaultOutputDir: inferOutputDirFromScript(buildScript) ?? DEFAULT_BUILD_DIR, + }; + } + + for (const hint of FRAMEWORK_HINTS) { + if (hint.matches(input)) { + const [cmd, ...args] = PM_EXEC[pm]; + return { + cmd, + args: [...args, ...hint.execCommand], + description: `${pm} exec ${hint.execCommand.join(" ")}`, + defaultOutputDir: hint.defaultOutputDir, + }; + } + } + + throw new BuildDetectError( + 'No build strategy detected. Add a "build" script to package.json, or install vite/next/typescript.', + ); +} + +/** Cheap heuristic: if the build script mentions a known tool, guess its default output dir. */ +function inferOutputDirFromScript(script: string): string | null { + if (/\bnext\b/.test(script)) return ".next"; + if (/\bvite\b/.test(script)) return "dist"; + if (/\btsc\b/.test(script)) return DEFAULT_BUILD_DIR; + return null; +} diff --git a/src/utils/build/index.ts b/src/utils/build/index.ts new file mode 100644 index 0000000..d819ce3 --- /dev/null +++ b/src/utils/build/index.ts @@ -0,0 +1,17 @@ +/** + * Public surface for build detection + execution. + * + * Kept free of React/Ink imports so this module can be consumed from a + * WebContainer (RevX) as well as the Node CLI. + */ + +export { + detectBuildConfig, + detectPackageManager, + BuildDetectError, + PM_LOCKFILES, + type BuildConfig, + type DetectInput, + type PackageManager, +} from "./detect.js"; +export { loadDetectInput, runBuild, type RunBuildOptions, type RunBuildResult } from "./runner.js"; diff --git a/src/utils/build/runner.ts b/src/utils/build/runner.ts new file mode 100644 index 0000000..42346c6 --- /dev/null +++ b/src/utils/build/runner.ts @@ -0,0 +1,114 @@ +/** + * Filesystem + child-process I/O for `dot build`. Kept in its own module so + * `detect.ts` can stay pure and unit-testable. + */ + +import { readFileSync, statSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { detectBuildConfig, PM_LOCKFILES, type BuildConfig, type DetectInput } from "./detect.js"; + +/** Files whose presence alters build strategy (read once at detect time). */ +const CONFIG_PROBES = [ + "vite.config.ts", + "vite.config.js", + "vite.config.mjs", + "next.config.ts", + "next.config.js", + "next.config.mjs", + "tsconfig.json", +] as const; + +/** Read just enough of the project root to drive `detectBuildConfig`. */ +export function loadDetectInput(projectDir: string): DetectInput { + const root = resolve(projectDir); + const stat = existsSync(root) ? statSync(root) : null; + if (!stat?.isDirectory()) { + throw new Error(`Project directory not found: ${root}`); + } + + const pkgPath = join(root, "package.json"); + const packageJson = existsSync(pkgPath) + ? (JSON.parse(readFileSync(pkgPath, "utf8")) as DetectInput["packageJson"]) + : null; + + const lockfiles = new Set(); + for (const name of Object.values(PM_LOCKFILES)) { + if (existsSync(join(root, name))) lockfiles.add(name); + } + + const configFiles = new Set(); + for (const name of CONFIG_PROBES) { + if (existsSync(join(root, name))) configFiles.add(name); + } + + return { packageJson, lockfiles, configFiles }; +} + +export interface RunBuildOptions { + /** Project root. */ + cwd: string; + /** Override the auto-detected build config. */ + config?: BuildConfig; + /** Per-line output callback (stdout + stderr). */ + onData?: (line: string) => void; +} + +export interface RunBuildResult { + config: BuildConfig; + /** Absolute path where the built artifacts live, according to the config. */ + outputDir: string; +} + +/** Run the detected build command; reject on non-zero exit with captured output. */ +export async function runBuild(options: RunBuildOptions): Promise { + const cwd = resolve(options.cwd); + const config = options.config ?? detectBuildConfig(loadDetectInput(cwd)); + + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(config.cmd, config.args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR ?? "1" }, + }); + + const tail: string[] = []; + const MAX_TAIL = 50; + + const forward = (chunk: Buffer) => { + for (const line of chunk.toString().split("\n")) { + if (line.length === 0) continue; + tail.push(line); + if (tail.length > MAX_TAIL) tail.shift(); + options.onData?.(line); + } + }; + + child.stdout.on("data", forward); + child.stderr.on("data", forward); + child.on("error", (err) => + rejectPromise( + new Error(`Failed to spawn "${config.description}": ${err.message}`, { + cause: err, + }), + ), + ); + child.on("close", (code) => { + if (code === 0) { + resolvePromise(); + } else { + const snippet = tail.slice(-10).join("\n") || "(no output)"; + rejectPromise( + new Error( + `Build failed (${config.description}) with exit code ${code}.\n${snippet}`, + ), + ); + } + }); + }); + + return { + config, + outputDir: resolve(cwd, config.defaultOutputDir), + }; +} diff --git a/src/utils/deploy/availability.test.ts b/src/utils/deploy/availability.test.ts new file mode 100644 index 0000000..95a5064 --- /dev/null +++ b/src/utils/deploy/availability.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi } from "vitest"; + +// Mock bulletin-deploy's DotNS class. Ownership check is now driven by the +// caller's H160 (derived from SS58 via `@polkadot-apps/address::ss58ToH160`), +// so the mock needs to reflect the full `{ owned, owner }` shape the caller +// sees when they DO pass a user address. +const classifyName = vi.fn(); +const checkOwnership = vi.fn(); +const connect = vi.fn(async () => {}); +const disconnect = vi.fn(); + +vi.mock("bulletin-deploy", () => ({ + DotNS: vi.fn().mockImplementation(() => ({ + connect, + classifyName, + checkOwnership, + disconnect, + })), +})); + +// A realistic dev SS58 → H160 pair so the tests exercise the real derivation. +// We use Alice's substrate address; its H160 is deterministic. +const ALICE_SS58 = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + +import { checkDomainAvailability, formatAvailability } from "./availability.js"; + +beforeEach(() => { + classifyName.mockReset(); + checkOwnership.mockReset(); + connect.mockClear(); + disconnect.mockClear(); +}); + +// vitest implicitly imports `describe` and `it`; `beforeEach` needs to come from vitest too. +import { beforeEach } from "vitest"; + +describe("checkDomainAvailability", () => { + it("returns 'available' when classification is NoStatus", async () => { + classifyName.mockResolvedValue({ requiredStatus: 0, message: "" }); + + const result = await checkDomainAvailability("my-app"); + expect(result).toEqual({ + status: "available", + label: "my-app", + fullDomain: "my-app.dot", + }); + }); + + it("returns 'reserved' when classification is Reserved (status 3)", async () => { + classifyName.mockResolvedValue({ + requiredStatus: 3, + message: "Reserved for Governance", + }); + + const result = await checkDomainAvailability("polkadot.dot"); + expect(result).toEqual({ + status: "reserved", + label: "polkadot", + fullDomain: "polkadot.dot", + message: "Reserved for Governance", + }); + }); + + it("re-deploys: 'owned by you' returns available with an update note", async () => { + // Regression: previously the availability check used the default dev + // mnemonic's h160 as the comparison, so a domain owned by the user's + // OWN phone signer came back as `owned: false, owner: ` + // and we mis-classified it as `taken`, blocking every re-deploy. + // Fix: derive the caller's H160 via `ss58ToH160` and pass it to + // `checkOwnership`; "owned by the caller" becomes an update path. + classifyName.mockResolvedValue({ requiredStatus: 0, message: "" }); + // DotNS computes owned = owner.toLowerCase() === checkAddress.toLowerCase(). + // The mock echoes the caller's h160 as "owner" so `owned = true`. + checkOwnership.mockImplementation(async (_label: string, checkAddress: string) => ({ + owned: true, + owner: checkAddress, + })); + + const result = await checkDomainAvailability("my-existing-site", { + ownerSs58Address: ALICE_SS58, + }); + expect(result.status).toBe("available"); + if (result.status === "available") { + expect(result.note).toMatch(/Already owned by you/i); + } + + // Lock in that the H160 we pass to DotNS really is derived from the + // SS58 we provided. Without this, the mock would silently accept any + // string and a broken `ss58ToH160` regression would go undetected. + // Alice's canonical H160 on Revive is the keccak256(pubkey)[12:] of + // `5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY`; we assert the + // call used the right length + `0x` shape + non-zero address — we + // avoid hard-coding the exact hex so future SS58 encoding changes + // don't cause spurious test failures as long as the derivation is + // still wired up. + expect(checkOwnership).toHaveBeenCalledTimes(1); + const [, passedH160] = checkOwnership.mock.calls[0]; + expect(passedH160).toMatch(/^0x[0-9a-f]{40}$/); + expect(passedH160).not.toBe("0x0000000000000000000000000000000000000000"); + }); + + it("returns 'taken' when the domain is owned by a different H160", async () => { + classifyName.mockResolvedValue({ requiredStatus: 0, message: "" }); + const otherOwner = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + checkOwnership.mockImplementation(async () => ({ owned: false, owner: otherOwner })); + + const result = await checkDomainAvailability("someone-elses-site", { + ownerSs58Address: ALICE_SS58, + }); + expect(result.status).toBe("taken"); + if (result.status === "taken") expect(result.owner).toBe(otherOwner); + }); + + it("skips the ownership check when no SS58 address is provided", async () => { + // Dev mode without a session signer: we can't do a meaningful + // comparison, so we don't call checkOwnership at all and let + // bulletin-deploy's own preflight handle it with the real signer. + classifyName.mockResolvedValue({ requiredStatus: 0, message: "" }); + + const result = await checkDomainAvailability("any-name"); + expect(result.status).toBe("available"); + expect(checkOwnership).not.toHaveBeenCalled(); + }); + + it("treats PoP Lite / Full requirements as available-with-note, not blockers", async () => { + // Regression: bulletin-deploy auto-sets PoP via setUserPopStatus on testnet, + // so these names DO register successfully. We must not block them in preflight. + classifyName.mockResolvedValue({ requiredStatus: 1, message: "PoP Lite" }); + + const lite = await checkDomainAvailability("short"); + expect(lite.status).toBe("available"); + if (lite.status === "available") { + expect(lite.note).toMatch(/Lite/); + expect(lite.note).toMatch(/automatically/); + } + + classifyName.mockResolvedValue({ requiredStatus: 2, message: "PoP Full" }); + const full = await checkDomainAvailability("shortr"); + expect(full.status).toBe("available"); + if (full.status === "available") { + expect(full.note).toMatch(/Full/); + } + }); + + it("returns 'unknown' and disconnects when the RPC call throws", async () => { + classifyName.mockRejectedValue(new Error("RPC down")); + + const result = await checkDomainAvailability("whatever"); + expect(result.status).toBe("unknown"); + if (result.status === "unknown") expect(result.message).toMatch(/RPC down/); + expect(disconnect).toHaveBeenCalled(); + }); + + it("rejects invalid domain syntax before touching the network", async () => { + await expect(checkDomainAvailability("NOT valid!")).rejects.toThrow(/Invalid domain/); + expect(classifyName).not.toHaveBeenCalled(); + }); +}); + +describe("formatAvailability", () => { + it("renders a friendly sentence for each result kind", () => { + expect(formatAvailability({ status: "available", label: "x", fullDomain: "x.dot" })).toBe( + "x.dot is available", + ); + expect( + formatAvailability({ + status: "reserved", + label: "polkadot", + fullDomain: "polkadot.dot", + message: "Reserved for Governance", + }), + ).toMatch(/reserved/); + expect( + formatAvailability({ + status: "available", + label: "x", + fullDomain: "x.dot", + note: "Requires Proof of Personhood (Lite). Will be set up automatically.", + }), + ).toMatch(/Proof of Personhood \(Lite\)/); + expect( + formatAvailability({ + status: "taken", + label: "x", + fullDomain: "x.dot", + owner: "0xabc", + }), + ).toMatch(/already registered by 0xabc/); + expect( + formatAvailability({ + status: "unknown", + label: "x", + fullDomain: "x.dot", + message: "RPC down", + }), + ).toMatch(/Could not verify/); + }); +}); diff --git a/src/utils/deploy/availability.ts b/src/utils/deploy/availability.ts new file mode 100644 index 0000000..845333d --- /dev/null +++ b/src/utils/deploy/availability.ts @@ -0,0 +1,155 @@ +/** + * Preflight domain availability check. + * + * Hits two view-only DotNS calls via bulletin-deploy's `DotNS` class: + * + * - `classifyName(label)` — PopOracle classification + * - `Reserved` → hard block (nobody can register). + * - `PoP Lite/Full` → advisory note; bulletin-deploy self-attests + * during register on testnet. + * - `checkOwnership(label, userH160?)` — catches names registered to + * a different account *before* we build + upload. When the caller + * passes their own H160 (derived from SS58 via `ss58ToH160`), a + * domain owned BY them returns `status: "available"` with a note — + * this is the re-deploy / update path, not a block. + */ + +import { DotNS } from "bulletin-deploy"; +import { ss58ToH160 } from "@polkadot-apps/address"; +import { normalizeDomain } from "./playground.js"; +import { getChainConfig, type Env } from "../../config.js"; + +/** Mirror of bulletin-deploy's `ProofOfPersonhoodStatus` enum. Kept local so we don't couple to internals. */ +const POP_STATUS_RESERVED = 3; +const POP_STATUS_LITE = 1; +const POP_STATUS_FULL = 2; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +export type AvailabilityResult = + | { status: "available"; label: string; fullDomain: string; note?: string } + | { status: "reserved"; label: string; fullDomain: string; message: string } + | { status: "taken"; label: string; fullDomain: string; owner: string } + | { status: "unknown"; label: string; fullDomain: string; message: string }; + +export interface CheckAvailabilityOptions { + env?: Env; + /** Optional timeout in ms. Each RPC call has its own internal timeout. */ + timeoutMs?: number; + /** + * The deploying account's SS58 address. When provided we derive its H160 + * via `ss58ToH160` and treat "owned by you" as an update path rather than + * a `taken` block. Omit in dev-mode-without-signer and we skip the + * ownership check entirely (bulletin-deploy's own preflight is the + * ultimate source of truth when the real signer is used). + */ + ownerSs58Address?: string; +} + +export async function checkDomainAvailability( + domain: string, + options: CheckAvailabilityOptions = {}, +): Promise { + const { label, fullDomain } = normalizeDomain(domain); + const cfg = getChainConfig(options.env); + + // DotNS connect pings RPC + does an `ensureAccountMapped` tx if the dev + // account isn't mapped yet. On testnet the default account is already + // mapped, so this is effectively a pure read path — no phone prompts. + const dotns = new DotNS(); + try { + await withTimeout( + dotns.connect({ rpc: cfg.assetHubRpc }), + options.timeoutMs ?? 30_000, + "DotNS connect", + ); + + const classification = await dotns.classifyName(label); + if (classification.requiredStatus === POP_STATUS_RESERVED) { + return { + status: "reserved", + label, + fullDomain, + message: classification.message || "Reserved for Governance", + }; + } + + // Ownership check — pass the user's H160 so "owned by you" is + // correctly identified as an update path rather than a block. + // When the caller doesn't know (dev mode with no session), we skip + // the ownership check and let bulletin-deploy's own preflight + // (which always has the right signer) make the final call. + const userH160 = options.ownerSs58Address ? ss58ToH160(options.ownerSs58Address) : null; + + if (userH160) { + const { owned, owner } = await dotns.checkOwnership(label, userH160); + if (owner && owner.toLowerCase() !== ZERO_ADDRESS && !owned) { + return { status: "taken", label, fullDomain, owner }; + } + if (owned) { + return { + status: "available", + label, + fullDomain, + note: "Already owned by you — will update the existing deployment.", + }; + } + } + + // Names that require Proof-of-Personhood are still registrable on + // testnet — bulletin-deploy self-attests during `register()` via + // `setUserPopStatus`. Surface it as an advisory note, not a blocker. + if ( + classification.requiredStatus === POP_STATUS_LITE || + classification.requiredStatus === POP_STATUS_FULL + ) { + const requirement = classification.requiredStatus === POP_STATUS_FULL ? "Full" : "Lite"; + return { + status: "available", + label, + fullDomain, + note: `Requires Proof of Personhood (${requirement}). Will be set up automatically.`, + }; + } + + return { status: "available", label, fullDomain }; + } catch (err) { + return { + status: "unknown", + label, + fullDomain, + message: err instanceof Error ? err.message : String(err), + }; + } finally { + try { + dotns.disconnect(); + } catch { + // best-effort — disconnect is idempotent in practice + } + } +} + +/** Human-readable single-line summary for the TUI / CLI. */ +export function formatAvailability(result: AvailabilityResult): string { + switch (result.status) { + case "available": + return result.note + ? `${result.fullDomain} is available — ${result.note}` + : `${result.fullDomain} is available`; + case "reserved": + return `${result.fullDomain} is reserved — ${result.message}`; + case "taken": + return `${result.fullDomain} is already registered by ${result.owner} — transfer it or use a different name`; + case "unknown": + return `Could not verify ${result.fullDomain}: ${result.message}`; + } +} + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms), + ), + ]); +} diff --git a/src/utils/deploy/index.ts b/src/utils/deploy/index.ts new file mode 100644 index 0000000..3545681 --- /dev/null +++ b/src/utils/deploy/index.ts @@ -0,0 +1,44 @@ +/** + * Public surface for programmatic deploy usage (RevX, automation, etc.). + * + * This module must not import React, Ink, or any CLI-specific code so it + * remains safe to consume from a WebContainer. All Node-specific bits are + * hidden inside the submodules and only surfaced through typed events. + */ + +export { + runDeploy, + type DeployEvent, + type DeployOutcome, + type RunDeployOptions, + type DeployPhase, +} from "./run.js"; +export { + publishToPlayground, + normalizeDomain, + normalizeGitRemote, + readGitRemote, + type PublishToPlaygroundOptions, + type PublishToPlaygroundResult, +} from "./playground.js"; +export { + resolveSignerSetup, + type SignerMode, + type DeployApproval, + type DeploySignerSetup, +} from "./signerMode.js"; +export type { SigningEvent } from "./signingProxy.js"; +export type { DeployLogEvent } from "./progress.js"; +export { + checkDomainAvailability, + formatAvailability, + type AvailabilityResult, + type CheckAvailabilityOptions, +} from "./availability.js"; + +// Re-exported so SDK consumers (RevX) can tear down the shared Paseo client +// that `publishToPlayground` and `runDeploy` use internally. The CLI calls +// this itself from `deploy/index.ts` cleanupOnce; non-CLI consumers must +// call it once they're done with a run or the WebSocket keeps their event +// loop alive. +export { destroyConnection } from "../connection.js"; diff --git a/src/utils/deploy/playground.test.ts b/src/utils/deploy/playground.test.ts new file mode 100644 index 0000000..beb3263 --- /dev/null +++ b/src/utils/deploy/playground.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the metadata upload path so we never actually touch the network. +// The mock returns a fake CID that publish() treats as the metadata CID. +vi.mock("@polkadot-apps/bulletin", () => ({ + upload: vi.fn(async () => ({ cid: "bafymeta", blockHash: "0x0" })), +})); + +// Likewise stub the connection + registry helpers. We capture the publish +// arguments so we can assert on them. +const publishTx = vi.fn(async () => ({ ok: true, txHash: "0xdead" })); +vi.mock("../connection.js", () => ({ + getConnection: vi.fn(async () => ({ raw: { assetHub: {} } })), +})); +vi.mock("../registry.js", () => ({ + getRegistryContract: vi.fn(async () => ({ + publish: { tx: publishTx }, + })), +})); + +import { publishToPlayground, normalizeDomain, normalizeGitRemote } from "./playground.js"; +import type { ResolvedSigner } from "../signer.js"; + +const fakeSigner: ResolvedSigner = { + signer: {} as any, + address: "5Fake", + source: "session", + destroy: () => {}, +}; + +beforeEach(() => { + publishTx.mockClear(); + publishTx.mockImplementation(async () => ({ ok: true, txHash: "0xdead" })); +}); + +describe("normalizeDomain", () => { + it("accepts a bare label", () => { + expect(normalizeDomain("my-app")).toEqual({ label: "my-app", fullDomain: "my-app.dot" }); + }); + + it("accepts a label with .dot suffix", () => { + expect(normalizeDomain("my-app.dot")).toEqual({ + label: "my-app", + fullDomain: "my-app.dot", + }); + }); + + it("rejects invalid characters", () => { + expect(() => normalizeDomain("My_App!")).toThrow(/Invalid domain/); + }); +}); + +describe("normalizeGitRemote", () => { + it("converts SSH URLs to HTTPS and strips .git", () => { + expect(normalizeGitRemote("git@github.com:paritytech/playground-cli.git\n")).toBe( + "https://github.com/paritytech/playground-cli", + ); + }); + + it("strips .git from HTTPS URLs", () => { + expect(normalizeGitRemote("https://github.com/foo/bar.git")).toBe( + "https://github.com/foo/bar", + ); + }); + + it("leaves non-.git URLs unchanged", () => { + expect(normalizeGitRemote("https://example.com/app")).toBe("https://example.com/app"); + }); +}); + +describe("publishToPlayground", () => { + it("uploads metadata JSON and calls registry.publish with the phone signer", async () => { + const result = await publishToPlayground({ + domain: "my-app", + publishSigner: fakeSigner, + repositoryUrl: "https://github.com/paritytech/example", + }); + + expect(result.fullDomain).toBe("my-app.dot"); + expect(result.metadata).toEqual({ repository: "https://github.com/paritytech/example" }); + expect(result.metadataCid).toBe("bafymeta"); + expect(publishTx).toHaveBeenCalledWith("my-app.dot", "bafymeta"); + }); + + it("omits the repository field when no git remote is available", async () => { + const result = await publishToPlayground({ + domain: "my-app.dot", + publishSigner: fakeSigner, + repositoryUrl: undefined, + // Force the git probe to return null without touching the user's real repo. + cwd: "/definitely/not/a/repo", + }); + expect(result.metadata).toEqual({}); + }); + + it("retries up to 3 times on registry publish failure", async () => { + publishTx.mockImplementationOnce(async () => { + throw new Error("nonce race"); + }); + publishTx.mockImplementationOnce(async () => { + throw new Error("nonce race"); + }); + publishTx.mockImplementationOnce(async () => ({ ok: true, txHash: "0xbeef" })); + + const result = await publishToPlayground({ + domain: "flaky", + publishSigner: fakeSigner, + repositoryUrl: "https://example.com/x", + }); + expect(publishTx).toHaveBeenCalledTimes(3); + expect(result.fullDomain).toBe("flaky.dot"); + }, 30_000); + + it("surfaces the last error after exhausting retries", async () => { + publishTx.mockImplementation(async () => { + throw new Error("unauthorized"); + }); + + await expect( + publishToPlayground({ + domain: "doomed", + publishSigner: fakeSigner, + repositoryUrl: "https://example.com/x", + }), + ).rejects.toThrow(/unauthorized/); + }, 30_000); +}); diff --git a/src/utils/deploy/playground.ts b/src/utils/deploy/playground.ts new file mode 100644 index 0000000..3c3f7ab --- /dev/null +++ b/src/utils/deploy/playground.ts @@ -0,0 +1,165 @@ +/** + * Own playground-registry publish flow. + * + * We upload the metadata JSON through `bulletin-deploy`'s `storeFile` (just + * storage, NO DotNS) and then call `registry.publish(domain, metadataCid)` + * ourselves via `getRegistryContract()`. Publishing is always signed by the + * user so the contract's `env::caller()` matches their address — that's + * what drives the playground-app "myApps" view. + * + * We deliberately do NOT use `bulletin-deploy.deploy()` for the metadata + * upload: `deploy()` unconditionally runs a DotNS `register()` + + * `setContenthash()` on whatever name you give it (or a randomly generated + * `test-domain-*` when you pass `null`). That second DotNS pass is wasteful + * and has been observed to revert with opaque contract errors. Calling + * `storeFile` directly is the scalpel we want. + */ + +import { execFileSync } from "node:child_process"; +import { createClient } from "polkadot-api"; +import { getWsProvider } from "polkadot-api/ws-provider/web"; +import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat"; +import { bulletin } from "@polkadot-apps/descriptors/bulletin"; +import { upload } from "@polkadot-apps/bulletin"; +import { getRegistryContract } from "../registry.js"; +import { getConnection } from "../connection.js"; +import { getChainConfig, type Env } from "../../config.js"; +import type { ResolvedSigner } from "../signer.js"; +import type { DeployLogEvent } from "./progress.js"; + +/** + * Heartbeat we force on the Bulletin WebSocket for the metadata upload. + * `polkadot-api`'s default is 40 s, which is shorter than the time a single + * `TransactionStorage.store` submission can take (finalization wait + chain + * round-trips), so the transport tears down mid-tx as `WS halt (3)`. + * Matches what `bulletin-deploy` does for its own clients. See CLAUDE.md. + */ +const BULLETIN_WS_HEARTBEAT_MS = 300_000; + +const MAX_REGISTRY_RETRIES = 3; +const REGISTRY_RETRY_DELAY_MS = 6_000; + +export interface PublishToPlaygroundOptions { + /** The DotNS label (with or without `.dot`). */ + domain: string; + /** Signer that will be recorded as the app owner in the registry. */ + publishSigner: ResolvedSigner; + /** Explicit repository URL. If omitted we probe `git remote get-url origin`. */ + repositoryUrl?: string; + /** Working dir used to probe the git remote when `repositoryUrl` is absent. */ + cwd?: string; + /** Progress sink for the metadata-upload sub-step. */ + onLogEvent?: (event: DeployLogEvent) => void; + /** Target environment. */ + env?: Env; +} + +export interface PublishToPlaygroundResult { + /** CID of the metadata JSON on Bulletin. */ + metadataCid: string; + /** Fully-qualified domain string recorded in the registry. */ + fullDomain: string; + /** Effective metadata payload that got uploaded. */ + metadata: Record; +} + +/** Strip `.dot` suffix if present so we can normalize to a canonical `label.dot`. */ +export function normalizeDomain(domain: string): { label: string; fullDomain: string } { + const label = domain.replace(/\.dot$/i, ""); + if (!/^[a-z0-9][a-z0-9-]*$/i.test(label)) { + throw new Error( + `Invalid domain "${domain}" — use lowercase letters, digits, and dashes (e.g. my-app.dot).`, + ); + } + return { label, fullDomain: `${label}.dot` }; +} + +/** Normalize `git remote get-url origin` output the same way bulletin-deploy does. */ +export function normalizeGitRemote(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith("git@")) { + return trimmed.replace(/^git@([^:]+):/, "https://$1/").replace(/\.git$/, ""); + } + return trimmed.replace(/\.git$/, ""); +} + +/** Try to read the `origin` git remote. Swallow errors — deploy still works without it. */ +export function readGitRemote(cwd?: string): string | null { + try { + const raw = execFileSync("git", ["remote", "get-url", "origin"], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + cwd, + }); + return normalizeGitRemote(raw); + } catch { + return null; + } +} + +export async function publishToPlayground( + options: PublishToPlaygroundOptions, +): Promise { + const { label, fullDomain } = normalizeDomain(options.domain); + + const repoUrl = options.repositoryUrl ?? readGitRemote(options.cwd); + const metadata: Record = {}; + if (repoUrl) metadata.repository = repoUrl; + + const metadataBytes = new Uint8Array(Buffer.from(JSON.stringify(metadata), "utf8")); + + options.onLogEvent?.({ kind: "info", message: "Uploading playground metadata to Bulletin…" }); + // Storage-only upload via `@polkadot-apps/bulletin`. Submits + // `TransactionStorage.store` directly — no DotNS, no `register()`, no + // `setContenthash()`. The signer defaults to the Alice dev signer on + // testnet, which is fine for a small metadata JSON. + // + // We spin up a DEDICATED Bulletin client with a 300 s WS heartbeat rather + // than reusing the shared one from `getConnection()`. The shared client + // uses polkadot-api's 40 s default which is shorter than a single-tx + // submission and manifests as `WS halt (3)` mid-upload. + const cfg = getChainConfig(options.env); + const bulletinClient = createClient( + withPolkadotSdkCompat( + getWsProvider({ + endpoints: [cfg.bulletinRpc], + heartbeatTimeout: BULLETIN_WS_HEARTBEAT_MS, + }), + ), + ); + let metadataCid: string; + try { + const bulletinApi = bulletinClient.getTypedApi(bulletin); + const result = await upload(bulletinApi, metadataBytes); + metadataCid = result.cid; + } finally { + bulletinClient.destroy(); + } + options.onLogEvent?.({ kind: "info", message: `Metadata CID: ${metadataCid}` }); + + const client = await getConnection(); + const registry = await getRegistryContract(client.raw.assetHub, options.publishSigner); + + let lastError: unknown; + for (let attempt = 1; attempt <= MAX_REGISTRY_RETRIES; attempt++) { + try { + const result = await registry.publish.tx(fullDomain, metadataCid); + if (result && result.ok === false) { + throw new Error("Registry publish transaction reverted"); + } + return { metadataCid, fullDomain, metadata }; + } catch (err) { + lastError = err; + if (attempt >= MAX_REGISTRY_RETRIES) break; + await new Promise((r) => setTimeout(r, REGISTRY_RETRY_DELAY_MS)); + } + } + + const msg = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error( + `Failed to publish to Playground registry after ${MAX_REGISTRY_RETRIES} attempts: ${msg}`, + { + cause: lastError instanceof Error ? lastError : undefined, + }, + ); +} diff --git a/src/utils/deploy/progress.test.ts b/src/utils/deploy/progress.test.ts new file mode 100644 index 0000000..1a40fe0 --- /dev/null +++ b/src/utils/deploy/progress.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { DeployLogParser, type DeployLogEvent } from "./progress.js"; + +function feedAll(lines: string[]): DeployLogEvent[] { + const parser = new DeployLogParser(); + const out: DeployLogEvent[] = []; + for (const line of lines) { + const ev = parser.feed(line); + if (ev) out.push(ev); + } + return out; +} + +describe("DeployLogParser", () => { + it("emits phase-start for the Storage banner", () => { + const events = feedAll([ + "============================================================", + "Storage", + "============================================================", + ]); + expect(events).toEqual([{ kind: "phase-start", phase: "storage" }]); + }); + + it("emits phase-start for DotNS and completion banners", () => { + const events = feedAll([ + "============================================================", + "DotNS", + "============================================================", + "============================================================", + "DEPLOYMENT COMPLETE!", + "============================================================", + ]); + expect(events).toEqual([ + { kind: "phase-start", phase: "dotns" }, + { kind: "phase-start", phase: "complete" }, + ]); + }); + + it("parses chunk progress lines", () => { + const events = feedAll([" [1/2] 1.00 MB (nonce: 42)", " [2/2] 0.23 MB (nonce: 43)"]); + expect(events).toEqual([ + { kind: "chunk-progress", current: 1, total: 2 }, + { kind: "chunk-progress", current: 2, total: 2 }, + ]); + }); + + it("drops unknown banner titles — extend PHASE_BANNERS to handle new ones", () => { + // Regression guard: previously an unknown banner emitted an info + // event, which could leak high-volume prose through the same code + // path. Unknown banners must be SILENT here — if bulletin-deploy + // adds a new phase, extend PHASE_BANNERS to match it. + const events = feedAll([ + "============================================================", + "Some Future Section", + "============================================================", + ]); + expect(events).toEqual([]); + }); + + it("drops plain prose lines so we don't flood the TUI", () => { + // Regression guard: previously we emitted `info` events for every + // random log line. Bulletin-deploy produces hundreds per deploy + // and the per-event allocation was a measurable contributor to + // the multi-GB memory pressure we hit during chunk uploads. + const events = feedAll([" Domain: my-app.dot", " Build dir: /tmp/dist"]); + expect(events).toEqual([]); + }); + + it("ignores blank lines and divider-only lines", () => { + const events = feedAll([ + "", + " ", + "============================================================", + ]); + expect(events).toEqual([]); + }); + + it("handles trailing carriage returns from Windows-style output", () => { + // The actual log capture may include \r from child_process buffers + // even on Linux; strip them so banners still match. + const parser = new DeployLogParser(); + parser.feed("============================================================\r"); + const ev = parser.feed("Storage\r"); + expect(ev).toEqual({ kind: "phase-start", phase: "storage" }); + }); +}); diff --git a/src/utils/deploy/progress.ts b/src/utils/deploy/progress.ts new file mode 100644 index 0000000..79559ac --- /dev/null +++ b/src/utils/deploy/progress.ts @@ -0,0 +1,95 @@ +/** + * Line-level parser that turns bulletin-deploy's banner/prose output into a + * typed event stream the TUI can render. Best-effort — we use it only for + * the phases that aren't signature-gated (chunk upload progress, etc.). + * + * Remove once bulletin-deploy exposes a first-class `onProgress` callback. + */ + +export type DeployPhase = + | "preflight" + | "storage" + | "dotns" + | "registry" + | "playground" + | "complete"; + +export type DeployLogEvent = + | { kind: "phase-start"; phase: DeployPhase } + | { kind: "chunk-progress"; current: number; total: number } + | { kind: "info"; message: string }; + +/** Map the human-readable banner titles bulletin-deploy prints to our phase keys. */ +const PHASE_BANNERS: Array<{ pattern: RegExp; phase: DeployPhase }> = [ + { pattern: /^preflight$/i, phase: "preflight" }, + { pattern: /^storage$/i, phase: "storage" }, + { pattern: /^dotns$/i, phase: "dotns" }, + { pattern: /^registry$/i, phase: "registry" }, + { pattern: /^playground$/i, phase: "playground" }, + { pattern: /^deployment complete!?$/i, phase: "complete" }, +]; + +const BANNER_DIVIDER = /^=+$/; +const CHUNK_RE = /^\s*\[(\d+)\/(\d+)\]/; + +/** + * Stateful parser: bulletin-deploy's banner is three lines + * + * ============================================================ + * Storage + * ============================================================ + * + * so we need to remember that we just saw a divider to correctly pair it with + * the next non-divider line. + */ +export class DeployLogParser { + private expectingBannerTitle = false; + + feed(rawLine: string): DeployLogEvent | null { + const line = rawLine.replace(/\r/g, "").trimEnd(); + const trimmed = line.trim(); + + if (BANNER_DIVIDER.test(trimmed)) { + // A divider means the next non-divider line *could* be a title. + // We just assert the flag rather than toggle — consecutive + // dividers (closing of one banner + opening of the next) keep + // `expectingBannerTitle` true so the title still registers. + this.expectingBannerTitle = true; + return null; + } + + if (this.expectingBannerTitle && trimmed.length > 0) { + this.expectingBannerTitle = false; + const match = PHASE_BANNERS.find((p) => p.pattern.test(trimmed)); + if (match) { + return { kind: "phase-start", phase: match.phase }; + } + // Unknown banner title — drop. Earlier we emitted `info` here as + // a forward-compat hook; that violated the "no event per log line" + // invariant documented in CLAUDE.md and left a loophole where a + // single banner with a typo could open the info firehose. New + // phases bulletin-deploy adds can be matched by extending + // `PHASE_BANNERS`. + return null; + } + + const chunkMatch = trimmed.match(CHUNK_RE); + if (chunkMatch) { + return { + kind: "chunk-progress", + current: Number(chunkMatch[1]), + total: Number(chunkMatch[2]), + }; + } + + // Everything else is quiet prose from bulletin-deploy (CID echoes, + // nonce traces, per-chunk success lines, etc). We intentionally DROP + // it rather than emit `info` events: the upload path produces + // hundreds of such lines and every one of them allocated an event + // object + traversed our orchestrator -> TUI pipeline, which was a + // measurable contributor to heap pressure during long deploys. The + // TUI already shows chunk progress from the parsed events above; + // users don't need the raw log stream in the Ink panel. + return null; + } +} diff --git a/src/utils/deploy/run.test.ts b/src/utils/deploy/run.test.ts new file mode 100644 index 0000000..4cbdfa6 --- /dev/null +++ b/src/utils/deploy/run.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mocks for the heavy underlying pieces. Orchestrator tests only care about +// sequencing, event shape, and error propagation. Declare mocks via +// `vi.hoisted()` so they're available when `vi.mock()` (itself hoisted) runs. +const { + runStorageDeploy, + publishToPlaygroundMock, + runBuildMock, + detectBuildConfigMock, + loadDetectInputMock, +} = vi.hoisted(() => ({ + runStorageDeploy: vi.fn< + (arg: any) => Promise<{ + domainName: string; + fullDomain: string; + cid: string; + ipfsCid: string; + }> + >(async () => ({ + domainName: "my-app", + fullDomain: "my-app.dot", + cid: "bafyapp", + ipfsCid: "bafyipfs", + })), + publishToPlaygroundMock: vi.fn(async () => ({ + metadataCid: "bafymeta", + fullDomain: "my-app.dot", + metadata: {}, + })), + runBuildMock: vi.fn(async () => ({ config: {} as any, outputDir: "/tmp/dist" })), + detectBuildConfigMock: vi.fn(() => ({ + cmd: "pnpm", + args: ["run", "build"], + description: "pnpm run build", + defaultOutputDir: "dist", + })), + loadDetectInputMock: vi.fn(() => ({ + packageJson: { scripts: { build: "vite build" } }, + lockfiles: new Set(), + configFiles: new Set(), + })), +})); + +vi.mock("./storage.js", () => ({ runStorageDeploy })); +vi.mock("./playground.js", () => ({ + publishToPlayground: publishToPlaygroundMock, + normalizeDomain: (d: string) => { + const label = d.replace(/\.dot$/, ""); + return { label, fullDomain: `${label}.dot` }; + }, +})); +vi.mock("../build/index.js", () => ({ + runBuild: runBuildMock, + loadDetectInput: loadDetectInputMock, + detectBuildConfig: detectBuildConfigMock, +})); + +import { runDeploy, type DeployEvent } from "./run.js"; +import type { ResolvedSigner } from "../signer.js"; + +const fakeUserSigner: ResolvedSigner = { + signer: { + publicKey: new Uint8Array(32), + signTx: vi.fn(), + signBytes: vi.fn(), + }, + address: "5Fake", + source: "session", + destroy: vi.fn(), +}; + +function collectEvents(): { events: DeployEvent[]; push: (e: DeployEvent) => void } { + const events: DeployEvent[] = []; + return { events, push: (e) => events.push(e) }; +} + +beforeEach(() => { + runStorageDeploy.mockClear(); + publishToPlaygroundMock.mockClear(); + runBuildMock.mockClear(); +}); + +describe("runDeploy", () => { + it("dev mode without playground: no phone taps, no publishToPlayground call", async () => { + const { events, push } = collectEvents(); + const outcome = await runDeploy({ + projectDir: "/tmp/proj", + buildDir: "/tmp/proj/dist", + domain: "my-app", + mode: "dev", + publishToPlayground: false, + userSigner: null, + onEvent: push, + }); + + expect(outcome.fullDomain).toBe("my-app.dot"); + expect(outcome.approvalsRequested).toEqual([]); + expect(publishToPlaygroundMock).not.toHaveBeenCalled(); + + const plan = events.find((e) => e.kind === "plan"); + expect(plan).toEqual({ kind: "plan", approvals: [] }); + + // bulletin-deploy auth must be empty in dev mode. + expect(runStorageDeploy).toHaveBeenCalledTimes(1); + const arg = runStorageDeploy.mock.calls[0][0]; + expect(arg.auth).toEqual({}); + expect(arg.domainName).toBe("my-app"); + }); + + it("dev mode with playground: 1 planned approval, calls publishToPlayground", async () => { + const { events, push } = collectEvents(); + const outcome = await runDeploy({ + projectDir: "/tmp/proj", + buildDir: "/tmp/proj/dist", + domain: "my-app", + mode: "dev", + publishToPlayground: true, + userSigner: fakeUserSigner, + onEvent: push, + }); + + expect(outcome.approvalsRequested).toEqual([ + { phase: "playground", label: "Publish to Playground registry" }, + ]); + expect(outcome.metadataCid).toBe("bafymeta"); + expect(publishToPlaygroundMock).toHaveBeenCalledTimes(1); + + const plan = events.find((e) => e.kind === "plan"); + expect(plan?.kind).toBe("plan"); + if (plan?.kind === "plan") expect(plan.approvals).toHaveLength(1); + }); + + it("phone mode with playground: 4 planned approvals, DotNS uses phone signer", async () => { + const { events, push } = collectEvents(); + const outcome = await runDeploy({ + projectDir: "/tmp/proj", + buildDir: "/tmp/proj/dist", + domain: "my-app", + mode: "phone", + publishToPlayground: true, + userSigner: fakeUserSigner, + onEvent: push, + }); + + expect(outcome.approvalsRequested).toHaveLength(4); + + // bulletin-deploy auth must carry a wrapped signer + our address. + const arg = runStorageDeploy.mock.calls[0][0]; + expect(arg.auth.signerAddress).toBe("5Fake"); + expect(arg.auth.signer).toBeDefined(); + + const plan = events.find((e) => e.kind === "plan"); + if (plan?.kind === "plan") { + expect(plan.approvals.map((a) => a.phase)).toEqual([ + "dotns", + "dotns", + "dotns", + "playground", + ]); + } + }); + + it("phone mode without a logged-in session throws before touching the network", async () => { + const { push } = collectEvents(); + await expect( + runDeploy({ + projectDir: "/tmp/proj", + buildDir: "/tmp/proj/dist", + domain: "my-app", + mode: "phone", + publishToPlayground: false, + userSigner: null, + onEvent: push, + }), + ).rejects.toThrow(/Phone signer requested/); + + expect(runStorageDeploy).not.toHaveBeenCalled(); + }); + + it("skipBuild bypasses detect + runBuild", async () => { + const { push } = collectEvents(); + await runDeploy({ + projectDir: "/tmp/proj", + buildDir: "/tmp/proj/dist", + skipBuild: true, + domain: "my-app", + mode: "dev", + publishToPlayground: false, + userSigner: null, + onEvent: push, + }); + expect(runBuildMock).not.toHaveBeenCalled(); + }); + + it("emits error event and rethrows when storage fails", async () => { + runStorageDeploy.mockImplementationOnce(async () => { + throw new Error("bulletin rpc down"); + }); + const { events, push } = collectEvents(); + await expect( + runDeploy({ + projectDir: "/tmp/proj", + buildDir: "/tmp/proj/dist", + skipBuild: true, + domain: "my-app", + mode: "dev", + publishToPlayground: false, + userSigner: null, + onEvent: push, + }), + ).rejects.toThrow(/bulletin rpc down/); + + const err = events.find((e) => e.kind === "error"); + expect(err).toMatchObject({ phase: "storage-and-dotns", message: "bulletin rpc down" }); + }); +}); diff --git a/src/utils/deploy/run.ts b/src/utils/deploy/run.ts new file mode 100644 index 0000000..0a0e743 --- /dev/null +++ b/src/utils/deploy/run.ts @@ -0,0 +1,237 @@ +/** + * Orchestrator for the full `dot deploy` flow. + * + * The function is deliberately pure-ish: it takes an already-resolved signer, + * emits a typed event stream, and leaves UI concerns (Ink, spinners) to the + * caller. RevX can import this module in a WebContainer and drive its own UI + * off the same events. + */ + +import { runBuild, loadDetectInput, detectBuildConfig, type BuildConfig } from "../build/index.js"; +import { runStorageDeploy } from "./storage.js"; +import { publishToPlayground, normalizeDomain } from "./playground.js"; +import { resolveSignerSetup, type SignerMode, type DeployApproval } from "./signerMode.js"; +import { + wrapSignerWithEvents, + createSigningCounter, + type SigningCounter, + type SigningEvent, +} from "./signingProxy.js"; +import type { DeployLogEvent } from "./progress.js"; +import type { ResolvedSigner } from "../signer.js"; +import type { Env } from "../../config.js"; + +// ── Events ─────────────────────────────────────────────────────────────────── + +export type DeployPhase = "build" | "storage-and-dotns" | "playground" | "done"; + +export type DeployEvent = + | { kind: "plan"; approvals: DeployApproval[] } + | { kind: "phase-start"; phase: DeployPhase } + | { kind: "phase-complete"; phase: DeployPhase } + | { kind: "build-log"; line: string } + | { kind: "build-detected"; config: BuildConfig } + | { kind: "storage-event"; event: DeployLogEvent } + | { kind: "signing"; event: SigningEvent } + | { kind: "error"; phase: DeployPhase; message: string }; + +// ── Inputs & outputs ───────────────────────────────────────────────────────── + +export interface RunDeployOptions { + /** Project root — where the build runs. */ + projectDir: string; + /** Relative path inside `projectDir` that holds the built artifacts. */ + buildDir: string; + /** Skip the build step (e.g. if the caller already built). */ + skipBuild?: boolean; + /** DotNS label (with or without `.dot`). */ + domain: string; + /** Signer mode — `dev` uses bulletin-deploy defaults, `phone` uses the user's session. */ + mode: SignerMode; + /** Whether to publish to the playground registry after DotNS succeeds. */ + publishToPlayground: boolean; + /** The logged-in phone signer. Required for `mode === "phone"` or `publishToPlayground`. */ + userSigner: ResolvedSigner | null; + /** Event sink — consumed by the TUI / RevX. */ + onEvent: (event: DeployEvent) => void; + /** Target environment. Defaults to `testnet`. */ + env?: Env; +} + +export interface DeployOutcome { + /** Canonical `