Refer to the Contributing and Architecture Highlights sections of README.md for development workflows, release process, and repo conventions.
These are things that aren't self-evident from reading the code and have bitten us before:
- Do not upgrade
polkadot-apior@polkadot-api/sdk-inkpast the current pins without also bumping@polkadot-apps/chain-client. Newer versions break the internalPolkadotClientshape thatchain-clientstill relies on. - The mobile app wraps
signRawdata with<Bytes>…</Bytes>, which breaks transaction signing. Oursrc/utils/session-signer-patch.tsexists specifically to route transactions throughsignPayloadinstead. Delete this file once@polkadot-apps/terminalships a fix — nothing else. getSessionSigner()returns an adapter that keeps the Node event loop alive. Every caller must invoke the returneddestroy()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 initauto-runs at the end ofinstall.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 an0x…address anywhere else — when mainnet launches we'll be flipping one switch, not grepping the tree. - Deploy delegates to
bulletin-deployfor 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 isregistry.publish()— because the contract recordsenv::caller()as app owner and that needs to be the user, not a shared dev key. Seesrc/utils/deploy/playground.ts. - Do NOT call
bulletin-deploy.deploy()just to store a metadata JSON.deploy()unconditionally runs a DotNSregister()+setContenthash()for whatever name you hand it — and fordomainName: nullit invents atest-domain-<random>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 submitsTransactionStorage.storedirectly and returns the CID. No DotNS side-trip. - Build a dedicated Bulletin client with
heartbeatTimeout: 300_000for the metadata upload. The shared client fromgetConnection()uses@polkadot-apps/chain-client, which callsgetWsProvider(rpcs)with no options → polkadot-api's 40 s default heartbeat. A singleTransactionStorage.storeround-trip can exceed 40 s and the socket tears down asWS halt (3).bulletin-deploysidesteps this with its own 300 s heartbeat; we mirror that with a one-off client insrc/utils/deploy/playground.tsthat we destroy immediately after the upload. dot deploydoes NOT passjsMerkle: truetobulletin-deployright now. bulletin-deploy's pure-JS merkleizer produces CARs that only contain raw leaves — the DAG-PB directory/file blocks are silently dropped byblockstore-core/memory'sgetAll()underrawLeaves: true+wrapWithDirectory: true. Proof: a real deployed CAR we fetched frompaseo-ipfs.polkadot.iocontained 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 initinstallsipfs, so this Just Works for anyone who ran setup. Trade-off: this temporarily breaks the RevX WebContainer story for the main storage upload — flipjsMerkle: trueback once bulletin-deploy fixesmerkleizeJSto 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/*andsrc/utils/build/*must not import React or Ink. They form the SDK surface that RevX consumes from a WebContainer. TUI code lives insrc/commands/*/.- Bun compiled-binary stdin quirk — Ink's
useInputsilently drops every keystroke (arrows, Enter, Ctrl+C) inbun build --compilebinaries unlessprocess.stdin.on('readable', …)is touched before Ink'srender(). We install a no-opreadablelistener at the top ofsrc/index.tsas 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-deploy0.7.4+ pulls in a transitive dep with a broken publish manifest that pnpm refuses to install.@parity/dotns-cli@0.5.6's publishedpackage.jsondeclares"@polkadot-api/descriptors": "file:.papi/descriptors"— a workspace-only path that doesn't exist in the published tarball. npm tolerates the danglingfile:reference (creates a broken symlink and continues); pnpm's strict resolver fails withERR_PNPM_LINKED_PKG_DIR_NOT_FOUND. We work around it with apnpm.overridesentry inpackage.jsonpointing the offending sub-dep at a tiny stub package (stubs/papi-descriptors-stub/) so resolution succeeds. The dep is functionally vestigial — dotns-cli'sdist/cli.jsis fully-bundled (Bun build, no externals) and never imports@polkadot-api/descriptorsat runtime, so the stub exporting{}is correct. Remove the override + stub once@parity/dotns-clirepublishes a clean manifest. Tracked upstream againstparitytech/dotns-sdk.bulletin-deployis pinned to an explicit version, notlatest. We're on0.7.6stable today. Thelatestnpm dist-tag is a moving target and previously pointed at 0.6.8, which has a WebSocket heartbeat bug (default 40s < chunk timeout 60s) that tears down uploads mid-flight asWS halt (3). Keep the pin explicit so we never silently slide onto a brokenlatest. When upgrading, read the release notes for any public-API changes todeploy(),DotNSmethods, or theDeployOptionswe rely on (jsMerkle,signer,signerAddress,mnemonic,rpc,attributes). Note: 0.7.0 removed theplayground?: booleanDeployOption(registry publishing now lives here insrc/utils/deploy/playground.ts), which is a no-op for us since we never passed that flag. 0.7.1 made the memory-report teardown Bun-safe upstream. 0.7.2 bumped the defaultCHUNK_TIMEOUT_MS60s → 180s to match Bulletin's new 24s Aura slot duration;BULLETIN_CHUNK_TIMEOUT_MSoverride still works. 0.7.4 extracted the dotns logic into a separate@parity/dotns-clisubprocess (forked via_require.resolve("@parity/dotns-cli")); see the publish-bug workaround note above. 0.7.4 also moved label classification off theDotNSinstance — the previously-instance methoddotns.classifyName(label)is now the top-level pure functionclassifyDotnsLabel(label), and the result field renamedrequiredStatus→status. The function isn't re-exported from the package root, sosrc/utils/deploy/availability.tsmirrors the (small, stable) logic locally asclassifyLabel— same precedent assimulateUserStatus. 0.7.6 added ambient Sentry mode for host apps; keep the CLI-owned privacy gate insrc/bootstrap.ts.- Throttle TUI info updates — bulletin-deploy logs per-chunk and builds (vite/next) stream thousands of lines/sec. Calling
setStateon every log event floods React's reconciler with so much backpressure the process can balloon past 20 GB and freeze the OS.RunningStagecoalesces "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, turningdotinto a zombie that accumulates retry buffers indefinitely (seen climbing past 25 GB). We defend in depth: (1)installSignalHandlers()catches SIGINT/TERM/HUP +unhandledRejectionand forces cleanup + exit within 3 s; (2)scheduleHardExit()installs anunref'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. SetDOT_MEMORY_TRACE=1to stream per-sample RSS/heap/external stats — useful when diagnosing a real leak report. Telemetry bootstrap (src/bootstrap.ts) is the FIRST import insrc/index.ts. It setsBULLETIN_DEPLOY_USE_AMBIENT_SENTRY=1andBULLETIN_DEPLOY_HOST_APP=playground-clibeforebulletin-deploycan evaluate, then mapsDOT_TELEMETRY/internal-context detection toBULLETIN_DEPLOY_TELEMETRY. Do not leaveBULLETIN_DEPLOY_TELEMETRYunset while setting the host app:bulletin-deploy@0.7.6treatsplayground-clias an internal host, which would enable deploy telemetry for external users.BULLETIN_DEPLOY_MEM_REPORTis not forced off by default anymore because upstream guards the Bun-incompatible memory-report path. Any new long-running command should register a cleanup hook viaonProcessShutdown(). - 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 returnsnull. Adding a catch-allinfoemit turns the parser into a firehose that allocates ~200 bytes × thousands of lines, and was a measurable contributor to chunk-upload memory pressure. dot modis GitHub-tarball-only and must stay that way.src/utils/mod/source.tsdownloads fromcodeload.github.com(no auth, nogit/ghrequired for the public-repo case) and extracts vianode:zlib+ the pure-JStarpackage. Do NOT re-introducegit cloneorgh repo forkpaths — both would re-add a hard tooling requirement and the fork path was specifically removed because GitHub caps you to one fork per source-repo per account, which broke "mod the same starter twice." A non-modable app (nometadata.repository) returns a hard error fromdot mod; the interactive picker filters those out so the user never sees an unmoddable option.ensureGhAuthed()does NOT shell out togh auth login. Even from the interactive deploy, Ink owns stdout + raw-mode stdin and astdio: "inherit"child would race Ink'suseInputfor keystrokes — producing garbled output and dropped key events. Both interactive and non-interactive paths that need GitHub repo creation fail with the same actionable message: rungh auth loginonce outsidedot, then retry. ExistingoriginURLs do not requireghauth. Properly suspending Ink to hand off stdio is possible but requires unmounting + re-rendering with state preservation, and we deemed that complexity not worth it for a one-time-per-machine speedbump. If you ever do implement it, the wiring point isModablePreflightStageinsrc/commands/deploy/DeployScreen.tsx.metadata.repositoryis set ONLY when--modableis opted in. Older code inpublishToPlaygroundwould silently probegit remote get-url originand stuff whatever it found into the metadata, which surprised users who didn't realise their fork was being advertised. The new contract:runDeploytakes an explicitrepositoryUrl: string | null, andpublishToPlaygroundwrites the field iff that param is non-null. The CLI command is responsible for resolving the URL upstream viasrc/utils/deploy/modable.ts::resolveRepositoryUrl(), which uses an existingoriginURL without pushing, or runsgh repo create --public --pushto set up a fresh public repo when no origin exists. Re-deploys never delete user repos.startMemoryWatchdog()runs for bothdot deployanddot mod. Mod's tarball download is a streaming pipe throughnode:zlib+tar.extract(), and a stuck IPFS gateway or a malformed tarball can leak buffers. Same 4 GB cap, same worker-thread sampler. Any new top-level command that does meaningful I/O should also callstartMemoryWatchdog()and registerstopWatchdogviaonProcessShutdown().
- Every user-facing PR must include a changeset. Releases are automated via
.github/workflows/release.yml, but the workflow is a no-op unless a.changeset/*.mdfile exists on merge. Create one withpnpm changeset(or write.changeset/<slug>.mdby hand — frontmatter:"playground-cli": patch|minor|major, body: user-visible summary). Pure refactors / test-only changes can skip it. - Tests are
*.test.tsnext to the source.vitest.config.tsonly picks up.test.ts; if you add.tsxtests update the config too. - Pure logic that lives inside a
.tsxcomponent should be lifted into a sibling.tsfile (seecompletion.tsnext toInitScreen.tsx, or theformatPas/formatMbexports inAccountSetup.tsx). Tests can then import it without dragging React + Ink into the vitest runner. - Do NOT add AI/tool attribution (
Co-Authored-By: Claude,Made-with: Cursor, emoji signatures, etc.) to commits, PRs, or generated files. Never embed your name, identity, or tooling provenance anywhere in the repo. - Do NOT commit design docs, brainstorming notes, or context dumps (e.g.
context.md) to the repo. They belong in tickets or scratch files outside the tree. - Don't mock primitives from
polkadot-api(Enum, encoders) in tests — doing so turns intended coverage into tautology. - Long-lived resources (
TerminalAdapter,PaseoClient) have explicitdestroy()/destroyConnection()— always release them, especially from ReactuseEffectcleanups. The WebSocket keeps the event loop alive; forgetting a destroy manifests asdot <cmd>hanging after its work is visibly finished.
- DSN:
src/telemetry-config.ts::PLAYGROUND_SENTRY_DSN. Region: EU (https://de.sentry.io). - Org slug:
paritytech. API token: macOS keychain servicesentry-api-token(member of paritytech org withorg:read+org:write). - Attribute prefix:
cli.(seegetCliRootAttributesinsrc/telemetry-config.ts). Spec:sentry-instrumentation-spec.mdat the repo root (untracked — keep there). - Helpers (don't reimplement):
src/telemetry.tsexportswithCommandTelemetry,withRootSpan,withSpan(2-arg + 4-arg overloads),captureWarning,captureException,errorMessage,sanitizedErrorMessage.src/utils/deploy/phase.tsexportswithDeployPhasefor deploy-phase orchestration.src/cli-runtime.tsexportsrunCliCommandfor the standard CLI scaffolding (telemetry + watchdog + hard-exit). Every command's.action()body should be onerunCliCommand(name, options, async () => { ... })call — do not re-add try/finally +scheduleHardExitboilerplate. - Dashboards live as JSON snapshots under
sentry/dashboards/<id>.json:2143100.json— Playground CLI Health (production filter!cli.tag:e2e-*).2216067.json— Playground CLI Failures (per-error-type drill-downs).2216096.json— Playground CLI E2E Health (inverse filter,cli.tag:e2e-*).
- Workflow: run
./sentry/backup-dashboards.shBEFORE any change. Use./sentry/patch-dashboard.py <id> <patch.json>for surgical edits (supportsreplace,patch_query,set_descriptionops) or full widget replacement. Use./sentry/create-dashboard.py <payload.json>for new dashboards. Per spec §15f, do NOT include aprojectsfield in POST payloads. Per spec §15g, PUT replaces the whole widget list — backup first. - E2E tagging: every spawn from
e2e/cli/helpers/dot.tsinjectsDOT_TAG=e2e-local(fallback) andDOT_TELEMETRY=1.tools/e2e-local.shoverrides that toe2e-local-{smoke|pr|nightly}based on the mode argument. CI setsDOT_TAG=e2e-ci-{pr|nightly|dispatch}in.github/workflows/e2e.ymlso production health widgets filter cleanly via!cli.tag:e2e-*. - SAD% propagation is verified by a regression test in
src/telemetry.test.ts("SAD% propagation through transaction envelope"). It confirmscaptureWarningflipscli.sad="true"on the root transaction. If that test fails, the SAD% dashboard widget on Dashboard 1 will silently degrade to a duplicate of the unexpected-failure rate.
- Local launcher:
tools/e2e-local.sh [smoke|pr|nightly]— also callable viapnpm test:e2e:smoke,pnpm test:e2e:pr,pnpm test:e2e:nightly. - CI workflow:
.github/workflows/e2e.yml— runs on PR / push:main / cron 06:00 UTC / workflow_dispatch. - CI matrix: 13 cells across four matrices —
test-no-publish(parallel: pr-install, pr-preflight, pr-mod, pr-init-session) +test-publish(max-parallel: 1: pr-deploy-frontend, pr-deploy-foundry) +test-nightly-no-publish(parallel, schedule/dispatch only: nightly-mod-miss, nightly-diagnostic, nightly-rejections, nightly-chaos-sigint) +test-nightly-publish(max-parallel: 1, schedule/dispatch only: nightly-deploy-hardhat, nightly-deploy-multi, nightly-chaos-rpc). Each cell runs a subset viavitest -t "<pattern>". - Release smoke:
.github/workflows/e2e-release.ymlfires onrelease: prereleased, downloads thedot-linux-x64SEA asset, and runse2e/cli/published.test.tsagainst it. Validates the published binary before stable release. - Post-release smoke:
.github/workflows/e2e-post-release.ymlfires onrelease: published(stable only —prerelease != true), waits for the SEA asset, runsinstall.sh(consumer install path viaVERSION=<tag> curl … | bash), then runspublished.test.tsagainst the installed~/.polkadot/bin/dot. Catchesinstall.shregressions that the prerelease/SEA-download path doesn't. - Test files:
e2e/cli/*.test.ts(vitest, spawned viabun run src/index.ts). - Reports directory:
e2e-reports/junit.xml+e2e-reports/dot-runs.log(gitignored). - Tag prefix:
DOT_TAG=e2e-{ci|local}-{trigger}so Sentry dashboards filter test traffic. The CLI plumbsDOT_TAGinto thecli.tagroot-span attribute viasrc/telemetry-config.ts. - CI report job name:
E2E Report— aggregates per-leg conclusions, posts a sticky PR comment with marker<!-- e2e-pr-report -->, opens an auto-issue on schedule/release fail. - Bootstrap: see
tools/register-e2e-fixtures.tsfor the mod-test fixture; full bootstrap doc TBD in a later phase. - Design spec:
docs-internal/2026-05-02-e2e-test-suite-design.md.