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 `