feat: sphere-cli phase 2 — legacy bridge + host DM transport#1
Merged
feat: sphere-cli phase 2 — legacy bridge + host DM transport#1
Conversation
added 11 commits
April 22, 2026 14:32
- Copy sphere-sdk/cli/{index,daemon,daemon-config}.ts → src/legacy/
- Rewrite all relative imports to @unicitylabs/sphere-sdk package paths
- Fix 8 TypeScript errors: flags→args.includes(), readonly mutations via
intermediate Record, inline import() → top-level type imports
- Prefix unused _sphere vars to satisfy no-unused-vars
- Wire all 15 legacy namespaces to dynamically import legacyMain()
- Phase 4 stubs (host, tenant) still show "not implemented yet"
- Update tests to reflect phase 2 behavior (legacy bridge, phase 4 stubs)
- npm run check: lint + typecheck + 8 tests all green
Dispatcher & argv (src/index.ts)
- buildLegacyArgv(): translate commander namespace+subcommand to the exact
legacy switch/case command the dispatcher expects; fixes 7 namespaces
(payments, group, market, swap, invoice, crypto, util) that previously
produced "Unknown command" on every invocation
- program.exitOverride(): commander now throws CommanderError instead of
calling process.exit(), making --help/--version safe in tests
- main() catch: handle CommanderError by code, sanitize error messages with
mnemonic-redaction regex before writing to stderr
Legacy CLI (src/legacy/legacy-cli.ts)
- legacyMain(argv: string[]): accept argv param; set module-level args/command
per-call so second invocations don't reuse frozen import-time state
- writeAtomic(): temp+rename pattern for saveConfig, saveProfiles, invoice-
export; prevents truncated files on SIGKILL mid-write
- isTTY guard on init mnemonic display: mnemonic suppressed (warning to stderr)
when stdout is not a terminal, preventing leak into CI logs or piped files
- isTTY guard on generate-key --unsafe-print: refuses to print private key to
non-TTY stdout
- stripDangerousKeys(): recursive prototype-pollution guard applied to all
user-supplied JSON before SDK entry (invoice-create, invoice-import)
- process.exit override: installs a shim in main() that calls sphere.destroy()
before exiting, covering all ~25 in-handler process.exit(1) calls without
touching each site; ensures Nostr WebSockets, IPFS, SQLite always close
- nametag catch: exits 1 on registration failure so scripts see the error
- mnemonic scrubbing comment: honest explanation that /proc/cmdline is NOT
cleared by mutating the args slice
Daemon (src/legacy/daemon.ts)
- writePidFileExclusive(): fs.openSync('wx') for exclusive create; prevents
two concurrent daemon starts both winning the PID file race
- PID file format: JSON {pid, nonce, cmd:'sphere-daemon'} enables /proc comm
check to detect PID reuse by unrelated processes (prevents SIGTERM to victim)
- earlyShutdown: SIGTERM/SIGINT registered before any await in runDaemon();
PID file cleanup even if signal arrives during getSphere() startup
- inflight Set: tracks dispatchRule promises; shutdown awaits allSettled with
10s timeout before closeSphere() — prevents token state corruption on stop
- logStream flush: log('Daemon stopped.') before end(), await finish event
- process.kill ESRCH: wrapped in try/catch so stopDaemon doesn't throw if
process died between liveness check and kill
- safeUnlink(): replaces existsSync+unlinkSync TOCTOU patterns everywhere
- child.stdin EPIPE: error handler added in executeBash to silence EPIPE when
command doesn't consume stdin
Config (src/legacy/daemon-config.ts)
- loadDaemonConfig: distinguishes ENOENT from parse errors; warns to stderr
on corrupt config rather than silently reverting to defaults
- BashAction.command: security notice added (chmod 600, trust boundary)
bin/sphere.mjs
- Always calls process.exit(code) so open Nostr sockets don't keep process alive
- Dev mode: resolve tsx from node_modules/.bin to avoid PATH injection
- Child process: listen on 'close' (not 'exit') to flush stdout before exiting
Adds src/transport/ with: - hmcp-types.ts: full HMCP-0 type definitions, constructors, and validator (parseHmcpResponse validates structure + dangerous keys + 64 KiB size limit) - errors.ts: TimeoutError, AuthError, TransportError - dm-transport.ts: DmTransport interface + createDmTransport factory * sendRequest: single-response with correlator + timeout * sendRequestStream: multi-response (spawn flow: ack → ready/failed) * sender auth via resolved pubkey (lazy on first send) * compressed (02/03) pubkey normalisation * dispose() cancels all in-flight requests - dm-transport.test.ts: 13 tests covering happy path, timeout, auth, multi-correlator, streaming, dispose, malformed/oversized/poisoned messages
Implements all 10 sphere-host subcommands as real HMCP-0 DM commands, replacing the phase-4 "not implemented yet" stub: sphere host spawn <name> --template <id> [--nametag <n>] [--env K=V...] sphere host list [--state <filter>] sphere host stop <name> [--id] sphere host start <name> [--id] sphere host inspect <name> [--id] sphere host cmd <name> <command> [--params <json>] [--cmd-timeout <ms>] sphere host remove <name> [--id] sphere host pause <name> [--id] sphere host resume <name> [--id] sphere host help Global options on the host parent: --manager, --json, --timeout. Manager address falls back to $SPHERE_HOST_MANAGER env var. Multi-step commands (spawn/start/resume) use sendRequestStream; others use sendRequest. Sphere.init from existing wallet (no autoGenerate). Errors set process.exitCode rather than calling process.exit().
…nings - hmcp-types: reviver now flags hadDangerousKeys and rejects the whole message instead of silently cleaning payload; isValidHmcpResponse no longer calls hasDangerousKeys (reviver catches it earlier) - hmcp-types: byteLength() uses Buffer.byteLength(utf8) so the 64 KiB cap counts bytes, not UTF-16 code units - dm-transport: early-message buffer (cap 32) + replay after resolvedPubkey set — fast manager replies no longer dropped during first sendDM - dm-transport: dispose() snapshots correlators before iterating to avoid map mutation during cancel() callbacks - host-commands: writeStderr accepts unknown; parseJsonParams rejects dangerous keys immediately; DEBUG env var logs cleanup errors - sphere-init: loadConfig() validates each field type individually instead of unsafe cast; logs a warning on parse failure before falling back - index: return process.exitCode when numeric so action-handler errors propagate correctly through bin/sphere.mjs → process.exit()
…edger - types.ts: TradingIntent (with salt), IntentRecord, DealTerms, DealRecord, TraderStrategy + DEFAULT_STRATEGY, adapter interfaces (MarketAdapter, SwapAdapter, PaymentsAdapter, CommsAdapter), NpMessage envelope - acp-types.ts: all 7 ACP command param/result interfaces (CREATE_INTENT, CANCEL_INTENT, LIST_INTENTS, GET_INTENT, UPDATE_STRATEGY, GET_STRATEGY, GET_DEALS) — ACP boundary uses number; bigint conversion at handler layer - utils.ts: validateIntent(), validateDealTerms(), canonicalJson(), encodeDescription/decodeDescription (4-line spec 2.8 format), hasDangerousKeys() with depth-20 limit - volume-reservation-ledger.ts: async mutex (promise-chain) on reserve(), reconstruct() for startup reconciliation, all bigint arithmetic
…int serialization
…r, test mocks - intent-engine.ts: full state machine (ACTIVE/PAUSED/CANCELLED/EXPIRED/FILLED), scan loop, feed subscription, 10-criteria matching filter, proposer selection (lower pubkey proposes), expiry sweep, monotonic intent updates, MatchEvent type - negotiation-handler.ts: NP-0 protocol (np.propose/accept/reject/cancel), auth validation (sig, ts_ms skew 300s, sender==participant, msg_id dedup 600s/10K), rate-limit 3 proposals/60s per counterparty, 64 KiB limit, dangerous-keys check, proposal 30s and acceptance 60s timeouts, CryptoAdapter interface - swap-executor.ts: ACCEPTED→EXECUTING→COMPLETED/FAILED, pingEscrow, deposit_attempted flag written before payInvoice, payout polling (30s×10), term binding on swap:proposal_received, V2 enforcement, EXECUTING timeout (deposit_timeout_sec+60s), sphere event subscriptions with unsubscribe tracking - test/mocks: MockMarket, MockSwap, MockPayments, MockComms with vi.fn() controls; fixtures.ts with makeIntent, makeIntentRecord, makeDealTerms, makeDealRecord, makeStrategy factories
…sting, not sphere-cli)
…lves PR #1 CI failed with: Cannot find module '@unicitylabs/sphere-sdk' or its corresponding type declarations. src/legacy/legacy-cli.ts#12 Root cause: package.json references sphere-sdk via `file:../../sphere-sdk`, which works locally but doesn't exist on the CI runner. Switching to the npm-published @unicitylabs/sphere-sdk@0.7.0 fails a different way — that version lacks several invoice-related type exports (CreateInvoiceRequest, PayInvoiceParams, GetInvoicesOptions, ReturnPaymentParams, and ~58 other errors) that were added AFTER the 0.7.0 tag in sphere-sdk commit bc07e89 (the CLI-extraction commit that promoted previously-internal types to the public surface so the extracted CLI could consume them). Fix: before `npm ci`, clone sphere-sdk to ../../sphere-sdk (public repo, anonymous clone works on GitHub-hosted runners) and build it so the file: dependency has a populated dist/. No sphere-cli source changes required. Long-term: when sphere-sdk publishes v0.7.1 to npm including the post-extraction exports, swap the package.json dependency to the published version and remove this CI workaround. The comment block at the top of the workflow documents this follow-up.
vrogojin
pushed a commit
that referenced
this pull request
Apr 23, 2026
…lves PR #1 CI failed with: Cannot find module '@unicitylabs/sphere-sdk' or its corresponding type declarations. src/legacy/legacy-cli.ts#12 Root cause: package.json references sphere-sdk via `file:../../sphere-sdk`, which works locally but doesn't exist on the CI runner. Switching to the npm-published @unicitylabs/sphere-sdk@0.7.0 fails a different way — that version lacks several invoice-related type exports (CreateInvoiceRequest, PayInvoiceParams, GetInvoicesOptions, ReturnPaymentParams, and ~58 other errors) that were added AFTER the 0.7.0 tag in sphere-sdk commit bc07e89 (the CLI-extraction commit that promoted previously-internal types to the public surface so the extracted CLI could consume them). Fix: before `npm ci`, clone sphere-sdk to ../../sphere-sdk (public repo, anonymous clone works on GitHub-hosted runners) and build it so the file: dependency has a populated dist/. No sphere-cli source changes required. Long-term: when sphere-sdk publishes v0.7.1 to npm including the post-extraction exports, swap the package.json dependency to the published version and remove this CI workaround. The comment block at the top of the workflow documents this follow-up.
added 5 commits
April 24, 2026 08:13
1. MAJOR: `sphere host spawn --env KEY=VAL` variadic bug Commander treats `<KEY=VAL...>` as variadic, greedily consuming every subsequent non-flag token — including the positional `<name>` argument. So `sphere host spawn --template tpl-1 --env A=1 B=2 mybot` fails with "missing required argument 'name'" because `B=2` and `mybot` are swallowed as env values. Drop the `...`; the argParser already accumulates across repeated `--env` flags (`--env A=1 --env B=2`). 2. MODERATE: CI pin on moving branch name → pin to commit SHA Cloning `refactor/extract-cli-to-sphere-cli` with --depth 1 is a moving ref. A force-push / rebase silently changes the code CI builds against. Pin to the tip SHA 86468103 with `git checkout --detach`. Bump the env var when a new sphere-sdk commit is required. 3. MODERATE: README said "Phase 1 scaffold" — stale PR lands phase 2 legacy bridge + live `sphere host`. Rewrote the Status section with a "What works today" table and a Quickstart snippet showing both legacy (`sphere wallet init`) and DM-native (`sphere host list/spawn`) flows.
- parseGlobalOpts → Command.optsWithGlobals() — deletes the hand-rolled
parent-chain walker in favor of commander 12's built-in. Kept as a
named wrapper so call sites stay readable.
- Extract TRANSPORT_HEADROOM_MS = 10_000 constant. Replaces the inline
magic number `+5_000` in handleCmd; doubled to 10s to cover realistic
public-relay RTT. Reasoning documented in the constant's JSDoc.
- Add per-type payload guards (isHmSpawnAckPayload, isHmListResultPayload,
etc.) so a misbehaving manager's malformed payload produces a clear
"manager returned malformed <type> payload" error instead of silently
printing "undefined" in formatted output. New onProtocolError() helper
owns the stderr format + exitCode=1 on guard-failure paths.
- Extract runStreamingLifecycle() helper used by handleSpawn/Start/Resume.
Reduces ~180 lines of repeated "collect → dispatch per response type"
to three compact call sites. A future bug fix in the streaming loop
now applies to all three commands at once.
- Uniform stderr prefix: writeStderr always prefixes `sphere host: `
(skipping if already present). main() in index.ts reserves the bare
`sphere: ` prefix for errors that reach there (parse errors, commander
throws). Removes the prior inconsistency where some paths leaked raw
messages without a tool prefix.
- Reset process.exitCode = 0 at entry to main(). The prior flow carried
exitCode across repeated in-process invocations (vitest's default).
Production is unaffected (bin/sphere.mjs calls main() once per process),
but the reset prevents tests from seeing ambient state from prior runs
— and makes future test-harness imports of main() work correctly.
- Tighten the redaction regex in main() to require tokens of length 3-8
(matches BIP-39 shape more precisely; less false-positive prone on
stack traces with short identifiers). Added defensive-in-depth note
on the expectation that this regex should never actually fire.
- Show inherited options (--manager/--json/--timeout) in every `sphere
host <subcommand> --help` output. Previously discoverable only from
`sphere host --help`. Implemented via post-construction
addHelpText('after', …) so new subcommands inherit automatically.
- eslint config: ignore .claude/** (agent-worktree scratch directories).
…d-side size cap Addresses remaining security-audit and code-review follow-ups: - hasDangerousKeys: recursive walk → iterative with explicit stack + MAX_PARAMS_DEPTH=64 cap. Prior implementation could blow the JS interpreter stack on a pathological 10k-deep `--params` payload, producing a confusing RangeError instead of the clear "forbidden keys" message. Too-deep input is now conservatively rejected as if it contained a dangerous key. - safeParse: return `value: null` when dangerous keys are seen. Callers that forget to check `hadDangerousKeys` cannot accidentally use a half-stripped object. parseHmcpResponse still short-circuits on the flag; this is defense-in-depth. - dm-transport: send-side MAX_MESSAGE_SIZE check. Symmetric with the receive-side guard in parseHmcpResponse. Prevents `sphere host cmd --params '<huge JSON>'` from handing a 10 MB payload to the relay and getting an opaque TransportError in response. - dm-transport handleIncoming: short-circuit when `disposed` is true. Closes the race between setting `this.disposed = true` and the async `unsubscribeDMs()` returning. - dm-transport handleIncoming: early byte-size guard before any other work — a 10 MB garbage DM never enters the early-message buffer. - dm-transport: log early-message overflow at DEBUG so a chatty manager during the handshake window is diagnosable. - byteLength: exported from hmcp-types so the transport can symmetrize size checks across send and receive paths. Tests: +25 new - host-commands.test.ts: parseEnvPairs, parseJsonParams, parseTimeout, targetPayload, and the --env variadic-bug regression test verifying that `--env FOO=1 --env BAR=2 mybot` captures both env pairs AND the positional name (the exact failure mode from the pre-merge review). - parseJsonParams depth-bound test with 200-level nested input — asserts we reject with "forbidden keys" before stack overflow. - dm-transport.test.ts: send-side MAX_MESSAGE_SIZE rejection case. - inherited-options help test: captures outputHelp() stdout to verify --manager/--json/--timeout are visible in every subcommand's --help (helpInformation() doesn't emit afterHelp events; outputHelp() does). All 46 tests pass; typecheck clean; lint clean on src/host + src/transport.
Two comments addressing low-priority notes from the pre-merge review:
* src/index.ts: explain that `await import('./legacy/legacy-cli.js')` is
intentional — keeps the ~40-file legacy dispatcher out of the hot start
path for phase-4 DM-native commands (`sphere host …`) that don't need it.
* src/host/sphere-init.ts: document that all config paths are CWD-relative
by design to share a wallet with `sphere wallet …` invocations.
eslint's consistent-type-imports flagged the value import since Command is only used as a type annotation in 'this: Command'. CI fails on error.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
wallet,balance,payments,dm,group,market,swap,invoice,nametag,crypto,util,faucet,daemon,config,completions) delegated to the sphere-sdk legacy dispatcher viabuildLegacyArgv()translationsrc/transport/):DmTransportcorrelator-map pattern over Sphere DMs; lazy pubkey resolution; early-message buffer + replay; full test suite (13 tests)sphere hostcommand tree (src/host/): 10 HMCP-0 subcommands (spawn,list,stop,start,inspect,remove,pause,resume,help,cmd) wired as DM-native commander subcommandsTest plan
npx tsc --noEmit— passes cleannpx vitest run— 21/21 tests passsphere wallet status— delegates to legacy sphere-sdk CLIsphere host list --manager @mymanager— sends HMCP hm.list DMsphere host spawn --manager @mymanager --name mybot tpl-1— sends hm.spawn, streams ack+readysphere --version— outputs package version