console-cli: command-line interface for Polkadot Bulletin Chain
Summary
Build console-cli — a CLI tool that exposes every workflow console-ui offers (store, download, renew, authorize, query, faucet, status) — backed by @parity/bulletin-sdk. Starting point is the CLI half of #135 (with the web frontend stripped out). Scope, decisions, and risks below were vetted in a plan-review pass.
Goals
Cover every workflow console-ui supports, scriptable and headless.
Reuse @parity/bulletin-sdk as the single source of truth — no duplicated chain logic.
Same network configs as console-ui (Westend, Paseo, local, previewnet).
Two output modes: human-readable tables and --output json / NDJSON for piping.
Non-goals
No reactive state (RxJS) — CLI is request/response.
No browser wallet extensions.
No persistent history UI — defer to shell history + JSON output.
What already exists (reuse, don't rebuild)
Asset
Reuse plan
#135 examples/bulletin-helia/src/cli.ts
Promote to console-cli/src/commands/download.ts, wrap in commander
#135 src/ipfs.ts (319 lines)
Kept as Helia retrieval impl behind a new RetrievalClient interface
#135 logger-base.ts + logger-cli.ts
Adopted; replaces ora/chalk
#135 tsconfig.cli.json
Promoted to tsconfig.json
examples/authorize_and_store_papi*.js, store_*.js, cid_dag_metadata.js, native_ipfs_dag_pb_chunked_data.js, typescript/authorize_and_store.js
Logic patterns reused; scripts retired post-launch
console-ui/src/config/networks.ts
Copied into CLI (decision: copy over shared package — see below)
console-ui/src/state/storage.state.ts read patterns
Reference for query commands
@parity/bulletin-sdk
Sole interface to chain ops; CLI is a thin shell
examples/authorize_and_store_papi_smoldot.js
Preserved via --smoldot global flag
#135 's frontend half (index.html, main.ts, style.css, logger.ts, web tsconfig.json, vite deps) is dropped.
Out of scope
Shared packages/bulletin-config workspace package — chose to copy networks.ts (~50 lines, rarely changes; revisit on drift).
Full keystore (HD paths, lockout, OS keychain) — deferred to TODOs; Phase 2 ships an env/stdin signer.
bulletin daemon (keep-warm Helia/PAPI) — multi-CID download covers the batch case.
Streaming chunker — belongs in SDK; current 64 MiB ceiling acceptable.
Re-implementing examples/upgrade_runtime.js (operator-only set_code) and examples/typescript/export_*.js (bulk export) — kept as standalone scripts.
Design decisions
#
Decision
Rationale
D1
RetrievalClient interface defined in Phase 0; Helia wrapped behind it
Locks the seam now so smoldot retrieval can swap in cleanly when SDK ships it
D2
Copy networks.ts into CLI; no shared package
Minimal diff; avoids cross-cutting refactor of working console-ui
D3
Signer precedence: --account → BULLETIN_SEED env → stdin prompt. No --seed flag.
CLI flags leak via ps/shell history; remove the worst-leak path
D4
withClient(opts, async client => …) helper used by every command; retries on transient WS disconnect, fails on persistent
Deterministic teardown; prevents 12+ inline try/finally blocks; handles WS-drop mid-multi-step
D5
Retire 7 fully-covered examples/*.js scripts as their CLI command lands; keep 4 specialty scripts (smoldot demo, runtime upgrade, chopsticks check, bulk export)
One canonical implementation per workflow
D6
runCommand(commandFn) wrapper in Phase 1 — error → exit code, SIGINT cleanup, output mode switching
Avoids rewriting all commands in a final hardening phase
D7
Phase 2 ships env/stdin signer only; full keystore deferred to TODO
Hidden complexity (encryption, lockout, HD paths, file perms, concurrent access) doesn't fit a 0.5-day phase
D8
One print(obj, mode) formatter; commands return plain objects; per-command human override only when default tabulation looks bad
One formatter, no drift between human/JSON modes
D9
download --timeout <seconds> flag, default 30s
Without it, dead-peer dial hangs forever — silent failure in cron
D10
Detect process.stdin.isTTY === false; fail fast with "no signer; set BULLETIN_SEED"
Prevents silent stdin-prompt hang under cron/CI
D11
Unit-heavy + zombienet smoke tests per phase
Unit covers branching/error mapping; zombienet confirms tx + retrieval end-to-end
D12
Promise.all([...]) for independent chain reads in status / query
4× RTT → 1× RTT on Westend
D13
Validate file size before reading; clear error if >64 MiB SDK ceiling
Fail at boundary; capture streaming chunker as TODO in SDK
D14
Multi-CID download <cid1> <cid2> … form, batched via Bitswap wantlist (≤16 CIDs/request)
Protocol-level batching, not just one Helia init
Architecture
┌─────────────────────────────────────┐
│ bulletin <cmd> │
│ (commander root) │
└─────────────────┬───────────────────┘
│
runCommand() wrapper ← D6
│
┌──────────┬──────────┬───────────┼───────────┬─────────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
download store renew auth account query block prepare status
(P0) (P3) (P4) preimage tx cid auth (P3) (P1)
refresh (P1)
remove
(P4)
│
┌────────────────┐ │ ┌──────────────────┐
│ RetrievalClient│ ◄─ download │ withClient() │ signer.ts │
│ iface (P0) │ │ (PAPI WS) ← D4 │ env→stdin (P2) │
│ │ │ │ │ └──────────────────┘
│ ▼ │ │ ▼
│ ipfs.ts │ │ AsyncBulletinClient (SDK)
│ (Helia/P2P) │ │ .store / .renew / .authorize…
└────────────────┘ │ builders → tx submit → events
│
┌───────▼────────┐
│ ProgressEvent │
│ → logger-cli │
└────────────────┘
│
┌───────▼────────┐
│ print(obj,mode)│ ← D8
│ human / json │
└────────────────┘
│
┌─────────▼──────────┐
│ exit code mapping │
│ BulletinError → │
│ 64/69/75/… │
└────────────────────┘
Proposed layout
console-cli/
├── package.json # bin: { "bulletin": "./dist/cli.js" }
├── src/
│ ├── cli.ts # commander entry, registers all commands
│ ├── commands/
│ │ ├── store.ts # store (signed + preimage)
│ │ ├── download.ts # P2P fetch via RetrievalClient
│ │ ├── renew.ts
│ │ ├── auth/ # authorize / refresh / remove-expired
│ │ ├── query/ # cid, tx, authorization, block
│ │ ├── prepare.ts # offline CID/chunk preview
│ │ ├── faucet.ts # testnet auth grants
│ │ └── status.ts
│ ├── lib/
│ │ ├── client.ts # withClient() — D4
│ │ ├── runCommand.ts # error/exit-code/SIGINT wrapper — D6
│ │ ├── retrieval.ts # RetrievalClient interface — D1
│ │ ├── retrieval/helia.ts # lifted from #135's ipfs.ts
│ │ ├── signer.ts # env → stdin — D3
│ │ ├── output.ts # print(obj, mode) — D8
│ │ └── progress.ts # SDK ProgressEvent → logger-cli
│ ├── config/
│ │ └── networks.ts # copied from console-ui — D2
│ └── logger-base.ts, logger-cli.ts # from #135
└── test/
Phased plan
Phase 0 — Strip #135 frontend + retrieval interface
Cherry-pick examples: Fetch from Bulletin node directly via P2P #135 . Drop frontend (index.html, main.ts, style.css, logger.ts, web tsconfig, vite deps).
Move to console-cli/. Promote tsconfig.cli.json → tsconfig.json.
(D1) Define RetrievalClient interface; wrap ipfs.ts as HeliaRetrievalClient.
(D9) Add --timeout <seconds> flag to download, default 30s.
(D2) Copy networks.ts into CLI; add comments cross-referencing console-ui copy.
Wire commander skeleton; register download as first subcommand.
Exit criteria: bulletin download <cid> --network westend works without explicit multiaddrs; --timeout honored.
Phase 1 — Read-only queries + error/lifecycle wrappers
(D4) lib/client.ts exports withClient(opts, async client => …) — connects, retries on transient WS disconnect, tears down on success/throw/SIGINT.
(D6) lib/runCommand.ts wraps every commander action: parse → withClient → command → format → exit-code.
(D8) lib/output.ts exports print(obj, mode); default tabulation via cli-table3, JSON via stringify, NDJSON for streaming.
(D12) Promise.all([...]) for independent reads in status and query.
Commands: query block, query tx, query cid, query authorization, status.
Global flags: --network, --rpc, --account, --output {human|json}, --smoldot, --verbose.
Phase 2 — Simple signer
(D3) lib/signer.ts resolves: BULLETIN_SEED env → stdin prompt. No --seed flag.
(D10) Detect process.stdin.isTTY === false; fail fast.
BIP39 mnemonic validation via @polkadot/util-crypto.
Phase 3 — Storage uploads
bulletin store <file> with --codec, --hash, --chunk-size, --wait, --preimage.
(D13) Validate file size before reading; fail clearly if >64 MiB.
bulletin prepare <file> (offline BulletinPreparer).
progress.ts bridges SDK ProgressEvent → logger-cli; NDJSON in --output json.
(D5 — partial) Retire examples/authorize_and_store_papi*.js, store_*.js, cid_dag_metadata.js, native_ipfs_dag_pb_chunked_data.js, typescript/authorize_and_store.js.
Phase 4 — Renew + Authorization writes
renew <block> <index>.
auth account|preimage create / refresh / remove-expired, --sudo flag.
Phase 5 — Faucet + multi-CID download + UX polish
bulletin faucet.
(D14) Multi-CID download <cid1> <cid2> ... form, batched via Bitswap wantlist (≤16 CIDs/request); reuses one Helia init.
--interactive mode (@inquirer/prompts) for store / auth account.
Shell completion (bulletin completion bash|zsh|fish).
Phase 6 — Hardening
BulletinError → exit codes finalized (slots into runCommand from Phase 1).
Integration tests via zombienet (per D11).
README, man pages from commander.
Test plan
#
Codepath
Type
T1
runCommand: success / BulletinError → exit code / unknown → exit 1 / SIGINT cleanup
Unit
T2
withClient: connect, run, destroy on success and on throw; retry on transient WS disconnect
Unit
T3
RetrievalClient interface — Helia impl pluggability
Unit
T4
download — CID parse error path
Unit
T5
download — peer dial timeout (D9)
Integration
T6
download — raw + dag-pb fetch
Integration (zombienet)
T7
signer.ts — env → stdin precedence; non-tty fail-fast
Unit
T8
signer.ts — invalid mnemonic
Unit
T9
store — empty file → ErrorCode.EMPTY_DATA
Unit
T10
store — chunked path (>2 MiB), manifest CID
Integration
T11
store — tx timeout
Integration (chopsticks)
T12
store --preimage — unsigned tx
Integration
T13
auth account/preimage happy paths
Integration
T14
auth refresh/remove-expired both scopes
Integration
T15
--sudo flag wraps in sudo call
Unit
T16
query block/tx/cid/authorization reads
Integration
T17
query authorization for non-authorized address — clear "not authorized" UX
Unit
T18
prepare — offline CID matches what store would produce
Unit
T19
print(obj, mode) — human / JSON / NDJSON snapshots
Unit
T20
--output json — every command emits valid JSON to stdout, errors to stderr
Unit
T21
faucet — testnet endpoint contract
Integration
Failure modes
#
Failure
Test
Handling
User sees
F1
download — dead peer
T5
D9 --timeout 30s default
Clear error
F2
store — RPC drop mid-tx
T11
SDK txTimeout 7 min
Clear error after wait
F3
WS disconnect mid-multi-step
T2 (extended)
D4 transient retry
Clear error if persistent
F4
stdin prompt under non-tty
T7
D10 isTTY check
Clear error
F5
query auth for non-authorized address
T17
SDK returns null
"not authorized" message
F6
insufficient balance for fees
extend T13
SDK surfaces; D6 maps
Clear error
F7
FS read error mid-stream
extend unit
Node throws; runCommand catches
Visible
F8
--smoldot cold-sync (minutes)
n/a
doc only
Slow but not silent
No silent-failure gaps remain.
Code-comment ASCII diagrams (per repo convention)
File
Diagram
src/lib/client.ts
withClient lifecycle: connect → run → finally(destroy); transient retry path
src/lib/retrieval.ts
Helia today / smoldot tomorrow — interface seam
src/lib/runCommand.ts
Pipeline: parse → withClient → cmd → format → exit-code; SIGINT branch
src/commands/status.ts
Read fan-out: Promise.all([finalized, auth, balance])
Deferred
Full keystore — HD paths, lockout, OS keychain integration. MVP env/stdin signer ships first.
Smoldot retrieval via bitswap_block RPC — replaces Helia behind the same RetrievalClient interface when SDK ships retrieval.
console-cli: command-line interface for Polkadot Bulletin Chain
Summary
Build
console-cli— a CLI tool that exposes every workflowconsole-uioffers (store, download, renew, authorize, query, faucet, status) — backed by@parity/bulletin-sdk. Starting point is the CLI half of #135 (with the web frontend stripped out). Scope, decisions, and risks below were vetted in a plan-review pass.Goals
console-uisupports, scriptable and headless.@parity/bulletin-sdkas the single source of truth — no duplicated chain logic.console-ui(Westend, Paseo, local, previewnet).--output json/ NDJSON for piping.Non-goals
What already exists (reuse, don't rebuild)
examples/bulletin-helia/src/cli.tsconsole-cli/src/commands/download.ts, wrap in commandersrc/ipfs.ts(319 lines)RetrievalClientinterfacelogger-base.ts+logger-cli.tstsconfig.cli.jsontsconfig.jsonexamples/authorize_and_store_papi*.js,store_*.js,cid_dag_metadata.js,native_ipfs_dag_pb_chunked_data.js,typescript/authorize_and_store.jsconsole-ui/src/config/networks.tsconsole-ui/src/state/storage.state.tsread patternsquerycommands@parity/bulletin-sdkexamples/authorize_and_store_papi_smoldot.js--smoldotglobal flag#135's frontend half (
index.html,main.ts,style.css,logger.ts, webtsconfig.json, vite deps) is dropped.Out of scope
packages/bulletin-configworkspace package — chose to copynetworks.ts(~50 lines, rarely changes; revisit on drift).bulletin daemon(keep-warm Helia/PAPI) — multi-CID download covers the batch case.examples/upgrade_runtime.js(operator-onlyset_code) andexamples/typescript/export_*.js(bulk export) — kept as standalone scripts.Design decisions
RetrievalClientinterface defined in Phase 0; Helia wrapped behind itnetworks.tsinto CLI; no shared packageconsole-ui--account→BULLETIN_SEEDenv → stdin prompt. No--seedflag.ps/shell history; remove the worst-leak pathwithClient(opts, async client => …)helper used by every command; retries on transient WS disconnect, fails on persistentexamples/*.jsscripts as their CLI command lands; keep 4 specialty scripts (smoldot demo, runtime upgrade, chopsticks check, bulk export)runCommand(commandFn)wrapper in Phase 1 — error → exit code, SIGINT cleanup, output mode switchingprint(obj, mode)formatter; commands return plain objects; per-command human override only when default tabulation looks baddownload --timeout <seconds>flag, default 30sprocess.stdin.isTTY === false; fail fast with "no signer; set BULLETIN_SEED"Promise.all([...])for independent chain reads instatus/querydownload <cid1> <cid2> …form, batched via Bitswap wantlist (≤16 CIDs/request)Architecture
Proposed layout
Phased plan
Phase 0 — Strip #135 frontend + retrieval interface
index.html,main.ts,style.css,logger.ts, web tsconfig, vite deps).console-cli/. Promotetsconfig.cli.json→tsconfig.json.RetrievalClientinterface; wrapipfs.tsasHeliaRetrievalClient.--timeout <seconds>flag todownload, default 30s.networks.tsinto CLI; add comments cross-referencing console-ui copy.commanderskeleton; registerdownloadas first subcommand.Exit criteria:
bulletin download <cid> --network westendworks without explicit multiaddrs;--timeouthonored.Phase 1 — Read-only queries + error/lifecycle wrappers
lib/client.tsexportswithClient(opts, async client => …)— connects, retries on transient WS disconnect, tears down on success/throw/SIGINT.lib/runCommand.tswraps every commander action: parse → withClient → command → format → exit-code.lib/output.tsexportsprint(obj, mode); default tabulation via cli-table3, JSON via stringify, NDJSON for streaming.Promise.all([...])for independent reads instatusandquery.query block,query tx,query cid,query authorization,status.--network,--rpc,--account,--output {human|json},--smoldot,--verbose.Phase 2 — Simple signer
lib/signer.tsresolves:BULLETIN_SEEDenv → stdin prompt. No--seedflag.process.stdin.isTTY === false; fail fast.@polkadot/util-crypto.Phase 3 — Storage uploads
bulletin store <file>with--codec,--hash,--chunk-size,--wait,--preimage.bulletin prepare <file>(offlineBulletinPreparer).progress.tsbridges SDKProgressEvent→logger-cli; NDJSON in--output json.examples/authorize_and_store_papi*.js,store_*.js,cid_dag_metadata.js,native_ipfs_dag_pb_chunked_data.js,typescript/authorize_and_store.js.Phase 4 — Renew + Authorization writes
renew <block> <index>.auth account|preimagecreate / refresh / remove-expired,--sudoflag.Phase 5 — Faucet + multi-CID download + UX polish
bulletin faucet.download <cid1> <cid2> ...form, batched via Bitswap wantlist (≤16 CIDs/request); reuses one Helia init.--interactivemode (@inquirer/prompts) forstore/auth account.bulletin completion bash|zsh|fish).Phase 6 — Hardening
BulletinError→ exit codes finalized (slots intorunCommandfrom Phase 1).Test plan
runCommand: success / BulletinError → exit code / unknown → exit 1 / SIGINT cleanupwithClient: connect, run, destroy on success and on throw; retry on transient WS disconnectRetrievalClientinterface — Helia impl pluggabilitydownload— CID parse error pathdownload— peer dial timeout (D9)download— raw + dag-pb fetchsigner.ts— env → stdin precedence; non-tty fail-fastsigner.ts— invalid mnemonicstore— empty file → ErrorCode.EMPTY_DATAstore— chunked path (>2 MiB), manifest CIDstore— tx timeoutstore --preimage— unsigned txauth account/preimagehappy pathsauth refresh/remove-expiredboth scopes--sudoflag wraps in sudo callquery block/tx/cid/authorizationreadsquery authorizationfor non-authorized address — clear "not authorized" UXprepare— offline CID matches whatstorewould produceprint(obj, mode)— human / JSON / NDJSON snapshots--output json— every command emits valid JSON to stdout, errors to stderrfaucet— testnet endpoint contractFailure modes
--timeout30s defaulttxTimeout7 minNo silent-failure gaps remain.
Code-comment ASCII diagrams (per repo convention)
src/lib/client.tswithClientlifecycle: connect → run → finally(destroy); transient retry pathsrc/lib/retrieval.tssrc/lib/runCommand.tssrc/commands/status.tsPromise.all([finalized, auth, balance])Deferred
bitswap_blockRPC — replaces Helia behind the sameRetrievalClientinterface when SDK ships retrieval.