diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceb6c76..abf9ec6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,16 @@ on: pull_request: branches: [main] +# sphere-cli consumes @unicitylabs/sphere-sdk via `file:../../sphere-sdk` +# because the CLI-extraction commit in sphere-sdk (which promoted types +# like CreateInvoiceRequest, PayInvoiceParams to the public exports) +# landed AFTER the sphere-sdk v0.7.0 npm release. Until a new sphere-sdk +# version is published, CI clones the sibling repo into the expected +# `../../sphere-sdk` location and builds it in-place so the `file:` +# dependency resolves correctly. +# +# Swap to an npm-published version once sphere-sdk v0.7.1+ ships. + jobs: lint-typecheck-test: name: lint + typecheck + test @@ -15,16 +25,49 @@ jobs: node-version: [20.x, 22.x] steps: - uses: actions/checkout@v4 + # Default path: $GITHUB_WORKSPACE/ + # = /home/runner/work/sphere-cli/sphere-cli/ + # package.json's `file:../../sphere-sdk` resolves from there to + # /home/runner/work/sphere-sdk/ — that's where we clone below. + - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: npm + + - name: Clone sphere-sdk sibling + # Pin to a specific commit SHA (not a branch name) for supply-chain + # integrity — a branch pointer can be force-pushed or rebased, + # silently changing the code CI builds against. This SHA points at + # the tip of `refactor/extract-cli-to-sphere-cli` at the time of + # this commit; that branch contains `bc07e89 feat(cli-extraction)` + # which promoted CLI-consumed types (CreateInvoiceRequest, + # PayInvoiceParams, encrypt/decrypt helpers, etc.) to the public + # module surface. Those exports have not yet landed on `main` or + # in any published npm version. + # + # Bump this SHA when a new sphere-sdk commit is required; remove + # this whole workaround once sphere-sdk publishes v0.7.1+ to npm + # with the post-extraction exports. + env: + SPHERE_SDK_SHA: 86468103ac25271b96a338f64349dd0eb472689f + run: | + git clone https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk + git -C ../../sphere-sdk checkout --detach "$SPHERE_SDK_SHA" + + - name: "Build sphere-sdk (required for file: dependency to resolve types)" + run: | + cd ../../sphere-sdk + npm ci + npm run build + - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm run test - run: npm run build + - name: Verify bin shim is executable run: | chmod +x bin/sphere.mjs diff --git a/README.md b/README.md index 1383cf8..3e502f2 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,29 @@ The unified CLI for the Sphere SDK and agentic-hosting control — DM-native. ## Status -**Phase 1 scaffold.** Real commands are not wired yet. `sphere --help` shows -the full command topology from the migration plan so you can see what's coming. +**Phase 2 — legacy bridge + live host commands.** The full wallet / balance / +payments / dm / market / swap / invoice / nametag / crypto / util / faucet / +daemon / config / completions surface is wired via the legacy sphere-sdk +dispatcher. The DM-native `sphere host` namespace (HMCP-0 over Sphere DMs) is +live and production-ready. Phase 4 (`sphere tenant` — ACP over DMs) remains +stubbed and exits with a pointer to the migration schedule. See [`docs/SPHERE-CLI-EXTRACTION-PLAN.md`](https://github.com/unicity-sphere/sphere-sdk/blob/refactor/extract-cli-to-sphere-cli/docs/SPHERE-CLI-EXTRACTION-PLAN.md) for the full migration plan (same doc lives in `agentic-hosting` under the parallel refactor branch). +### What works today + +| Namespace | Status | Notes | +|---|---|---| +| `sphere wallet` | legacy bridge | list, use, create, current, delete, init, status | +| `sphere balance` / `payments` / `dm` / `group` | legacy bridge | | +| `sphere market` / `swap` / `invoice` | legacy bridge | | +| `sphere nametag` / `crypto` / `util` / `faucet` | legacy bridge | | +| `sphere daemon` / `config` / `completions` | legacy bridge | | +| `sphere host` | **DM-native (live)** | HMCP-0: spawn, list, stop, start, inspect, remove, pause, resume, help, cmd | +| `sphere tenant` | Phase 4 (stub) | Exits with scheduled message | + ## Install ```bash @@ -22,10 +38,14 @@ npm install -g @unicity-sphere/cli ```bash sphere --help sphere --version -``` -Phase 1 provides only scaffolding. Invoking any namespace (e.g. `sphere wallet`) -prints a pointer to the migration schedule and exits non-zero. +# Legacy bridge example +sphere wallet init --network testnet + +# DM-native host example +sphere host list --manager @myhostmanager +sphere host spawn --manager @myhostmanager --template tpl-1 mybot +``` ## Development diff --git a/bin/sphere.mjs b/bin/sphere.mjs index 3d369ed..247fbe7 100755 --- a/bin/sphere.mjs +++ b/bin/sphere.mjs @@ -16,15 +16,24 @@ const srcEntry = resolve(__dirname, '../src/index.ts'); if (existsSync(distEntry)) { const mod = await import(distEntry); const code = await mod.main(process.argv); - if (code !== 0) process.exit(code); + // Always exit explicitly so open handles (Nostr sockets, timers) don't keep + // the process alive after the command finishes. Daemon commands manage their + // own keep-alive via event loop references and never return from main(). + process.exit(code); } else if (existsSync(srcEntry)) { - // Dev mode: delegate to tsx (must be installed). + // Dev mode: resolve tsx from local node_modules to avoid PATH injection. const { spawn } = await import('node:child_process'); - const child = spawn('npx', ['tsx', srcEntry, ...process.argv.slice(2)], { + const tsxBin = resolve(__dirname, '../node_modules/.bin/tsx'); + const tsxEntry = existsSync(tsxBin) ? tsxBin : 'npx tsx'; + const [cmd, ...cmdArgs] = tsxEntry.includes(' ') + ? ['npx', 'tsx'] + : [tsxBin]; + const child = spawn(cmd, [...cmdArgs, srcEntry, ...process.argv.slice(2)], { stdio: 'inherit', }); - child.on('exit', (code) => process.exit(code ?? 0)); + // Use 'close' (not 'exit') so all child stdio flushes before we exit. + child.on('close', (code) => process.exit(code ?? 0)); } else { - console.error('sphere: no entry found. Did you run `npm run build`?'); + process.stderr.write('sphere: no entry found. Did you run `npm run build`?\n'); process.exit(1); } diff --git a/eslint.config.js b/eslint.config.js index a9be6a5..630aec1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,14 @@ import globals from 'globals'; export default tseslint.config( { - ignores: ['dist/**', 'node_modules/**', 'coverage/**', '**/*.d.ts'], + ignores: [ + 'dist/**', + 'node_modules/**', + 'coverage/**', + '**/*.d.ts', + // Agent-worktree scratch directories from Claude Code — not source. + '.claude/**', + ], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/package-lock.json b/package-lock.json index 74ea354..5e6a7a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { + "@unicitylabs/sphere-sdk": "file:../../sphere-sdk", "commander": "^12.1.0" }, "bin": { @@ -32,6 +33,78 @@ "node": ">=20.0.0" } }, + "../../sphere-sdk": { + "name": "@unicitylabs/sphere-sdk", + "version": "0.7.0", + "license": "MIT", + "dependencies": { + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@unicitylabs/nostr-js-sdk": "^0.4.1", + "@unicitylabs/state-transition-sdk": "1.6.1-rc.f37cb85", + "bip39": "^3.1.0", + "buffer": "^6.0.3", + "canonicalize": "^3.0.0", + "crypto-js": "^4.2.0", + "elliptic": "^6.6.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@libp2p/bootstrap": "^12.0.11", + "@libp2p/crypto": "^5.1.13", + "@libp2p/interface": "^3.1.0", + "@libp2p/peer-id": "^6.0.4", + "@types/crypto-js": "^4.2.2", + "@types/elliptic": "^6.4.18", + "@types/node": "^22.0.0", + "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^9.39.2", + "fake-indexeddb": "^6.2.5", + "multiformats": "^13.4.2", + "testcontainers": "^11.11.0", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "~5.6.0", + "typescript-eslint": "^8.54.0", + "vitest": "^2.0.0", + "ws": "^8.18.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@libp2p/crypto": "^5.1.13", + "@libp2p/peer-id": "^6.0.4", + "ipns": "^10.0.0", + "multiformats": "^13.4.2" + }, + "peerDependencies": { + "@libp2p/crypto": ">=5.0.0", + "@libp2p/peer-id": ">=6.0.0", + "ipns": ">=10.0.0", + "multiformats": ">=13.0.0", + "ws": ">=8.0.0" + }, + "peerDependenciesMeta": { + "@libp2p/crypto": { + "optional": true + }, + "@libp2p/peer-id": { + "optional": true + }, + "ipns": { + "optional": true + }, + "multiformats": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1402,6 +1475,10 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unicitylabs/sphere-sdk": { + "resolved": "../../sphere-sdk", + "link": true + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", diff --git a/package.json b/package.json index 9ffecdd..4872366 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "prepublishOnly": "npm run check && npm run build" }, "dependencies": { + "@unicitylabs/sphere-sdk": "file:../../sphere-sdk", "commander": "^12.1.0" }, "devDependencies": { diff --git a/src/host/host-commands.test.ts b/src/host/host-commands.test.ts new file mode 100644 index 0000000..a9e05b5 --- /dev/null +++ b/src/host/host-commands.test.ts @@ -0,0 +1,207 @@ +/** + * Pure-function tests for `sphere host` helpers — no DM transport, no real + * Sphere. Exercises the exported parsers (parseEnvPairs, parseJsonParams, + * parseTimeout, targetPayload) and the commander wiring around + * createHostCommand. + */ + +import { describe, it, expect } from 'vitest'; +import type { Command } from 'commander'; +import { + parseEnvPairs, + parseJsonParams, + parseTimeout, + targetPayload, + createHostCommand, +} from './host-commands.js'; + +describe('parseEnvPairs', () => { + it('returns undefined when no pairs supplied', () => { + expect(parseEnvPairs(undefined)).toBeUndefined(); + expect(parseEnvPairs([])).toBeUndefined(); + }); + + it('parses a single KEY=VALUE', () => { + expect(parseEnvPairs(['FOO=1'])).toEqual({ FOO: '1' }); + }); + + it('accumulates across multiple --env flags', () => { + expect(parseEnvPairs(['FOO=1', 'BAR=2'])).toEqual({ FOO: '1', BAR: '2' }); + }); + + it('preserves `=` inside the value', () => { + expect(parseEnvPairs(['URL=postgres://user:pass=secret@host/db'])) + .toEqual({ URL: 'postgres://user:pass=secret@host/db' }); + }); + + it('trims whitespace in the key but not the value', () => { + expect(parseEnvPairs([' FOO =bar '])) + .toEqual({ FOO: 'bar ' }); + }); + + it('throws on missing `=`', () => { + expect(() => parseEnvPairs(['no-equals'])).toThrow(/expected KEY=VALUE/); + }); + + it('throws on empty key', () => { + expect(() => parseEnvPairs(['=bar'])).toThrow(/expected KEY=VALUE/); + }); +}); + +describe('parseJsonParams', () => { + it('returns undefined for undefined input', () => { + expect(parseJsonParams(undefined)).toBeUndefined(); + }); + + it('parses a flat JSON object', () => { + expect(parseJsonParams('{"a":1,"b":"x"}')).toEqual({ a: 1, b: 'x' }); + }); + + it('throws on invalid JSON', () => { + expect(() => parseJsonParams('{not-json')).toThrow(/Invalid --params JSON/); + }); + + it('throws when value is not a JSON object', () => { + expect(() => parseJsonParams('[1,2,3]')).toThrow(/must be a JSON object/); + expect(() => parseJsonParams('"string"')).toThrow(/must be a JSON object/); + expect(() => parseJsonParams('42')).toThrow(/must be a JSON object/); + expect(() => parseJsonParams('null')).toThrow(/must be a JSON object/); + }); + + it('rejects __proto__ at top level', () => { + expect(() => parseJsonParams('{"__proto__":{"polluted":true}}')) + .toThrow(/forbidden keys/); + }); + + it('rejects nested __proto__', () => { + expect(() => parseJsonParams('{"a":{"b":{"__proto__":{"x":1}}}}')) + .toThrow(/forbidden keys/); + }); + + it('rejects constructor and prototype at any depth', () => { + expect(() => parseJsonParams('{"constructor":{}}')).toThrow(/forbidden keys/); + expect(() => parseJsonParams('{"a":{"prototype":{}}}')).toThrow(/forbidden keys/); + }); + + it('rejects deeply-nested JSON via explicit depth cap (no stack overflow)', () => { + // Build 200-deep nesting — well past MAX_PARAMS_DEPTH (64). The hardened + // hasDangerousKeys uses an explicit stack + depth bound, returning true + // ("too deep to inspect — conservative reject") before blowing the + // interpreter stack. + let deep = '{}'; + for (let i = 0; i < 200; i++) deep = `{"a":${deep}}`; + expect(() => parseJsonParams(deep)).toThrow(/forbidden keys/); + }); +}); + +describe('parseTimeout', () => { + it('falls back when input is undefined', () => { + expect(parseTimeout(undefined, 5000)).toBe(5000); + }); + + it('parses a valid positive integer', () => { + expect(parseTimeout('1000', 5000)).toBe(1000); + }); + + it('floors a decimal', () => { + expect(parseTimeout('1234.7', 5000)).toBe(1234); + }); + + it('throws on zero, negative, NaN, Infinity', () => { + expect(() => parseTimeout('0', 5000)).toThrow(/Invalid timeout/); + expect(() => parseTimeout('-100', 5000)).toThrow(/Invalid timeout/); + expect(() => parseTimeout('abc', 5000)).toThrow(/Invalid timeout/); + expect(() => parseTimeout('Infinity', 5000)).toThrow(/Invalid timeout/); + }); +}); + +describe('targetPayload', () => { + it('uses instance_name by default', () => { + expect(targetPayload('mybot', {})).toEqual({ instance_name: 'mybot' }); + }); + + it('uses instance_id when --id is set', () => { + expect(targetPayload('550e8400-e29b-41d4-a716-446655440000', { id: true })) + .toEqual({ instance_id: '550e8400-e29b-41d4-a716-446655440000' }); + }); +}); + +describe('createHostCommand --env variadic-bug regression test', () => { + // Ship-blocker from pre-merge review: `--env ` (with ellipsis) + // greedily consumed every subsequent non-flag arg, including the positional + // . After the fix, `--env ` (single-arg) + argParser + // accumulator handles multi-env via repetition. The positional `name` + // must not be swallowed. + + it('`--env FOO=1 --env BAR=2 mybot` captures both env pairs AND name', () => { + const host = createHostCommand(); + host.exitOverride(); // commander should not call process.exit during the test + + let capturedName: string | undefined; + let capturedEnv: string[] | undefined; + + // Replace the spawn action to capture parsed values instead of dialling + // out to the real DM transport. + const spawn = host.commands.find((c) => c.name() === 'spawn')!; + spawn.action(function (this: Command, name: string, opts: { env?: string[] }) { + capturedName = name; + capturedEnv = opts.env; + }); + + host.parse( + ['node', 'host', 'spawn', '--template', 'tpl-1', '--env', 'FOO=1', '--env', 'BAR=2', 'mybot'], + { from: 'node' }, + ); + + expect(capturedName).toBe('mybot'); + expect(capturedEnv).toEqual(['FOO=1', 'BAR=2']); + }); + + it('`--env FOO=1 mybot` captures env + name (single --env form)', () => { + const host = createHostCommand(); + host.exitOverride(); + + let capturedName: string | undefined; + let capturedEnv: string[] | undefined; + + const spawn = host.commands.find((c) => c.name() === 'spawn')!; + spawn.action(function (this: Command, name: string, opts: { env?: string[] }) { + capturedName = name; + capturedEnv = opts.env; + }); + + host.parse( + ['node', 'host', 'spawn', '--template', 'tpl-1', '--env', 'FOO=1', 'mybot'], + { from: 'node' }, + ); + + expect(capturedName).toBe('mybot'); + expect(capturedEnv).toEqual(['FOO=1']); + }); +}); + +describe('createHostCommand inherited-options help', () => { + // Commander's addHelpText('after', ...) attaches via the `afterHelp` event, + // which fires during outputHelp() (stdout) — not via helpInformation() which + // only returns the core Usage/Options block. Capture stdout to verify. + it('each subcommand --help renders the shared --manager/--json/--timeout options', () => { + const host = createHostCommand(); + for (const sub of host.commands) { + let captured = ''; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array): boolean => { + captured += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + return true; + }) as typeof process.stdout.write; + try { + sub.outputHelp(); + } finally { + process.stdout.write = origWrite; + } + expect(captured, `subcommand "${sub.name()}" should render inherited --manager`) + .toContain('--manager'); + expect(captured).toContain('--json'); + expect(captured).toContain('--timeout'); + } + }); +}); diff --git a/src/host/host-commands.ts b/src/host/host-commands.ts new file mode 100644 index 0000000..f755aaf --- /dev/null +++ b/src/host/host-commands.ts @@ -0,0 +1,752 @@ +/** + * `sphere host` Commander subcommand tree — HMCP-0 client over Sphere DMs. + */ + +import { Command, Option } from 'commander'; +import type { Sphere } from '@unicitylabs/sphere-sdk'; +import { createDmTransport } from '../transport/dm-transport.js'; +import { createHmcpRequest } from '../transport/hmcp-types.js'; +import { TimeoutError, TransportError } from '../transport/errors.js'; +import type { DmTransport } from '../transport/dm-transport.js'; +import type { + HmcpRequest, + HmcpRequestType, + HmcpResponse, + HmCommandPayload, + HmCommandResultPayload, + HmErrorPayload, + HmHelpResultPayload, + HmInspectResultPayload, + HmInstanceSummary, + HmListResultPayload, + HmSpawnAckPayload, + HmSpawnFailedPayload, + HmSpawnReadyPayload, + HmStartAckPayload, + HmStartFailedPayload, + HmStartReadyPayload, + HmStopResultPayload, +} from '../transport/hmcp-types.js'; +import { hasDangerousKeys } from '../transport/hmcp-types.js'; +import { initSphere, resolveManagerAddress } from './sphere-init.js'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Transport-layer timeout headroom past a caller-supplied per-command + * timeout. The tenant's command handler honours `--cmd-timeout`; we need + * the transport to wait at least that long PLUS round-trip for the reply + * to travel back. 10 s covers relay RTT under typical public-relay load. + */ +const TRANSPORT_HEADROOM_MS = 10_000; + +// ============================================================================= +// Option types +// ============================================================================= + +interface GlobalOpts { + manager?: string; + json?: boolean; + timeout?: string; +} + +interface NameOrIdOpts { + id?: boolean; +} + +interface SpawnOpts { + template: string; + nametag?: string; + env?: string[]; +} + +interface ListOpts { + state?: string; +} + +interface CmdOpts extends NameOrIdOpts { + params?: string; + cmdTimeout?: string; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function parseGlobalOpts(cmd: Command): GlobalOpts { + // Commander 12's optsWithGlobals() walks the parent chain and merges + // — replaces an earlier hand-rolled walker. Kept as a named helper so + // call sites still read well and we have a single swap point for + // future option additions. + return cmd.optsWithGlobals(); +} + +function parseTimeout(raw: string | undefined, fallback: number): number { + if (!raw) return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + throw new Error(`Invalid timeout: ${raw}`); + } + return Math.floor(n); +} + +function parseEnvPairs(pairs: readonly string[] | undefined): Record | undefined { + if (!pairs || pairs.length === 0) return undefined; + const out: Record = {}; + for (const raw of pairs) { + const eq = raw.indexOf('='); + if (eq <= 0) { + throw new Error(`Invalid --env value "${raw}" (expected KEY=VALUE).`); + } + const key = raw.slice(0, eq).trim(); + const value = raw.slice(eq + 1); + if (!key) { + throw new Error(`Invalid --env value "${raw}" (empty key).`); + } + out[key] = value; + } + return out; +} + +function parseJsonParams(raw: string | undefined): Record | undefined { + if (!raw) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Invalid --params JSON: ${(err as Error).message}`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('--params must be a JSON object.'); + } + if (hasDangerousKeys(parsed)) { + throw new Error('--params contains forbidden keys (__proto__, constructor, prototype).'); + } + return parsed as Record; +} + +function targetPayload(nameOrId: string, opts: NameOrIdOpts): Record { + return opts.id ? { instance_id: nameOrId } : { instance_name: nameOrId }; +} + +function isErrorResponse(res: HmcpResponse): boolean { + return res.type === 'hm.error'; +} + +function errorPayload(res: HmcpResponse): HmErrorPayload { + return res.payload as unknown as HmErrorPayload; +} + +// ============================================================================= +// Per-type payload guards +// ============================================================================= +// +// The HMCP envelope is validated by isValidHmcpResponse (hmcp-types.ts), but +// the payload shape is not — a misbehaving manager could send any fields. The +// guards below do a minimal structural check before the handler reads fields, +// so a protocol drift produces a clear error instead of "undefined" surfacing +// in a printed string. They intentionally check only the fields the handler +// uses, not every documented field. + +function isHmSpawnAckPayload(p: unknown): p is HmSpawnAckPayload { + return isPlainObject(p) + && typeof p['instance_id'] === 'string' + && typeof p['state'] === 'string'; +} +function isHmSpawnReadyPayload(p: unknown): p is HmSpawnReadyPayload { + return isPlainObject(p) + && typeof p['tenant_direct_address'] === 'string'; +} +function isHmSpawnFailedPayload(p: unknown): p is HmSpawnFailedPayload { + return isPlainObject(p) && typeof p['reason'] === 'string'; +} +function isHmStartAckPayload(p: unknown): p is HmStartAckPayload { + return isHmSpawnAckPayload(p) as unknown as boolean; +} +function isHmStartReadyPayload(p: unknown): p is HmStartReadyPayload { + return isHmSpawnReadyPayload(p) as unknown as boolean; +} +function isHmStartFailedPayload(p: unknown): p is HmStartFailedPayload { + return isHmSpawnFailedPayload(p) as unknown as boolean; +} +function isHmStopResultPayload(p: unknown): p is HmStopResultPayload { + return isPlainObject(p) + && typeof p['instance_name'] === 'string' + && typeof p['instance_id'] === 'string'; +} +function isHmInspectResultPayload(p: unknown): p is HmInspectResultPayload { + return isPlainObject(p) && typeof p['instance_id'] === 'string'; +} +function isHmListResultPayload(p: unknown): p is HmListResultPayload { + return isPlainObject(p) && Array.isArray(p['instances']); +} +function isHmHelpResultPayload(p: unknown): p is HmHelpResultPayload { + return isPlainObject(p) + && Array.isArray(p['commands']) + && typeof p['version'] === 'string'; +} +function isHmCommandResultPayload(p: unknown): p is HmCommandResultPayload { + return isPlainObject(p) && p['result'] !== undefined; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Emit a "manager sent a malformed payload" error. Sets exitCode=1. */ +function onProtocolError(type: string, json: boolean): void { + if (json) { + writeStderr(`Protocol error: received ${type} with malformed payload`); + } else { + writeStderr(`sphere host: manager returned malformed ${type} payload`); + } + process.exitCode = 1; +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +/** + * Write an error line to stderr with a consistent `sphere host: ` prefix. + * Callers pass the bare message; this helper owns the formatting so user- + * facing output is uniform across every failure path (transport error, + * timeout, protocol error, hm.error response, parse failure, etc.). + */ +function writeStderr(msg: unknown): void { + const s = typeof msg === 'string' ? msg : String(msg ?? 'unknown error'); + const prefixed = s.startsWith('sphere host:') || s.startsWith('sphere:') + ? s + : `sphere host: ${s}`; + process.stderr.write(prefixed.endsWith('\n') ? prefixed : `${prefixed}\n`); +} + +// ============================================================================= +// Core runner +// ============================================================================= + +interface RunContext { + sphere: Sphere; + transport: DmTransport; + timeoutMs: number; + json: boolean; +} + +type Handler = (ctx: RunContext) => Promise; + +async function runWithTransport(cmd: Command, handler: Handler): Promise { + const globals = parseGlobalOpts(cmd); + const json = globals.json ?? false; + + let timeoutMs: number; + let managerAddress: string; + try { + timeoutMs = parseTimeout(globals.timeout, DEFAULT_TIMEOUT_MS); + managerAddress = resolveManagerAddress({ manager: globals.manager }); + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + return; + } + + let sphere: Sphere | null = null; + let transport: DmTransport | null = null; + try { + sphere = await initSphere(); + transport = createDmTransport(sphere.communications, { managerAddress, timeoutMs }); + await handler({ sphere, transport, timeoutMs, json }); + } catch (err) { + handleError(err, json); + } finally { + // transport must be disposed before sphere.destroy so communications + // unsubscribe works while the transport layer is still live. + if (transport) { + try { await transport.dispose(); } catch (e) { + if (process.env['DEBUG']) writeStderr(`sphere-cli: transport.dispose error: ${e}`); + } + } + if (sphere) { + try { await sphere.destroy(); } catch (e) { + if (process.env['DEBUG']) writeStderr(`sphere-cli: sphere.destroy error: ${e}`); + } + } + } +} + +function handleError(err: unknown, json: boolean): void { + if (err instanceof TimeoutError) { + writeStderr('Request timed out'); + } else if (err instanceof TransportError) { + writeStderr(err.message); + } else if (err instanceof Error) { + writeStderr(err.message); + } else { + writeStderr(String(err)); + } + // json flag does not change error channel — errors always go to stderr as plain text. + void json; + process.exitCode = 1; +} + +function handleHmError(res: HmcpResponse, json: boolean): void { + if (json) { + printJson(res); + } else { + writeStderr(errorPayload(res).message); + } + process.exitCode = 1; +} + +// ============================================================================= +// Streaming lifecycle helper (spawn / start / resume) +// ============================================================================= +// +// spawn, start and resume share the same shape: send request → stream of ack +// + (ready | failed | error) → terminal response. Originally each handler +// rebuilt the collection + iteration loop; this helper gives one correct +// implementation and lets each caller plug in its own human-readable +// formatters for ack/ready/failed. + +interface StreamingLifecycleHandlers { + readonly ackType: string; + readonly readyTypes: readonly string[]; + readonly failedType: string; + readonly formatAck: (res: HmcpResponse) => void; + readonly formatReady: (res: HmcpResponse) => void; + readonly formatFailed: (res: HmcpResponse) => void; +} + +async function runStreamingLifecycle( + cmd: Command, + type: HmcpRequestType, + payload: Record, + h: StreamingLifecycleHandlers, +): Promise { + await runWithTransport(cmd, async ({ transport, json }) => { + const req = createHmcpRequest(type, payload); + + const collected: HmcpResponse[] = []; + await transport.sendRequestStream(req, (res) => { + collected.push(res); + return ( + h.readyTypes.includes(res.type) || + res.type === h.failedType || + res.type === 'hm.error' + ); + }); + + if (json) { + printJson(collected); + const last = collected[collected.length - 1]; + if (last && (last.type === h.failedType || last.type === 'hm.error')) { + process.exitCode = 1; + } + return; + } + + for (const res of collected) { + if (res.type === h.ackType) { + h.formatAck(res); + } else if (h.readyTypes.includes(res.type)) { + h.formatReady(res); + } else if (res.type === h.failedType) { + h.formatFailed(res); + process.exitCode = 1; + } else if (isErrorResponse(res)) { + handleHmError(res, json); + } + } + }); +} + +// ============================================================================= +// Subcommand handlers +// ============================================================================= + +async function handleSpawn(cmd: Command, name: string, sOpts: SpawnOpts): Promise { + const env = parseEnvPairs(sOpts.env); + const payload: Record = { + template_id: sOpts.template, + instance_name: name, + }; + if (sOpts.nametag) payload['nametag'] = sOpts.nametag; + if (env) payload['env'] = env; + + await runStreamingLifecycle(cmd, 'hm.spawn', payload, { + ackType: 'hm.spawn_ack', + readyTypes: ['hm.spawn_ready'], + failedType: 'hm.spawn_failed', + formatAck: (res) => { + if (!isHmSpawnAckPayload(res.payload)) return onProtocolError(res.type, false); + writeStderr(`Accepted: ${res.payload.instance_id} (${res.payload.state})`); + }, + formatReady: (res) => { + if (!isHmSpawnReadyPayload(res.payload)) return onProtocolError(res.type, false); + const nt = res.payload.tenant_nametag ?? '(no nametag)'; + process.stdout.write(`Container ready: ${nt} (${res.payload.tenant_direct_address})\n`); + }, + formatFailed: (res) => { + if (!isHmSpawnFailedPayload(res.payload)) return onProtocolError(res.type, false); + writeStderr(`Failed: ${res.payload.reason}`); + }, + }); +} + +async function handleList(cmd: Command, lOpts: ListOpts): Promise { + await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { + const payload: Record = {}; + if (lOpts.state) payload['state_filter'] = lOpts.state; + + const req = createHmcpRequest('hm.list', payload); + const res = await transport.sendRequest(req, timeoutMs); + + if (isErrorResponse(res)) { + handleHmError(res, json); + return; + } + + if (json) { + printJson(res); + return; + } + + if (!isHmListResultPayload(res.payload)) { + onProtocolError(res.type, json); + return; + } + printInstanceTable(res.payload.instances); + }); +} + +function printInstanceTable(instances: readonly HmInstanceSummary[]): void { + const header = ['NAME', 'ID', 'TEMPLATE', 'STATE', 'CREATED']; + const rows: string[][] = [header]; + for (const inst of instances) { + rows.push([ + inst.instance_name, + inst.instance_id, + inst.template_id, + inst.state, + inst.created_at, + ]); + } + const widths = header.map((_, col) => + Math.max(...rows.map((r) => (r[col] ?? '').length)), + ); + for (const r of rows) { + const line = r.map((cell, i) => (cell ?? '').padEnd(widths[i] ?? 0)).join(' '); + process.stdout.write(`${line.trimEnd()}\n`); + } +} + +async function handleSimple( + cmd: Command, + type: HmcpRequestType, + nameOrId: string, + opts: NameOrIdOpts, + onOk: (res: HmcpResponse, nameOrId: string) => void, +): Promise { + await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { + const req = createHmcpRequest(type, targetPayload(nameOrId, opts)); + const res = await transport.sendRequest(req, timeoutMs); + if (isErrorResponse(res)) { + handleHmError(res, json); + return; + } + if (json) { + printJson(res); + return; + } + onOk(res, nameOrId); + }); +} + +async function handleStop(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await handleSimple(cmd, 'hm.stop', nameOrId, opts, (res) => { + if (!isHmStopResultPayload(res.payload)) return onProtocolError(res.type, false); + process.stdout.write(`Stopped: ${res.payload.instance_name} (${res.payload.instance_id})\n`); + }); +} + +async function handleStart(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await runStreamingLifecycle(cmd, 'hm.start', targetPayload(nameOrId, opts), { + ackType: 'hm.start_ack', + readyTypes: ['hm.start_ready'], + failedType: 'hm.start_failed', + formatAck: (res) => { + if (!isHmStartAckPayload(res.payload)) return onProtocolError(res.type, false); + writeStderr(`Accepted: ${res.payload.instance_id} (${res.payload.state})`); + }, + formatReady: (res) => { + if (!isHmStartReadyPayload(res.payload)) return onProtocolError(res.type, false); + const nt = res.payload.tenant_nametag ?? '(no nametag)'; + process.stdout.write(`Container ready: ${nt} (${res.payload.tenant_direct_address})\n`); + }, + formatFailed: (res) => { + if (!isHmStartFailedPayload(res.payload)) return onProtocolError(res.type, false); + writeStderr(`Failed: ${res.payload.reason}`); + }, + }); +} + +async function handleInspect(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await handleSimple(cmd, 'hm.inspect', nameOrId, opts, (res) => { + if (!isHmInspectResultPayload(res.payload)) return onProtocolError(res.type, false); + const p = res.payload; + const rows: Array<[string, string]> = [ + ['instance_id', p.instance_id], + ['instance_name', p.instance_name], + ['state', p.state], + ['template_id', p.template_id], + ['tenant_pubkey', p.tenant_pubkey ?? '(none)'], + ['tenant_direct_address', p.tenant_direct_address ?? '(none)'], + ['tenant_nametag', p.tenant_nametag ?? '(none)'], + ['created_at', p.created_at], + ['last_heartbeat_at', p.last_heartbeat_at ?? '(never)'], + ['docker_container_id', p.docker_container_id ?? '(none)'], + ]; + const keyWidth = Math.max(...rows.map(([k]) => k.length)); + for (const [k, v] of rows) { + process.stdout.write(`${`${k}:`.padEnd(keyWidth + 2)} ${v}\n`); + } + }); +} + +async function handleCmd( + cmd: Command, + nameOrId: string, + command: string, + cOpts: CmdOpts, +): Promise { + let params: Record | undefined; + let cmdTimeoutMs: number | undefined; + try { + params = parseJsonParams(cOpts.params); + if (cOpts.cmdTimeout !== undefined) { + cmdTimeoutMs = parseTimeout(cOpts.cmdTimeout, DEFAULT_TIMEOUT_MS); + } + } catch (err) { + writeStderr((err as Error).message); + process.exitCode = 1; + return; + } + + await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { + const base = targetPayload(nameOrId, cOpts); + const payload: HmCommandPayload = { + ...(base as { instance_id?: string; instance_name?: string }), + command, + ...(params ? { params } : {}), + ...(cmdTimeoutMs ? { timeout_ms: cmdTimeoutMs } : {}), + }; + + // If cmdTimeout is set, give the transport TRANSPORT_HEADROOM_MS past the + // tenant's own execution timer so the reply has time to travel back over + // the relay before we give up. + const txTimeout = cmdTimeoutMs ? cmdTimeoutMs + TRANSPORT_HEADROOM_MS : timeoutMs; + const req = createHmcpRequest('hm.command', payload as unknown as Record); + const res = await transport.sendRequest(req, txTimeout); + + if (isErrorResponse(res)) { + handleHmError(res, json); + return; + } + + if (json) { + printJson(res); + return; + } + + if (!isHmCommandResultPayload(res.payload)) { + onProtocolError(res.type, json); + return; + } + printJson(res.payload.result); + }); +} + +async function handleRemove(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await handleSimple(cmd, 'hm.remove', nameOrId, opts, (res) => { + const p = res.payload as { instance_name?: string; instance_id?: string }; + process.stdout.write(`Removed: ${p.instance_name ?? p.instance_id ?? nameOrId}\n`); + }); +} + +async function handlePause(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + await handleSimple(cmd, 'hm.pause', nameOrId, opts, (res) => { + const p = res.payload as { instance_name?: string; instance_id?: string }; + process.stdout.write(`Paused: ${p.instance_name ?? p.instance_id ?? nameOrId}\n`); + }); +} + +async function handleResume(cmd: Command, nameOrId: string, opts: NameOrIdOpts): Promise { + // resume has no distinct ack type; the tenant emits ready/result directly. + await runStreamingLifecycle(cmd, 'hm.resume', targetPayload(nameOrId, opts), { + ackType: '__no_ack__', + readyTypes: ['hm.resume_ready', 'hm.resume_result'], + failedType: 'hm.resume_failed', + formatAck: () => { /* no ack for resume */ }, + formatReady: (res) => { + const p = isPlainObject(res.payload) ? res.payload : {}; + const label = (typeof p['instance_name'] === 'string' && p['instance_name']) + || (typeof p['instance_id'] === 'string' && p['instance_id']) + || nameOrId; + process.stdout.write(`Ready: ${label}\n`); + }, + formatFailed: (res) => { + const p = isPlainObject(res.payload) ? res.payload : {}; + const reason = typeof p['reason'] === 'string' ? p['reason'] : 'resume failed'; + writeStderr(`Failed: ${reason}`); + }, + }); +} + +async function handleHelp(cmd: Command): Promise { + await runWithTransport(cmd, async ({ transport, timeoutMs, json }) => { + const req = createHmcpRequest('hm.help', {}); + const res = await transport.sendRequest(req, timeoutMs); + if (isErrorResponse(res)) { + handleHmError(res, json); + return; + } + if (json) { + printJson(res); + return; + } + if (!isHmHelpResultPayload(res.payload)) { + onProtocolError(res.type, json); + return; + } + process.stdout.write(`HMCP version: ${res.payload.version}\nCommands:\n`); + for (const c of res.payload.commands) { + process.stdout.write(` ${String(c)}\n`); + } + }); +} + +// ============================================================================= +// Command tree +// ============================================================================= + +export function createHostCommand(): Command { + const host = new Command('host') + .description('HMCP: controller → host manager (over Sphere DM)') + .option('--manager
', 'Host manager address (@nametag, DIRECT://hex, or hex pubkey)') + .option('--json', 'Output raw JSON response') + .option('--timeout ', 'Override default request timeout (ms)', String(DEFAULT_TIMEOUT_MS)); + + // Render the shared options in every subcommand's --help so discovery + // doesn't require scrolling up to `sphere host --help`. Commander + // forwards the values automatically; this just makes them visible. + const inheritedHelp = + 'Inherited options:\n' + + ' --manager
Host manager address (@nametag, DIRECT://hex, or hex pubkey)\n' + + ' --json Output raw JSON response\n' + + ' --timeout Override default request timeout (ms)'; + + host + .command('spawn ') + .description('Spawn a new tenant instance from a template') + .requiredOption('--template ', 'Template ID to instantiate') + .option('--nametag ', 'Nametag to register for the tenant') + // Note: single-arg form (no ellipsis) + argParser accumulator. + // The variadic form `` greedily consumes every subsequent + // non-flag token — including the positional `` that follows. Users + // repeat `--env FOO=1 --env BAR=2` to pass multiple pairs. + .addOption( + new Option('--env ', 'Environment variable pair (repeat for multiple)') + .argParser((value: string, previous: string[] | undefined) => + previous ? [...previous, value] : [value]) as Option, + ) + .action(async function (this: Command, name: string, sOpts: SpawnOpts) { + await handleSpawn(this, name, sOpts); + }); + + host + .command('list') + .description('List tenant instances managed by this host') + .option('--state ', 'Filter by state (CREATED, BOOTING, RUNNING, STOPPED, FAILED)') + .action(async function (this: Command, lOpts: ListOpts) { + await handleList(this, lOpts); + }); + + host + .command('stop ') + .description('Stop a running tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleStop(this, nameOrId, opts); + }); + + host + .command('start ') + .description('Start a stopped tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleStart(this, nameOrId, opts); + }); + + host + .command('inspect ') + .description('Inspect a tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleInspect(this, nameOrId, opts); + }); + + host + .command('cmd ') + .description('Send a command to a tenant instance') + .option('--params ', 'JSON object of command parameters') + .option('--id', 'Treat as an instance_id') + .option('--cmd-timeout ', 'Per-command execution timeout (ms)') + .action(async function (this: Command, nameOrId: string, command: string, cOpts: CmdOpts) { + await handleCmd(this, nameOrId, command, cOpts); + }); + + host + .command('remove ') + .description('Remove a tenant instance record (and its container)') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleRemove(this, nameOrId, opts); + }); + + host + .command('pause ') + .description('Pause a running tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handlePause(this, nameOrId, opts); + }); + + host + .command('resume ') + .description('Resume a paused tenant instance') + .option('--id', 'Treat as an instance_id') + .action(async function (this: Command, nameOrId: string, opts: NameOrIdOpts) { + await handleResume(this, nameOrId, opts); + }); + + host + .command('help') + .description('Ask the host manager for its supported commands') + .action(async function (this: Command) { + await handleHelp(this); + }); + + // Attach the shared-options help text to every subcommand. Iterating after + // construction keeps the subcommand definitions above small and ensures + // any newly-added subcommand automatically inherits the help surface. + for (const sub of host.commands) { + sub.addHelpText('after', `\n${inheritedHelp}`); + } + + return host; +} + +// Exported for unit tests. +export { parseEnvPairs, parseJsonParams, parseTimeout, targetPayload }; + +// Re-exported for external consumers wanting to inspect the request type union. +export type { HmcpRequest }; diff --git a/src/host/sphere-init.ts b/src/host/sphere-init.ts new file mode 100644 index 0000000..cafb962 --- /dev/null +++ b/src/host/sphere-init.ts @@ -0,0 +1,83 @@ +/** + * Sphere initialisation for the `sphere host` namespace. + * + * Loads `.sphere-cli/config.json` (matching legacy-cli defaults) and initialises + * Sphere from the existing wallet — never auto-creates. Modules not needed by + * HMCP (market, swap, accounting, groupChat) are left disabled to keep startup + * fast and failures isolated to the DM transport. + */ + +import * as fs from 'node:fs'; +import { Sphere } from '@unicitylabs/sphere-sdk'; +import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; +import type { NetworkType } from '@unicitylabs/sphere-sdk'; + +// All paths are CWD-relative by design — matches legacy-cli behaviour so the +// same wallet is visible whether invoked via `sphere wallet …` (legacy) or +// `sphere host …` (this namespace). Callers that need a fixed wallet location +// should chdir before invocation or set `dataDir` in config.json to an absolute +// path. +const CONFIG_FILE = './.sphere-cli/config.json'; +const DEFAULT_DATA_DIR = './.sphere-cli'; +const DEFAULT_TOKENS_DIR = './.sphere-cli/tokens'; + +interface CliConfig { + network: NetworkType; + dataDir: string; + tokensDir: string; + currentProfile?: string; +} + +function loadConfig(): CliConfig { + try { + if (fs.existsSync(CONFIG_FILE)) { + const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) as Record; + return { + network: typeof raw['network'] === 'string' ? raw['network'] as NetworkType : 'testnet', + dataDir: typeof raw['dataDir'] === 'string' ? raw['dataDir'] : DEFAULT_DATA_DIR, + tokensDir: typeof raw['tokensDir'] === 'string' ? raw['tokensDir'] : DEFAULT_TOKENS_DIR, + currentProfile: typeof raw['currentProfile'] === 'string' ? raw['currentProfile'] : undefined, + }; + } + } catch (e) { + process.stderr.write(`sphere: failed to parse ${CONFIG_FILE}: ${String(e)}. Using defaults.\n`); + } + return { network: 'testnet', dataDir: DEFAULT_DATA_DIR, tokensDir: DEFAULT_TOKENS_DIR }; +} + +export async function initSphere(): Promise { + const config = loadConfig(); + + const providers = createNodeProviders({ + network: config.network, + dataDir: config.dataDir, + tokensDir: config.tokensDir, + }); + + const exists = await Sphere.exists(providers.storage); + if (!exists) { + throw new Error( + `No wallet found in ${config.dataDir}. Run \`sphere wallet init\` before using \`sphere host\`.`, + ); + } + + const { sphere } = await Sphere.init({ + storage: providers.storage, + transport: providers.transport, + oracle: providers.oracle, + network: config.network, + autoGenerate: false, + }); + + return sphere; +} + +export function resolveManagerAddress(opts: { manager?: string }): string { + const address = opts.manager ?? process.env['SPHERE_HOST_MANAGER']; + if (!address || address.trim() === '') { + throw new Error( + 'No host manager address. Pass --manager <@nametag|DIRECT://hex|hex> or set SPHERE_HOST_MANAGER.', + ); + } + return address.trim(); +} diff --git a/src/index.test.ts b/src/index.test.ts index 5857ca9..4a10d87 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -10,15 +10,10 @@ describe('sphere-cli scaffold', () => { it('--version prints the VERSION constant', () => { const program = createCli(); - // commander's version() wires a flag that writes to stdout and exits. - // Rather than intercept process.exit, verify the version metadata. const opts = program.opts(); expect(typeof opts).toBe('object'); - // version is on the program, not in opts; commander attaches it privately. - // We verify via the public API: the help text includes VERSION. const help = program.helpInformation(); expect(VERSION).toBeTruthy(); - // Help text should include "Usage: sphere" expect(help).toContain('Usage: sphere'); }); @@ -35,8 +30,7 @@ describe('sphere-cli scaffold', () => { } }); - it('invoking an unimplemented namespace prints "not implemented yet" and exits non-zero', async () => { - // Capture stderr writes + intercept process.exit so the test runner survives. + it('invoking a phase-4 namespace prints "not implemented yet" and exits 64', async () => { const stderrCalls: string[] = []; const writeSpy = vi .spyOn(process.stderr, 'write') @@ -49,12 +43,11 @@ describe('sphere-cli scaffold', () => { .spyOn(process, 'exit') .mockImplementation(((code?: number) => { exitCode = code; - // Throw to short-circuit the namespace action; main()'s catch will swallow it. throw new Error('__mock_exit__'); }) as never); try { - await main(['node', 'sphere', 'wallet']); + await main(['node', 'sphere', 'tenant']); } finally { writeSpy.mockRestore(); exitSpy.mockRestore(); @@ -63,13 +56,18 @@ describe('sphere-cli scaffold', () => { expect(exitCode).toBe(64); const joined = stderrCalls.join(''); expect(joined).toContain('not implemented yet'); - expect(joined).toContain('phase 2'); + expect(joined).toContain('phase 4'); }); - it('help namespaces show the phase annotations', () => { + it('help shows phase 4 annotation for DM-native namespaces', () => { const program = createCli(); const help = program.helpInformation(); - expect(help).toContain('[phase 2]'); expect(help).toContain('[phase 4]'); }); + + it('help shows legacy bridge annotation for phase 2 namespaces', () => { + const program = createCli(); + const help = program.helpInformation(); + expect(help).toContain('legacy bridge'); + }); }); diff --git a/src/index.ts b/src/index.ts index 0023410..36b88d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,13 +15,100 @@ * sphere daemon — long-running event listener * sphere config — CLI configuration (profiles, relays, manager nametag) * - * Phase 1 wires only the scaffold + `--version`. Real commands land in - * phase 2 (migrate from sphere-sdk/cli/) and phase 4 (migrate from - * agentic-hosting/src/cli/ + the new DM transport). + * Phase 2: legacy sphere-sdk CLI wired under all namespaces. + * Phase 4: real DM-native commands replace legacy. */ import { Command } from 'commander'; import { VERSION } from './version.js'; +import { createHostCommand } from './host/host-commands.js'; + +// Legacy namespaces that delegate to the sphere-sdk CLI dispatcher. +// These are wired in phase 2 and replaced command-by-command in phase 4+. +const LEGACY_NAMESPACES = new Set([ + 'wallet', 'balance', 'payments', 'dm', 'group', 'market', 'swap', + 'invoice', 'nametag', 'crypto', 'util', 'faucet', 'daemon', 'config', + 'completions', +]); + +// Phase 4 namespaces — DM-native, not yet implemented. +const PHASE4_NAMESPACES: Array<[string, string]> = [ + ['tenant', 'ACP: controller → tenant (over DM, host-agnostic)'], +]; + +/** + * Translate a commander namespace + subcommand into the argv shape that the + * legacy sphere-sdk CLI switch/case dispatcher expects. + * + * The legacy dispatcher reads `args[0]` as `command`. Different commander + * namespaces require different translations: + * - `wallet`, `balance`, `daemon`, `config`, `completions` map 1:1. + * - `faucet` rewrites to `topup`. + * - `nametag ` expands to legacy flat commands (`nametag-info`, etc.). + * - `payments`, `crypto`, `util` strip the namespace entirely (their subs + * are already legacy top-level commands like `send`, `to-human`, ...). + * - `dm`, `group`, `market`, `swap`, `invoice` prefix the subcommand with + * `-` (e.g. `sphere swap propose` → `swap-propose`). + */ +function buildLegacyArgv(namespace: string): string[] { + // tail = everything after: node sphere + const tail = process.argv.slice(3); + + switch (namespace) { + // These namespaces directly match legacy top-level commands — keep namespace as command + case 'wallet': return ['wallet', ...tail]; + case 'balance': return ['balance', ...tail]; + case 'daemon': return ['daemon', ...tail]; + case 'config': return ['config', ...tail]; + case 'completions': return ['completions', ...tail]; + + // faucet → legacy 'topup' + case 'faucet': return ['topup', ...tail]; + + // nametag subcommands: register → nametag, info → nametag-info, my → my-nametag, sync → nametag-sync + case 'nametag': { + const [sub, ...rest] = tail; + if (!sub || sub === 'register') return ['nametag', ...rest]; + if (sub === 'info') return ['nametag-info', ...rest]; + if (sub === 'my') return ['my-nametag', ...rest]; + if (sub === 'sync') return ['nametag-sync', ...rest]; + return ['nametag', sub, ...rest]; + } + + // payments → strip namespace (send, receive, history, sync, addresses, switch, hide, unhide) + case 'payments': return tail; + + // crypto → strip namespace (generate-key, hex-to-wif, validate-key, etc.) + case 'crypto': return tail; + + // util → strip namespace (to-human, to-smallest, format, base58-*) + case 'util': return tail; + + // dm: send → dm @addr msg; inbox → dm-inbox; history → dm-history + case 'dm': { + const [sub, ...rest] = tail; + if (!sub) return ['dm', ...rest]; + if (sub === 'send') return ['dm', ...rest]; + if (sub === 'inbox') return ['dm-inbox', ...rest]; + if (sub === 'history') return ['dm-history', ...rest]; + return ['dm', sub, ...rest]; + } + + // group, market, swap, invoice: prefix subcommand with 'namespace-' + case 'group': return prefixSub('group-', tail); + case 'market': return prefixSub('market-', tail); + case 'swap': return prefixSub('swap-', tail); + case 'invoice': return prefixSub('invoice-', tail); + + default: return [namespace, ...tail]; + } +} + +function prefixSub(prefix: string, tail: string[]): string[] { + const [sub, ...rest] = tail; + if (!sub) return [prefix.replace(/-$/, ''), ...rest]; + return [`${prefix}${sub}`, ...rest]; +} export function createCli(): Command { const program = new Command(); @@ -31,60 +118,87 @@ export function createCli(): Command { .description('The unified CLI for Sphere SDK and agentic-hosting control') .version(VERSION, '-v, --version', 'output the version number'); - // Namespace placeholders. Each is a real commander subcommand with a help - // stub that tells the user "not yet — see phase N". This lets `sphere --help` - // reflect the full topology immediately, so users know what's coming. - const namespaces: Array<[string, string, string]> = [ - ['wallet', 'Wallet lifecycle (init, status, profiles)', 'phase 2'], - ['balance', 'L3 token balances + asset info', 'phase 2'], - ['payments', 'Send, receive, history', 'phase 2'], - ['dm', 'Direct messages (NIP-17) — send, inbox, history', 'phase 2'], - ['group', 'Group chat (NIP-29)', 'phase 2'], - ['market', 'Post and search trading intents', 'phase 2'], - ['swap', 'P2P atomic swap lifecycle', 'phase 2'], - ['invoice', 'Invoicing + accounting', 'phase 2'], - ['nametag', 'Register and look up nametags', 'phase 2'], - ['crypto', 'Key / wallet utility commands', 'phase 2'], - ['util', 'Amount conversion, base58 codec', 'phase 2'], - ['faucet', 'Testnet token faucet', 'phase 2'], - ['daemon', 'Long-running event listener', 'phase 2'], - ['host', 'HMCP: controller → host manager (over DM)', 'phase 4'], - ['tenant', 'ACP: controller → tenant (over DM, host-agnostic)', 'phase 4'], - ['config', 'CLI configuration (profiles, relays, manager)', 'phase 1+'], - ['completions', 'Shell completion scripts', 'phase 2'], - ]; - - for (const [name, description, phase] of namespaces) { + // Throw CommanderError instead of calling process.exit() for --help, --version, + // and parse errors. main() catches these and returns the right code, which is + // essential for a CLI that is also imported from tests. + program.exitOverride(); + + // Phase 2: legacy commands — delegate to the sphere-sdk CLI dispatcher. + for (const name of LEGACY_NAMESPACES) { + const sub = program + .command(name) + .description(`${name} commands (legacy bridge — phase 2)`); + + sub.allowUnknownOption(true); + sub.action(async () => { + const legacyArgv = buildLegacyArgv(name); + // Dynamic import keeps the legacy ~40-file dispatcher out of the hot + // start path for phase-4 DM-native commands (`sphere host …`, etc.) + // that don't need it. Paid once on first legacy invocation per process. + const { legacyMain } = await import('./legacy/legacy-cli.js'); + await legacyMain(legacyArgv); + }); + } + + // Phase 4 stubs — DM-native commands, not yet implemented. + for (const [name, description] of PHASE4_NAMESPACES) { const sub = program .command(name) - .description(`${description} [${phase}]`); + .description(`${description} [phase 4]`); - // Catch-all action so `sphere wallet init` (with args) prints a helpful - // message until the real command lands. Subcommands within each namespace - // come in later phases. sub.allowUnknownOption(true); sub.action(() => { process.stderr.write( - `sphere ${name}: not implemented yet (scheduled for ${phase}). ` + + `sphere ${name}: not implemented yet (scheduled for phase 4). ` + `See SPHERE-CLI-EXTRACTION-PLAN.md for the migration schedule.\n`, ); process.exit(64); // EX_USAGE }); } + // Phase 4 (live): `sphere host` — HMCP over Sphere DMs. + program.addCommand(createHostCommand()); + return program; } /** Parse argv and execute. Returns the exit code. */ export async function main(argv: string[] = process.argv): Promise { + // Reset exit code at entry — action handlers set `process.exitCode = 1` + // on error, and that value is process-wide. Without this reset, a + // prior invocation (or test) that left a non-zero exit code would + // cause a subsequent successful main() to return non-zero. Repeated + // in-process invocations are rare in production (bin/sphere.mjs runs + // main() exactly once per process) but are the default in vitest. + process.exitCode = 0; const program = createCli(); try { await program.parseAsync(argv); - return 0; + return (typeof process.exitCode === 'number' ? process.exitCode : 0); } catch (err) { - process.stderr.write( - `sphere: ${err instanceof Error ? err.message : String(err)}\n`, - ); + // commander throws CommanderError on --help/--version/parse errors; those are + // handled internally (output already printed). Re-exit with the right code. + if (err && typeof err === 'object' && 'code' in err && 'exitCode' in err) { + const ce = err as { code: string; exitCode: number }; + if (ce.code === 'commander.helpDisplayed' || ce.code === 'commander.version') { + return 0; + } + return ce.exitCode ?? 1; + } + // Error-prefix hygiene: downstream writers (writeStderr in host-commands) + // already prefix `sphere host: ` for their own errors. Parse/unexpected + // errors that reach here fall under `sphere:` for generality. + // + // Defense-in-depth redaction: the CLI should NEVER surface messages + // containing secret material in the first place, so this regex is + // expected to never match in practice. It narrowly targets BIP-39-shape + // lowercase word sequences of length 12-24 and does not match stack + // traces or camelCase/snake_case tokens. If it ever fires, the stderr + // format includes `[REDACTED]` which an operator can grep to identify + // a genuine message-sanitisation failure. + const raw = err instanceof Error ? err.message : String(err); + const safe = raw.replace(/\b(?:[a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/gi, '[REDACTED]'); + process.stderr.write(`sphere: ${safe}\n`); return 1; } } diff --git a/src/legacy/daemon-config.ts b/src/legacy/daemon-config.ts new file mode 100644 index 0000000..65ec320 --- /dev/null +++ b/src/legacy/daemon-config.ts @@ -0,0 +1,436 @@ +/** + * Daemon configuration: interfaces, validation, loading, and CLI flag parsing. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// ============================================================================= +// Atomic file writes +// ============================================================================= + +// ============================================================================= +// Interfaces +// ============================================================================= + +export interface DaemonConfig { + logFile?: string; + pidFile?: string; + marketFeed?: boolean; + actionTimeout?: number; + rules: DaemonRule[]; +} + +export interface DaemonRule { + name?: string; + events: string[]; + filter?: Record; + actions: DaemonAction[]; + sequential?: boolean; + disabled?: boolean; +} + +export type DaemonAction = BashAction | WebhookAction | BuiltinAction; + +export interface BashAction { + type: 'bash'; + // SECURITY: `command` is executed via exec() with full shell interpretation. + // The daemon config file must be chmod 600 and writable only by the daemon user. + // Never accept daemon config from untrusted sources. + command: string; + timeout?: number; + cwd?: string; +} + +export interface WebhookAction { + type: 'webhook'; + url: string; + headers?: Record; + timeout?: number; + method?: 'POST' | 'PUT'; +} + +export interface BuiltinAction { + type: 'builtin'; + action: 'auto-receive' | 'log-to-file'; + path?: string; + finalize?: boolean; +} + +// ============================================================================= +// Defaults +// ============================================================================= + +const DEFAULT_CONFIG_PATH = './.sphere-cli/daemon.json'; +const DEFAULT_LOG_FILE = './.sphere-cli/daemon.log'; +const DEFAULT_PID_FILE = './.sphere-cli/daemon.pid'; +const DEFAULT_ACTION_TIMEOUT = 30000; + +// ============================================================================= +// Validation +// ============================================================================= + +export function validateDaemonConfig(config: unknown): DaemonConfig { + if (!config || typeof config !== 'object') { + throw new Error('Daemon config must be a JSON object'); + } + + const c = config as Record; + + if (c.logFile !== undefined && typeof c.logFile !== 'string') { + throw new Error('logFile must be a string'); + } + if (c.pidFile !== undefined && typeof c.pidFile !== 'string') { + throw new Error('pidFile must be a string'); + } + if (c.marketFeed !== undefined && typeof c.marketFeed !== 'boolean') { + throw new Error('marketFeed must be a boolean'); + } + if (c.actionTimeout !== undefined) { + if (typeof c.actionTimeout !== 'number' || c.actionTimeout <= 0) { + throw new Error('actionTimeout must be a positive number'); + } + } + + if (!Array.isArray(c.rules)) { + throw new Error('rules must be an array'); + } + + const rules = c.rules.map((rule: unknown, i: number) => validateRule(rule, i)); + + return { + logFile: c.logFile as string | undefined, + pidFile: c.pidFile as string | undefined, + marketFeed: c.marketFeed as boolean | undefined, + actionTimeout: c.actionTimeout as number | undefined, + rules, + }; +} + +function validateRule(rule: unknown, index: number): DaemonRule { + if (!rule || typeof rule !== 'object') { + throw new Error(`rules[${index}]: must be an object`); + } + + const r = rule as Record; + + if (r.name !== undefined && typeof r.name !== 'string') { + throw new Error(`rules[${index}]: name must be a string`); + } + + if (!Array.isArray(r.events) || r.events.length === 0) { + throw new Error(`rules[${index}]: events must be a non-empty array of strings`); + } + for (const e of r.events) { + if (typeof e !== 'string' || e.length === 0) { + throw new Error(`rules[${index}]: each event must be a non-empty string`); + } + } + + if (r.filter !== undefined) { + if (!r.filter || typeof r.filter !== 'object' || Array.isArray(r.filter)) { + throw new Error(`rules[${index}]: filter must be an object`); + } + } + + if (!Array.isArray(r.actions) || r.actions.length === 0) { + throw new Error(`rules[${index}]: actions must be a non-empty array`); + } + const actions = r.actions.map((a: unknown, j: number) => validateAction(a, index, j)); + + if (r.sequential !== undefined && typeof r.sequential !== 'boolean') { + throw new Error(`rules[${index}]: sequential must be a boolean`); + } + if (r.disabled !== undefined && typeof r.disabled !== 'boolean') { + throw new Error(`rules[${index}]: disabled must be a boolean`); + } + + return { + name: r.name as string | undefined, + events: r.events as string[], + filter: r.filter as Record | undefined, + actions, + sequential: r.sequential as boolean | undefined, + disabled: r.disabled as boolean | undefined, + }; +} + +function validateAction(action: unknown, ruleIndex: number, actionIndex: number): DaemonAction { + if (!action || typeof action !== 'object') { + throw new Error(`rules[${ruleIndex}].actions[${actionIndex}]: must be an object`); + } + + const a = action as Record; + const prefix = `rules[${ruleIndex}].actions[${actionIndex}]`; + + switch (a.type) { + case 'bash': { + if (typeof a.command !== 'string' || a.command.length === 0) { + throw new Error(`${prefix}: bash action requires a non-empty command`); + } + if (a.timeout !== undefined && (typeof a.timeout !== 'number' || a.timeout <= 0)) { + throw new Error(`${prefix}: timeout must be a positive number`); + } + if (a.cwd !== undefined && typeof a.cwd !== 'string') { + throw new Error(`${prefix}: cwd must be a string`); + } + return { + type: 'bash', + command: a.command as string, + timeout: a.timeout as number | undefined, + cwd: a.cwd as string | undefined, + }; + } + + case 'webhook': { + if (typeof a.url !== 'string' || a.url.length === 0) { + throw new Error(`${prefix}: webhook action requires a non-empty url`); + } + if (a.headers !== undefined) { + if (!a.headers || typeof a.headers !== 'object' || Array.isArray(a.headers)) { + throw new Error(`${prefix}: headers must be an object`); + } + } + if (a.timeout !== undefined && (typeof a.timeout !== 'number' || a.timeout <= 0)) { + throw new Error(`${prefix}: timeout must be a positive number`); + } + if (a.method !== undefined && a.method !== 'POST' && a.method !== 'PUT') { + throw new Error(`${prefix}: method must be "POST" or "PUT"`); + } + return { + type: 'webhook', + url: a.url as string, + headers: a.headers as Record | undefined, + timeout: a.timeout as number | undefined, + method: a.method as 'POST' | 'PUT' | undefined, + }; + } + + case 'builtin': { + if (a.action !== 'auto-receive' && a.action !== 'log-to-file') { + throw new Error(`${prefix}: builtin action must be "auto-receive" or "log-to-file"`); + } + if (a.action === 'log-to-file' && (typeof a.path !== 'string' || a.path.length === 0)) { + throw new Error(`${prefix}: log-to-file requires a non-empty path`); + } + if (a.finalize !== undefined && typeof a.finalize !== 'boolean') { + throw new Error(`${prefix}: finalize must be a boolean`); + } + return { + type: 'builtin', + action: a.action as 'auto-receive' | 'log-to-file', + path: a.path as string | undefined, + finalize: a.finalize as boolean | undefined, + }; + } + + default: + throw new Error(`${prefix}: type must be "bash", "webhook", or "builtin" (got "${a.type}")`); + } +} + +// ============================================================================= +// Loading +// ============================================================================= + +export function loadDaemonConfig(configPath?: string): DaemonConfig { + const filePath = configPath || DEFAULT_CONFIG_PATH; + + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`Config file not found: ${filePath}`); + } + // Non-ENOENT read errors (permissions, I/O) surface as warnings + rethrow. + process.stderr.write( + `sphere: WARNING: Failed to read config at ${filePath} — ` + + `Backup the file and delete it to reset. Error: ${(e as Error).message}\n` + ); + throw e; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e: unknown) { + process.stderr.write( + `sphere: WARNING: Failed to parse config at ${filePath} — using defaults. ` + + `Backup the file and delete it to reset. Error: ${(e as Error).message}\n` + ); + throw new Error(`Invalid JSON in config file: ${filePath}`); + } + + return validateDaemonConfig(parsed); +} + +// ============================================================================= +// CLI Flag Parsing +// ============================================================================= + +export interface DaemonFlags { + configPath?: string; + detach: boolean; + logFile?: string; + pidFile?: string; + events: string[]; + actions: string[]; + marketFeed: boolean; + verbose: boolean; + _forked: boolean; +} + +export function parseDaemonFlags(args: string[]): DaemonFlags { + const flags: DaemonFlags = { + detach: false, + events: [], + actions: [], + marketFeed: false, + verbose: false, + _forked: false, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--config': + flags.configPath = args[++i]; + break; + case '--detach': + flags.detach = true; + break; + case '--log': + flags.logFile = args[++i]; + break; + case '--pid': + flags.pidFile = args[++i]; + break; + case '--event': + flags.events.push(args[++i]); + break; + case '--action': + flags.actions.push(args[++i]); + break; + case '--market-feed': + flags.marketFeed = true; + break; + case '--verbose': + flags.verbose = true; + break; + case '--_forked': + flags._forked = true; + break; + } + } + + return flags; +} + +/** + * Build a DaemonConfig from quick-mode CLI flags (--event / --action). + * When no --event/--action flags are given and a config file exists, load from file. + * When --event/--action flags are given, build an inline config. + */ +export function buildConfigFromFlags(flags: DaemonFlags): DaemonConfig { + // If there are quick-mode flags, build inline config + if (flags.events.length > 0 || flags.actions.length > 0) { + if (flags.events.length === 0) { + throw new Error('--action requires at least one --event'); + } + if (flags.actions.length === 0) { + throw new Error('--event requires at least one --action'); + } + + const actions: DaemonAction[] = flags.actions.map(parseActionSpec); + + return { + logFile: flags.logFile, + pidFile: flags.pidFile, + marketFeed: flags.marketFeed, + rules: [ + { + name: 'cli-quick', + events: flags.events, + actions, + }, + ], + }; + } + + // Otherwise load from config file + const config = loadDaemonConfig(flags.configPath); + + // Apply CLI overrides + if (flags.logFile) config.logFile = flags.logFile; + if (flags.pidFile) config.pidFile = flags.pidFile; + if (flags.marketFeed) config.marketFeed = true; + + return config; +} + +/** + * Parse an action spec string from --action flag. + * Formats: "auto-receive", "bash:command", "webhook:url", "log:path" + */ +function parseActionSpec(spec: string): DaemonAction { + if (spec === 'auto-receive') { + return { type: 'builtin', action: 'auto-receive', finalize: true }; + } + + const colonIdx = spec.indexOf(':'); + if (colonIdx === -1) { + throw new Error(`Invalid action spec "${spec}". Expected: auto-receive, bash:command, webhook:url, or log:path`); + } + + const prefix = spec.substring(0, colonIdx); + const value = spec.substring(colonIdx + 1); + + if (!value) { + throw new Error(`Invalid action spec "${spec}": missing value after "${prefix}:"`); + } + + switch (prefix) { + case 'bash': + return { type: 'bash', command: value }; + case 'webhook': + return { type: 'webhook', url: value }; + case 'log': + return { type: 'builtin', action: 'log-to-file', path: value }; + default: + throw new Error(`Unknown action type "${prefix}". Expected: bash, webhook, log, or auto-receive`); + } +} + +// ============================================================================= +// Resolved Config (with defaults applied) +// ============================================================================= + +export interface ResolvedDaemonConfig extends DaemonConfig { + logFile: string; + pidFile: string; + actionTimeout: number; +} + +export function resolveConfig(config: DaemonConfig): ResolvedDaemonConfig { + return { + ...config, + logFile: config.logFile || DEFAULT_LOG_FILE, + pidFile: config.pidFile || DEFAULT_PID_FILE, + actionTimeout: config.actionTimeout || DEFAULT_ACTION_TIMEOUT, + }; +} + +export function getDefaultConfigPath(): string { + return DEFAULT_CONFIG_PATH; +} + +export function getDefaultPidFile(): string { + return DEFAULT_PID_FILE; +} + +export function ensureDir(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} diff --git a/src/legacy/daemon.ts b/src/legacy/daemon.ts new file mode 100644 index 0000000..dd4c4ed --- /dev/null +++ b/src/legacy/daemon.ts @@ -0,0 +1,858 @@ +/** + * CLI Daemon: persistent event listener with configurable actions. + * + * Subscribes to all Sphere events and dispatches configurable actions + * (bash scripts, HTTP webhooks, built-in actions) based on rules. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { fork } from 'child_process'; +import type { Sphere } from '@unicitylabs/sphere-sdk'; +import type { SphereEventType } from '@unicitylabs/sphere-sdk'; +import type { + DaemonRule, + DaemonAction, + BashAction, + WebhookAction, + BuiltinAction, + ResolvedDaemonConfig, + DaemonFlags, +} from './daemon-config'; +import { + parseDaemonFlags, + buildConfigFromFlags, + resolveConfig, + getDefaultPidFile, + ensureDir, +} from './daemon-config'; + +// ============================================================================= +// PID file helpers (JSON format with nonce for PID reuse detection) +// ============================================================================= + +interface PidFileData { + pid: number; + nonce: number; + cmd: string; +} + +/** + * Parse a PID file. Handles both the new JSON format and the legacy plain-text + * format (just a number). Returns null on parse failure or missing file. + */ +function readPidFile(pidFile: string): PidFileData | null { + let raw: string; + try { + raw = fs.readFileSync(pidFile, 'utf8').trim(); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; + return null; + } + if (!raw) return null; + + // Try JSON first (current format) + try { + const obj = JSON.parse(raw) as unknown; + if (obj && typeof obj === 'object') { + const o = obj as Record; + const pid = typeof o.pid === 'number' ? o.pid : NaN; + if (!Number.isFinite(pid)) return null; + return { + pid, + nonce: typeof o.nonce === 'number' ? o.nonce : 0, + cmd: typeof o.cmd === 'string' ? o.cmd : '', + }; + } + } catch { + // Fall through to legacy format + } + + // Legacy plain-text PID + const pid = parseInt(raw, 10); + if (!Number.isFinite(pid)) return null; + return { pid, nonce: 0, cmd: '' }; +} + +/** + * Check if `pid` is alive and appears to be a node process (via /proc//comm + * on Linux). On non-Linux platforms, falls back to a pure liveness check. + * Returns false for dead PIDs and for PIDs that are alive but clearly not ours + * (i.e. PID reuse case). + */ +function isDaemonProcessAlive(pid: number): boolean { + if (!isProcessAlive(pid)) return false; + // Best-effort PID reuse detection via /proc//comm (Linux only). + try { + const comm = fs.readFileSync(`/proc/${pid}/comm`, 'utf8').trim().toLowerCase(); + // Node processes show up as 'node' (or 'sphere-daemon' if process.title was set). + if (comm.length === 0) return true; + if (comm.includes('node') || comm.includes('sphere')) return true; + // Alive but the cmd doesn't match — treat as stale (PID reuse). + return false; + } catch { + // /proc not available (non-Linux) or no permission — fall back to liveness. + return true; + } +} + +/** + * Write the PID file atomically with exclusive create semantics. + * Throws EEXIST if another daemon has already claimed the file. + */ +function writePidFileExclusive(pidFile: string, data: PidFileData): void { + const fd = fs.openSync(pidFile, 'wx'); + try { + fs.writeSync(fd, JSON.stringify(data)); + } finally { + fs.closeSync(fd); + } +} + +/** + * Delete a file, ignoring ENOENT. Avoids TOCTOU races from existsSync+unlinkSync. + */ +function safeUnlink(filePath: string): void { + try { + fs.unlinkSync(filePath); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } +} + +// ============================================================================= +// All known SphereEventType values (for wildcard expansion) +// ============================================================================= + +const ALL_SPHERE_EVENTS: SphereEventType[] = [ + 'transfer:incoming', + 'transfer:confirmed', + 'transfer:failed', + 'payment_request:incoming', + 'payment_request:accepted', + 'payment_request:rejected', + 'payment_request:paid', + 'payment_request:response', + 'message:dm', + 'message:read', + 'message:typing', + 'composing:started', + 'message:broadcast', + 'sync:started', + 'sync:completed', + 'sync:provider', + 'sync:error', + 'connection:changed', + 'nametag:registered', + 'nametag:recovered', + 'identity:changed', + 'address:activated', + 'address:hidden', + 'address:unhidden', + 'sync:remote-update', + 'groupchat:message', + 'groupchat:joined', + 'groupchat:left', + 'groupchat:kicked', + 'groupchat:group_deleted', + 'groupchat:updated', + 'groupchat:connection', +]; + +// ============================================================================= +// Event Matching +// ============================================================================= + +/** + * Expand a pattern like "transfer:*" or "*" against known event types. + */ +function expandPattern(pattern: string): SphereEventType[] { + if (pattern === '*') return [...ALL_SPHERE_EVENTS]; + + if (pattern.endsWith(':*')) { + const prefix = pattern.slice(0, -1); // "transfer:" from "transfer:*" + return ALL_SPHERE_EVENTS.filter(e => e.startsWith(prefix)); + } + + // Exact match — verify it's a known event type + if (ALL_SPHERE_EVENTS.includes(pattern as SphereEventType)) { + return [pattern as SphereEventType]; + } + + // Unknown event pattern — return as-is (may be a custom/future event) + return [pattern as SphereEventType]; +} + +/** + * Build a dispatch map: event type → list of rules that match it. + */ +function buildDispatchMap(rules: DaemonRule[]): Map { + const map = new Map(); + + for (const rule of rules) { + if (rule.disabled) continue; + + for (const pattern of rule.events) { + const expanded = expandPattern(pattern); + for (const eventType of expanded) { + if (!map.has(eventType)) map.set(eventType, []); + const list = map.get(eventType)!; + if (!list.includes(rule)) list.push(rule); + } + } + } + + return map; +} + +// ============================================================================= +// Filter Matching +// ============================================================================= + +function matchesFilter(filter: Record | undefined, data: unknown): boolean { + if (!filter) return true; + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + for (const [key, value] of Object.entries(filter)) { + if (obj[key] !== value) return false; + } + return true; +} + +// ============================================================================= +// Environment Variables from Event Data +// ============================================================================= + +function buildEnvVars(eventType: string, data: unknown): Record { + const env: Record = { + SPHERE_EVENT: eventType, + SPHERE_EVENT_JSON: JSON.stringify(data), + SPHERE_TIMESTAMP: new Date().toISOString(), + }; + + if (data && typeof data === 'object') { + const d = data as Record; + + if (d.senderNametag) env.SPHERE_SENDER = String(d.senderNametag); + else if (d.senderPubkey) env.SPHERE_SENDER = String(d.senderPubkey); + else if (d.authorNametag) env.SPHERE_SENDER = String(d.authorNametag); + else if (d.authorPubkey) env.SPHERE_SENDER = String(d.authorPubkey); + + if (d.amount) env.SPHERE_AMOUNT = String(d.amount); + if (d.coinId) env.SPHERE_COIN_ID = String(d.coinId); + if (d.groupId) env.SPHERE_GROUP_ID = String(d.groupId); + + if (d.content) env.SPHERE_MESSAGE = String(d.content); + else if (d.memo) env.SPHERE_MESSAGE = String(d.memo); + else if (d.message) env.SPHERE_MESSAGE = String(d.message); + + // For incoming transfers, sum token amounts + if (Array.isArray(d.tokens) && d.tokens.length > 0 && !d.amount) { + try { + const total = d.tokens.reduce((sum: bigint, t: Record) => { + return sum + BigInt(String(t.amount || '0')); + }, BigInt(0)); + env.SPHERE_AMOUNT = total.toString(); + } catch { + // Ignore amount calculation errors + } + } + } + + return env; +} + +// ============================================================================= +// Action Executors +// ============================================================================= + +function executeBash(action: BashAction, envVars: Record, globalTimeout: number): Promise { + const timeout = action.timeout || globalTimeout; + return new Promise((resolve) => { + // SECURITY: `command` is executed via exec() with full shell interpretation. + // The daemon config file must be chmod 600 and writable only by the daemon user. + // Never accept daemon config from untrusted sources. + const child = exec(action.command, { + env: { ...process.env, ...envVars }, + timeout, + cwd: action.cwd || process.cwd(), + }, (error, stdout, stderr) => { + if (error) { + log(` [BASH ERROR] ${action.command}: ${error.message}`); + } + if (stdout) { + for (const line of stdout.trimEnd().split('\n')) { + log(` [BASH] ${line}`); + } + } + if (stderr) { + for (const line of stderr.trimEnd().split('\n')) { + log(` [BASH STDERR] ${line}`); + } + } + resolve(); + }); + + // Pipe event JSON to stdin. Ignore EPIPE in case the child closes stdin early. + if (child.stdin) { + child.stdin.on('error', () => { /* ignore EPIPE etc. */ }); + child.stdin.write(envVars.SPHERE_EVENT_JSON); + child.stdin.end(); + } + }); +} + +async function executeWebhook(action: WebhookAction, eventType: string, data: unknown, globalTimeout: number): Promise { + const timeout = action.timeout || Math.min(globalTimeout, 10000); + const method = action.method || 'POST'; + const body = JSON.stringify({ + event: eventType, + timestamp: new Date().toISOString(), + data, + }); + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + const response = await fetch(action.url, { + method, + headers: { + 'Content-Type': 'application/json', + ...action.headers, + }, + body, + signal: controller.signal, + }); + + clearTimeout(timer); + + if (!response.ok) { + log(` [WEBHOOK ERROR] ${action.url}: HTTP ${response.status}`); + } else { + log(` [WEBHOOK] ${action.url}: ${response.status}`); + } + } catch (err) { + log(` [WEBHOOK ERROR] ${action.url}: ${err instanceof Error ? err.message : err}`); + } +} + +async function executeAutoReceive(action: BuiltinAction, sphere: Sphere): Promise { + try { + const finalize = action.finalize !== false; + const result = await sphere.payments.receive({ finalize }); + log(` [AUTO-RECEIVE] Received ${result.transfers.length} transfer(s), finalize=${finalize}`); + try { + await sphere.payments.sync(); + log(' [AUTO-RECEIVE] Synced with IPFS'); + } catch (err) { + log(` [AUTO-RECEIVE] Sync warning: ${err instanceof Error ? err.message : err}`); + } + } catch (err) { + log(` [AUTO-RECEIVE ERROR] ${err instanceof Error ? err.message : err}`); + } +} + +function executeLogToFile(action: BuiltinAction, eventType: string, data: unknown): void { + const filePath = action.path!; + try { + ensureDir(filePath); + const entry = JSON.stringify({ + timestamp: new Date().toISOString(), + event: eventType, + data, + }) + '\n'; + fs.appendFileSync(filePath, entry); + log(` [LOG] → ${filePath}`); + } catch (err) { + log(` [LOG ERROR] ${filePath}: ${err instanceof Error ? err.message : err}`); + } +} + +// ============================================================================= +// Action Dispatch +// ============================================================================= + +async function executeAction( + action: DaemonAction, + eventType: string, + data: unknown, + envVars: Record, + sphere: Sphere, + globalTimeout: number, +): Promise { + switch (action.type) { + case 'bash': + await executeBash(action, envVars, globalTimeout); + break; + case 'webhook': + await executeWebhook(action, eventType, data, globalTimeout); + break; + case 'builtin': + if (action.action === 'auto-receive') { + await executeAutoReceive(action, sphere); + } else if (action.action === 'log-to-file') { + executeLogToFile(action, eventType, data); + } + break; + } +} + +async function dispatchRule( + rule: DaemonRule, + eventType: string, + data: unknown, + envVars: Record, + sphere: Sphere, + globalTimeout: number, +): Promise { + const ruleName = rule.name || '(unnamed)'; + + if (!matchesFilter(rule.filter, data)) return; + + const actionNames = rule.actions.map(a => { + if (a.type === 'builtin') return a.action; + if (a.type === 'bash') return `bash`; + return a.type; + }).join(', '); + + log(`[${new Date().toISOString()}] EVENT ${eventType} | rule:${ruleName} | actions: ${actionNames}`); + + if (rule.sequential) { + for (const action of rule.actions) { + await executeAction(action, eventType, data, envVars, sphere, globalTimeout); + } + } else { + await Promise.all( + rule.actions.map(action => + executeAction(action, eventType, data, envVars, sphere, globalTimeout) + ) + ); + } +} + +// ============================================================================= +// Logging +// ============================================================================= + +let logStream: fs.WriteStream | null = null; +let verboseMode = false; + +function log(message: string): void { + const line = message.startsWith('[') ? message : `[${new Date().toISOString()}] ${message}`; + if (logStream) { + logStream.write(line + '\n'); + } + console.log(line); +} + +// ============================================================================= +// Market Feed Integration +// ============================================================================= + +function setupMarketFeed( + sphere: Sphere, + dispatchMap: Map, + globalTimeout: number, + inflight: Set>, +): (() => void) | null { + if (!sphere.market) { + log('Warning: Market module not available, skipping market feed'); + return null; + } + + // Market feed events are dispatched as synthetic "market:feed" rules + const marketRules = dispatchMap.get('market:feed' as SphereEventType); + if (!marketRules || marketRules.length === 0) return null; + + const unsubscribe = sphere.market.subscribeFeed((message) => { + const envVars = buildEnvVars('market:feed', message); + for (const rule of marketRules) { + const p = dispatchRule(rule, 'market:feed', message, envVars, sphere, globalTimeout).catch(err => { + log(`[DISPATCH ERROR] market:feed: ${err instanceof Error ? err.message : err}`); + }); + inflight.add(p); + p.finally(() => inflight.delete(p)); + } + }); + + log('Subscribed to market feed'); + return unsubscribe; +} + +// ============================================================================= +// runDaemon — Foreground Mode +// ============================================================================= + +export async function runDaemon( + args: string[], + getSphere: () => Promise, + closeSphere: () => Promise, +): Promise { + const flags = parseDaemonFlags(args); + + // Detach mode: fork and exit parent + if (flags.detach && !flags._forked) { + return detachDaemon(args, flags); + } + + // Build or load config + let config: ResolvedDaemonConfig; + try { + const rawConfig = buildConfigFromFlags(flags); + config = resolveConfig(rawConfig); + } catch (err) { + console.error(`Config error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + verboseMode = flags.verbose; + + // ----------------------------------------------------------------------- + // Fix D-3: Install early signal handlers BEFORE any async setup begins, so + // a SIGTERM during getSphere() or subscription wiring still cleans up the + // PID file and exits cleanly instead of being handled by the default + // terminating action. + // ----------------------------------------------------------------------- + let shuttingDown = false; + let fullShutdown: (() => Promise) | null = null; + + const earlyShutdown = (): void => { + if (shuttingDown) return; + shuttingDown = true; + if (fullShutdown) { + fullShutdown().catch(() => { /* swallow */ }).finally(() => process.exit(0)); + } else { + // Signal arrived before setup completed — best-effort PID file cleanup. + if (flags._forked || flags.detach) { + safeUnlink(config.pidFile); + } + process.exit(0); + } + }; + + process.on('SIGTERM', earlyShutdown); + process.on('SIGINT', earlyShutdown); + + // In forked mode, redirect stdout/stderr to log file + if (flags._forked) { + ensureDir(config.logFile); + const stream = fs.createWriteStream(config.logFile, { flags: 'a' }); + logStream = stream; + // Redirect console output to log file + const origLog = console.log; + const origErr = console.error; + const origWarn = console.warn; + console.log = (...a: unknown[]) => { stream.write(a.map(String).join(' ') + '\n'); }; + console.error = (...a: unknown[]) => { stream.write('[ERROR] ' + a.map(String).join(' ') + '\n'); }; + console.warn = (...a: unknown[]) => { stream.write('[WARN] ' + a.map(String).join(' ') + '\n'); }; + + // Fix D-1 + D-2: Exclusive-create the PID file with JSON payload so two + // concurrent `sphere daemon start` invocations don't both succeed, and so + // stop logic can detect PID reuse via the nonce + cmd fields. + ensureDir(config.pidFile); + const pidData: PidFileData = { + pid: process.pid, + nonce: Date.now(), + cmd: 'sphere-daemon', + }; + try { + writePidFileExclusive(config.pidFile, pidData); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'EEXIST') { + process.stderr.write( + 'sphere daemon: another daemon instance is already starting. Exiting.\n' + ); + process.exit(1); + } + throw e; + } + + // Disconnect from parent + if (process.disconnect) process.disconnect(); + + // Restore on exit for cleanup logging + process.on('exit', () => { + console.log = origLog; + console.error = origErr; + console.warn = origWarn; + }); + } + + // Filter out disabled rules + const activeRules = config.rules.filter(r => !r.disabled); + if (activeRules.length === 0) { + log('No active rules in config. Nothing to do.'); + process.exit(0); + } + + // Build dispatch map + const dispatchMap = buildDispatchMap(activeRules); + const subscribedEvents = [...dispatchMap.keys()].filter(e => e !== 'market:feed' as string); + + log('Starting Sphere daemon...'); + log(`Active rules: ${activeRules.length}`); + log(`Subscribed events: ${subscribedEvents.join(', ') || '(none)'}`); + if (config.marketFeed || dispatchMap.has('market:feed' as SphereEventType)) { + log('Market feed: enabled'); + } + + // Initialize Sphere + let sphere: Sphere; + try { + sphere = await getSphere(); + } catch (err) { + log(`Failed to initialize Sphere: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + const identity = sphere.identity; + if (identity) { + log(`Wallet: ${identity.nametag ? '@' + identity.nametag : identity.l1Address}`); + } + + // Fix D-4: Track in-flight dispatch promises so shutdown can await them. + const inflight = new Set>(); + + // Subscribe to events + const unsubscribers: (() => void)[] = []; + + for (const eventType of subscribedEvents) { + const rules = dispatchMap.get(eventType)!; + const unsub = sphere.on(eventType, (data: unknown) => { + if (verboseMode) { + log(`[${new Date().toISOString()}] EVENT ${eventType} data=${JSON.stringify(data)}`); + } + const envVars = buildEnvVars(eventType, data); + for (const rule of rules) { + const p = dispatchRule(rule, eventType, data, envVars, sphere, config.actionTimeout).catch(err => { + log(`[DISPATCH ERROR] ${eventType}: ${err instanceof Error ? err.message : err}`); + }); + inflight.add(p); + p.finally(() => inflight.delete(p)); + } + }); + unsubscribers.push(unsub); + } + + // Market feed + if (config.marketFeed || dispatchMap.has('market:feed' as SphereEventType)) { + const unsub = setupMarketFeed(sphere, dispatchMap, config.actionTimeout, inflight); + if (unsub) unsubscribers.push(unsub); + } + + log('Daemon running. Waiting for events...'); + + // Graceful shutdown + const shutdown = async (): Promise => { + log('Shutting down daemon...'); + + for (const unsub of unsubscribers) { + try { unsub(); } catch { /* ignore */ } + } + + // Fix D-4: Wait for in-flight dispatches to drain (max 10s) before + // tearing down Sphere or exiting. Prevents mid-action corruption of + // token state when SIGTERM arrives during auto-receive / bash / webhook. + if (inflight.size > 0) { + await Promise.race([ + Promise.allSettled([...inflight]), + new Promise(resolve => setTimeout(resolve, 10_000)), + ]); + } + + try { await closeSphere(); } catch { /* ignore */ } + + // Fix D-7: replace existsSync+unlinkSync with safeUnlink (no TOCTOU). + if (flags._forked || flags.detach) { + try { safeUnlink(config.pidFile); } catch { /* ignore */ } + } + + // Fix D-5 / D-9: log final message BEFORE ending the stream, then await + // end() so pending writes flush before we exit. + log('Daemon stopped.'); + if (logStream) { + const stream = logStream; + logStream = null; + await new Promise(resolve => { + try { + stream.end(() => resolve()); + } catch { + resolve(); + } + }); + } + }; + + // Swap the early handler for the real one now that setup is complete. + fullShutdown = shutdown; + + // Keep alive + await new Promise(() => {}); +} + +// ============================================================================= +// Detach Mode +// ============================================================================= + +function detachDaemon(args: string[], flags: DaemonFlags): void { + // Build the resolved config just to get the PID file path + let pidFile: string; + try { + const rawConfig = buildConfigFromFlags(flags); + const config = resolveConfig(rawConfig); + pidFile = config.pidFile; + } catch { + pidFile = getDefaultPidFile(); + } + + // Check if already running. Note: this check is advisory only — the forked + // child performs an atomic exclusive-create on the PID file so concurrent + // `daemon start` invocations won't both succeed (Fix D-1). + const existing = readPidFile(pidFile); + if (existing) { + if (isDaemonProcessAlive(existing.pid)) { + console.error(`Daemon already running (PID ${existing.pid}). Use "daemon stop" first.`); + process.exit(1); + } + // Stale PID file (process dead OR PID reused by unrelated process). + safeUnlink(pidFile); + } + + // Build child args: replace --detach with --_forked, keep everything else + const childArgs = ['daemon', 'start', '--_forked', ...args.filter(a => a !== '--detach')]; + + // Fork the child process + const child = fork(process.argv[1], childArgs, { + detached: true, + stdio: 'ignore', + }); + + child.unref(); + + console.log(`Daemon started in background (PID ${child.pid})`); + console.log(`PID file: ${pidFile}`); + + if (flags.logFile) { + console.log(`Log file: ${flags.logFile}`); + } else { + console.log('Log file: .sphere-cli/daemon.log'); + } + + process.exit(0); +} + +// ============================================================================= +// stopDaemon +// ============================================================================= + +export async function stopDaemon(args: string[]): Promise { + const flags = parseDaemonFlags(args); + const pidFile = flags.pidFile || getDefaultPidFile(); + + const pidData = readPidFile(pidFile); + if (!pidData) { + console.log('No daemon running (PID file not found).'); + return; + } + const pid = pidData.pid; + + // Fix D-2: Check liveness + PID reuse detection. If the PID is alive but + // appears to be a different (non-node) process, treat as stale so we don't + // SIGTERM someone else's editor/shell. + if (!isDaemonProcessAlive(pid)) { + console.log(`Stale PID file (process ${pid} not running or reused). Cleaning up.`); + safeUnlink(pidFile); + return; + } + + console.log(`Stopping daemon (PID ${pid})...`); + + // Fix D-6: SIGTERM may race against process exit (ESRCH). Treat as already-dead. + try { + process.kill(pid, 'SIGTERM'); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e; + // Already dead — fall through to cleanup. + } + + // Wait up to 5 seconds for graceful shutdown + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + await sleep(200); + if (!isProcessAlive(pid)) { + console.log('Daemon stopped.'); + // Fix D-7: no existsSync race — unlink-or-ignore-ENOENT. + safeUnlink(pidFile); + return; + } + } + + // Force kill + console.log('Graceful shutdown timed out, sending SIGKILL...'); + try { + process.kill(pid, 'SIGKILL'); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e; + // Process already exited. + } + + await sleep(500); + + safeUnlink(pidFile); + console.log('Daemon killed.'); +} + +// ============================================================================= +// statusDaemon +// ============================================================================= + +export async function statusDaemon(args: string[]): Promise { + const flags = parseDaemonFlags(args); + const pidFile = flags.pidFile || getDefaultPidFile(); + + const pidData = readPidFile(pidFile); + if (!pidData) { + console.log('Daemon is not running.'); + return; + } + const pid = pidData.pid; + + // Fix D-2: treat "alive but not a node process" as stale (PID reuse). + if (!isDaemonProcessAlive(pid)) { + console.log(`Daemon is not running (stale PID file, process ${pid}).`); + return; + } + + console.log(`Daemon is running (PID ${pid}).`); + console.log(`PID file: ${path.resolve(pidFile)}`); + + // Try to show config summary + try { + const rawConfig = buildConfigFromFlags(flags); + const config = resolveConfig(rawConfig); + console.log(`Log file: ${path.resolve(config.logFile)}`); + console.log(`Rules: ${config.rules.filter(r => !r.disabled).length} active`); + if (config.marketFeed) console.log('Market feed: enabled'); + } catch { + // Config may not be loadable without full flags + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts new file mode 100644 index 0000000..8cc231b --- /dev/null +++ b/src/legacy/legacy-cli.ts @@ -0,0 +1,5112 @@ +#!/usr/bin/env npx tsx +/** + * Sphere SDK CLI + * Usage: npx tsx cli/index.ts [args...] + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import { encrypt, decrypt } from '@unicitylabs/sphere-sdk'; +import { parseWalletText, isTextWalletEncrypted, parseAndDecryptWalletText } from '@unicitylabs/sphere-sdk'; +import { parseWalletDat, isSQLiteDatabase, isWalletDatEncrypted } from '@unicitylabs/sphere-sdk'; +import { isValidPrivateKey, base58Encode, base58Decode } from '@unicitylabs/sphere-sdk'; +import { hexToWIF, generatePrivateKey } from '@unicitylabs/sphere-sdk'; +import { toSmallestUnit, toHumanReadable, formatAmount } from '@unicitylabs/sphere-sdk'; +import { getPublicKey } from '@unicitylabs/sphere-sdk'; +import { generateAddressFromMasterKey } from '@unicitylabs/sphere-sdk'; +import { Sphere } from '@unicitylabs/sphere-sdk'; +import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; +import { TokenRegistry } from '@unicitylabs/sphere-sdk'; +import { TokenValidator } from '@unicitylabs/sphere-sdk'; +import { tokenToTxf } from '@unicitylabs/sphere-sdk'; +import type { NetworkType } from '@unicitylabs/sphere-sdk'; +import type { TransportProvider } from '@unicitylabs/sphere-sdk'; +import type { ProviderStatus } from '@unicitylabs/sphere-sdk'; +import type { + CreateInvoiceRequest, + InvoiceRequestedAsset, + GetInvoicesOptions, + PayInvoiceParams, + ReturnPaymentParams, + TxfToken, +} from '@unicitylabs/sphere-sdk'; + +/** + * Strip prototype-pollution-prone keys from user-supplied JSON before passing + * it deeper into the SDK. Guards against `__proto__`, `constructor`, and + * `prototype` keys that could mutate Object.prototype or bypass checks. + */ +function writeAtomic(filePath: string, contents: string): void { + const tmp = filePath + '.tmp'; + fs.writeFileSync(tmp, contents, 'utf8'); + fs.renameSync(tmp, filePath); +} + +function stripDangerousKeys(value: unknown, depth = 0): unknown { + if (depth > 20 || value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(v => stripDangerousKeys(v, depth + 1)); + const out: Record = Object.create(null); + for (const [k, v] of Object.entries(value as Record)) { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; + out[k] = stripDangerousKeys(v, depth + 1); + } + return out; +} + +let args: string[] = []; +let command: string | undefined; + +// ============================================================================= +// CLI Configuration +// ============================================================================= + +const DEFAULT_DATA_DIR = './.sphere-cli'; +const DEFAULT_TOKENS_DIR = './.sphere-cli/tokens'; +const CONFIG_FILE = './.sphere-cli/config.json'; +const PROFILES_FILE = './.sphere-cli/profiles.json'; + +interface CliConfig { + network: NetworkType; + dataDir: string; + tokensDir: string; + currentProfile?: string; +} + +interface WalletProfile { + name: string; + dataDir: string; + tokensDir: string; + network: NetworkType; + createdAt: string; +} + +interface ProfilesStore { + profiles: WalletProfile[]; +} + +function loadConfig(): CliConfig { + try { + if (fs.existsSync(CONFIG_FILE)) { + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + } + } catch { + // Use defaults + } + return { + network: 'testnet', + dataDir: DEFAULT_DATA_DIR, + tokensDir: DEFAULT_TOKENS_DIR, + }; +} + +function saveConfig(config: CliConfig): void { + fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true }); + writeAtomic(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +// ============================================================================= +// Wallet Profile Management +// ============================================================================= + +function loadProfiles(): ProfilesStore { + try { + if (fs.existsSync(PROFILES_FILE)) { + return JSON.parse(fs.readFileSync(PROFILES_FILE, 'utf8')); + } + } catch { + // Use defaults + } + return { profiles: [] }; +} + +function saveProfiles(store: ProfilesStore): void { + fs.mkdirSync(path.dirname(PROFILES_FILE), { recursive: true }); + writeAtomic(PROFILES_FILE, JSON.stringify(store, null, 2)); +} + +function getProfile(name: string): WalletProfile | undefined { + const store = loadProfiles(); + return store.profiles.find(p => p.name === name); +} + +function addProfile(profile: WalletProfile): void { + const store = loadProfiles(); + const existing = store.profiles.findIndex(p => p.name === profile.name); + if (existing >= 0) { + store.profiles[existing] = profile; + } else { + store.profiles.push(profile); + } + saveProfiles(store); +} + +function deleteProfile(name: string): boolean { + const store = loadProfiles(); + const index = store.profiles.findIndex(p => p.name === name); + if (index >= 0) { + store.profiles.splice(index, 1); + saveProfiles(store); + return true; + } + return false; +} + +function switchToProfile(name: string): boolean { + const profile = getProfile(name); + if (!profile) return false; + + const config = loadConfig(); + config.dataDir = profile.dataDir; + config.tokensDir = profile.tokensDir; + config.network = profile.network; + config.currentProfile = name; + saveConfig(config); + return true; +} + +// ============================================================================= +// Sphere Instance Management +// ============================================================================= + +let sphereInstance: Sphere | null = null; +let noNostrGlobal = false; + +/** + * Create a no-op transport that does nothing. + * Used with --no-nostr to prove IPFS-only recovery. + */ +function createNoopTransport(): TransportProvider { + return { + id: 'noop-transport', + name: 'No-Op Transport', + type: 'p2p' as const, + description: 'No-op transport (Nostr disabled)', + setIdentity: () => {}, + connect: async () => {}, + disconnect: async () => {}, + isConnected: () => false, + getStatus: () => 'disconnected' as ProviderStatus, + sendMessage: async () => '', + onMessage: () => () => {}, + sendTokenTransfer: async () => '', + onTokenTransfer: () => () => {}, + fetchPendingEvents: async () => {}, + }; +} + +async function getSphere(options?: { autoGenerate?: boolean; mnemonic?: string; nametag?: string }): Promise { + if (sphereInstance) return sphereInstance; + + const config = loadConfig(); + const providers = createNodeProviders({ + network: config.network, + dataDir: config.dataDir, + tokensDir: config.tokensDir, + tokenSync: { ipfs: { enabled: true } }, + market: true, + groupChat: true, + }); + + const initProviders = noNostrGlobal + ? { ...providers, transport: createNoopTransport() } + : providers; + + const result = await Sphere.init({ + ...initProviders, + autoGenerate: options?.autoGenerate, + mnemonic: options?.mnemonic, + nametag: options?.nametag, + market: true, + groupChat: true, + accounting: true, + swap: true, + }); + + sphereInstance = result.sphere; + + // Attach IPFS storage provider for sync if available + if (providers.ipfsTokenStorage) { + await sphereInstance.addTokenStorageProvider(providers.ipfsTokenStorage); + } + + return sphereInstance; +} + +async function closeSphere(): Promise { + if (sphereInstance) { + await sphereInstance.destroy(); + sphereInstance = null; + } +} + +/** Push local state changes to IPFS after write operations. Tolerates failures. */ +async function syncAfterWrite(sphere: Sphere): Promise { + try { + await sphere.payments.sync(); + } catch { + // Tolerated — IPFS may be unavailable + } +} + +/** + * Resolve a coin identifier (symbol, name, or hex ID) to its registry definition. + * Tries symbol first, then name, then raw hex ID. + * Exits with error if not found. + */ +function resolveCoin(identifier: string): { coinId: string; symbol: string; decimals: number; name: string } { + const registry = TokenRegistry.getInstance(); + let def = registry.getDefinitionBySymbol(identifier); + if (!def) def = registry.getDefinitionByName(identifier); + if (!def) def = registry.getDefinition(identifier); + if (!def) { + console.error(`Unknown asset: "${identifier}"`); + console.error('Use "npm run cli -- assets" to list all registered assets.'); + process.exit(1); + } + return { + coinId: def.id, + symbol: def.symbol || identifier, + decimals: def.decimals ?? 8, + name: def.name || identifier, + }; +} + +/** + * Parse an asset argument in " " format. + * Examples: "1000000 UCT", "10.5 BTC", "500000 USDU" + */ +/** + * Resolve a swap ID prefix to a full 64-char swap ID. + * Accepts full IDs or unique prefixes (like invoice commands do). + */ + +function parseAssetArg(value: string): { amount: string; coin: string } { + const parts = value.trim().split(/\s+/); + if (parts.length !== 2) { + console.error(`Invalid asset format: "${value}". Expected " " (e.g., "1000000 UCT")`); + process.exit(1); + } + return { amount: parts[0], coin: parts[1] }; +} + +/** Map common symbols to faucet coin names. */ +const FAUCET_COIN_MAP: Record = { + 'UCT': 'unicity', 'BTC': 'bitcoin', 'ETH': 'ethereum', + 'SOL': 'solana', 'USDT': 'tether', 'USDC': 'usd-coin', + 'USDU': 'unicity-usd', 'EURU': 'unicity-eur', 'ALPHT': 'alpha-token', +}; + +/** + * Ensure wallet state is synced before reading: + * 1. Wait for Nostr transport to deliver pending wallet events (DMs, transfers) + * 2. Process incoming transfers (receive) + * 3. Sync with IPFS + * + * Replaces the old syncIfEnabled(). All CLI commands that read wallet state + * should call this before accessing in-memory data. + */ +/** + * Sync wallet state before executing a command. + * All sync steps tolerate failures (Nostr timeout, IPFS unreachable). + * Nostr WRITES (sendDM in swap-propose, swap-accept) propagate errors + * naturally through the SwapModule — they are NOT part of this sync. + * + * Two sync modes: + * + * - **'nostr'**: Fetch pending Nostr events only (DMs: swap proposals, + * invoice receipts, escrow messages, transfer notifications). + * Used by: swap-list, swap-accept, swap-status, receive, dm-inbox, dm-history. + * + * - **'full'**: Nostr fetch + receive incoming transfers + IPFS sync. + * Gives the most up-to-date token inventory and invoice state. + * Used by: send, balance, tokens, history, verify-balance, sync, + * invoice-pay, invoice-close, invoice-cancel, invoice-transfers, swap-deposit. + */ +async function ensureSync(sphere: Sphere, mode: 'nostr' | 'full'): Promise { + console.log('Syncing...'); + + // Step 1: Always fetch pending Nostr events through the multi-address + // transport mux. This ensures DMs (swap proposals, invoice receipts, + // escrow messages) are delivered to all module handlers. + // Tolerates relay being down — continues after timeout. + try { + await sphere.fetchPendingEvents(); + // Allow async DM handlers (swap proposal processing, invoice import, etc.) + // to complete before reading in-memory state. These fire-and-forget handlers + // resolve addresses, validate manifests, and persist state asynchronously. + await new Promise(resolve => setTimeout(resolve, 500)); + } catch { + // Tolerated — relay may be unreachable for reads + } + + if (mode === 'full') { + // Step 2: Process incoming token transfers via Nostr. + // Tolerates failures — tokens may arrive later. + try { + const result = await sphere.payments.receive(); + if (result.transfers?.length > 0) { + console.log(` Received ${result.transfers.length} transfer(s)`); + } + } catch { + // Tolerated + } + + // Step 3: Sync with IPFS remote storage. + // Tolerates IPFS being down for both read and write. + try { + const result = await sphere.payments.sync(); + if (result.added > 0 || result.removed > 0) { + console.log(` IPFS: +${result.added} added, -${result.removed} removed`); + } + } catch { + // Tolerated — IPFS may be unavailable + } + } + + console.log(' Ready.'); +} + +// ============================================================================= +// Interactive Input +// ============================================================================= + +function _prompt(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +// ============================================================================= +// Per-command help definitions +// ============================================================================= + +interface CommandHelp { + usage: string; + description: string; + flags?: Array<{ flag: string; description: string; default?: string }>; + examples: string[]; + notes?: string[]; +} + +const COMMAND_HELP: Record = { + // --- WALLET --- + 'init': { + usage: 'init [--network ] [--mnemonic ""] [--nametag ]', + description: 'Create a new wallet or import an existing one from a mnemonic phrase. If no mnemonic is provided, a new 24-word mnemonic is generated automatically.', + flags: [ + { flag: '--network ', description: 'Network to use (mainnet, testnet, dev)', default: 'testnet' }, + { flag: '--mnemonic ""', description: 'Import wallet from BIP-39 mnemonic phrase (24 words in quotes)' }, + { flag: '--nametag ', description: 'Register a nametag during initialization (mints on-chain)' }, + { flag: '--no-nostr', description: 'Disable Nostr transport (use no-op transport)' }, + ], + examples: [ + 'npm run cli -- init --network testnet', + 'npm run cli -- init --mnemonic "word1 word2 ... word24"', + 'npm run cli -- init --nametag alice --network mainnet', + ], + notes: [ + 'If a wallet already exists in the current profile, it will be loaded instead of creating a new one.', + 'Back up the generated mnemonic immediately -- it cannot be retrieved later.', + ], + }, + 'status': { + usage: 'status', + description: 'Show wallet identity information including L1 address, Direct address, chain public key, nametag, and current network/profile.', + examples: [ + 'npm run cli -- status', + ], + }, + 'config': { + usage: 'config [set ]', + description: 'Show current CLI configuration or update a specific setting. Without arguments, displays all config values as JSON.', + flags: [ + { flag: 'set network ', description: 'Set network (mainnet, testnet, dev)' }, + { flag: 'set dataDir ', description: 'Set wallet data directory path' }, + { flag: 'set tokensDir ', description: 'Set token storage directory path' }, + ], + examples: [ + 'npm run cli -- config', + 'npm run cli -- config set network mainnet', + 'npm run cli -- config set dataDir ./.my-wallet', + ], + }, + 'clear': { + usage: 'clear', + description: 'Delete all wallet data including keys, tokens, and storage. This is irreversible -- make sure you have your mnemonic backed up.', + examples: [ + 'npm run cli -- clear', + ], + notes: [ + 'This deletes everything in the current profile data and token directories.', + ], + }, + 'wallet': { + usage: 'wallet ', + description: 'Manage wallet profiles. Each profile has its own data directory, token storage, and network configuration.', + examples: [ + 'npm run cli -- wallet list', + 'npm run cli -- wallet create myprofile', + 'npm run cli -- wallet use myprofile', + 'npm run cli -- wallet current', + 'npm run cli -- wallet delete myprofile', + ], + notes: [ + 'Subcommands: list, create, use, current, delete', + 'Use "help wallet " for detailed help on each.', + ], + }, + 'wallet list': { + usage: 'wallet list', + description: 'List all wallet profiles with their network and data directory. The current active profile is marked with an arrow.', + examples: [ + 'npm run cli -- wallet list', + ], + }, + 'wallet create': { + usage: 'wallet create [--network ]', + description: 'Create a new wallet profile and switch to it. The profile stores its data in .sphere-cli-/. After creating, run "init" to initialize the wallet.', + flags: [ + { flag: '--network ', description: 'Network for the profile (mainnet, testnet, dev)', default: 'testnet' }, + ], + examples: [ + 'npm run cli -- wallet create alice', + 'npm run cli -- wallet create bob --network mainnet', + ], + notes: [ + 'Profile names must be unique. After creation, initialize with: npm run cli -- init --nametag ', + ], + }, + 'wallet use': { + usage: 'wallet use ', + description: 'Switch the active wallet profile. All subsequent commands will operate on this profile.', + examples: [ + 'npm run cli -- wallet use alice', + ], + }, + 'wallet current': { + usage: 'wallet current', + description: 'Show the currently active wallet profile, its network, data directory, and identity (if initialized).', + examples: [ + 'npm run cli -- wallet current', + ], + }, + 'wallet delete': { + usage: 'wallet delete ', + description: 'Remove a wallet profile from the profiles list. The on-disk data directory is NOT deleted -- remove it manually if needed.', + examples: [ + 'npm run cli -- wallet delete bob', + ], + notes: [ + 'Cannot delete the currently active profile. Switch to a different profile first.', + ], + }, + + // --- BALANCE & TOKENS --- + 'balance': { + usage: 'balance [--finalize] [--no-sync]', + description: 'Show L3 token balance grouped by coin. Receives any pending incoming transfers before displaying. Shows confirmed and unconfirmed amounts, token counts, and USD value.', + flags: [ + { flag: '--finalize', description: 'Wait for unconfirmed tokens to be finalized (may take several seconds)' }, + { flag: '--no-sync', description: 'Skip IPFS sync before showing balance' }, + ], + examples: [ + 'npm run cli -- balance', + 'npm run cli -- balance --finalize', + 'npm run cli -- balance --no-sync', + ], + }, + 'tokens': { + usage: 'tokens [--no-sync]', + description: 'List all individual tokens with their ID, coin type, amount, and status.', + flags: [ + { flag: '--no-sync', description: 'Skip IPFS sync before listing tokens' }, + ], + examples: [ + 'npm run cli -- tokens', + 'npm run cli -- tokens --no-sync', + ], + }, + 'assets': { + usage: 'assets [--type ]', + description: 'List all registered assets (coins and NFTs) from the token registry. Shows symbol, name, kind, decimals, and coin ID.', + flags: [ + { flag: '--type ', description: 'Filter by asset kind: fungible (or coin/coins), nft (or nfts/non-fungible)' }, + ], + examples: [ + 'npm run cli -- assets', + 'npm run cli -- assets --type fungible', + 'npm run cli -- assets --type nft', + ], + notes: [ + 'Requires wallet initialization (to load the token registry from the network).', + 'The registry is fetched from a remote URL and cached locally.', + ], + }, + 'asset-info': { + usage: 'asset-info ', + description: 'Show detailed information for a specific asset. Looks up by symbol (UCT), name (bitcoin), or coin ID hex.', + examples: [ + 'npm run cli -- asset-info UCT', + 'npm run cli -- asset-info bitcoin', + 'npm run cli -- asset-info 0a1b2c3d4e5f...', + ], + notes: [ + 'Use "assets" command first to see all available assets.', + 'Lookup is case-insensitive for symbols and names.', + ], + }, + 'l1-balance': { + usage: 'l1-balance', + description: 'Show L1 (ALPHA blockchain) balance including confirmed and unconfirmed amounts. Connects to Fulcrum electrum server on first use.', + examples: [ + 'npm run cli -- l1-balance', + ], + }, + 'topup': { + usage: 'topup [ ]', + description: 'Request test tokens from the Unicity faucet. Without arguments, requests default amounts of all supported coins. With amount and coin, requests a specific coin.', + flags: [ + { flag: '', description: 'Amount to request (numeric)' }, + { flag: '', description: 'Coin symbol (UCT, BTC, ETH, SOL, USDT, USDC, USDU, EURU, ALPHT) or faucet name (unicity, bitcoin, ethereum, solana, tether, usd-coin, unicity-usd)' }, + ], + examples: [ + 'npm run cli -- topup', + 'npm run cli -- topup 100 UCT', + 'npm run cli -- topup 2 BTC', + 'npm run cli -- topup 42 ETH', + ], + notes: [ + 'Requires a registered nametag. The faucet is only available on testnet.', + 'Also accessible as "top-up" or "faucet".', + ], + }, + 'top-up': { + usage: 'top-up [ ]', + description: 'Alias for "topup". Request test tokens from the Unicity faucet.', + examples: [ + 'npm run cli -- top-up', + 'npm run cli -- top-up 2 BTC', + ], + notes: [ + 'This is an alias for the "topup" command. See "help topup" for full details.', + ], + }, + 'faucet': { + usage: 'faucet [ ]', + description: 'Alias for "topup". Request test tokens from the Unicity faucet.', + examples: [ + 'npm run cli -- faucet', + 'npm run cli -- faucet 100 ETH', + ], + notes: [ + 'This is an alias for the "topup" command. See "help topup" for full details.', + ], + }, + 'verify-balance': { + usage: 'verify-balance [--remove] [-v|--verbose]', + description: 'Verify all tokens against the aggregator to detect spent tokens that were not properly removed from local storage. Useful for cleaning up stale token state.', + flags: [ + { flag: '--remove', description: 'Move detected spent tokens to the Sent (archive) folder' }, + { flag: '-v, --verbose', description: 'Show all tokens, not just spent ones; show progress and errors' }, + ], + examples: [ + 'npm run cli -- verify-balance', + 'npm run cli -- verify-balance --verbose', + 'npm run cli -- verify-balance --remove', + ], + }, + 'sync': { + usage: 'sync', + description: 'Sync tokens with IPFS remote storage. Uploads local tokens and downloads any tokens stored remotely.', + examples: [ + 'npm run cli -- sync', + ], + }, + + // --- TRANSFERS --- + 'send': { + usage: 'send [--direct|--proxy] [--instant|--conservative] [--no-sync]', + description: 'Send L3 tokens to a recipient. The recipient can be a @nametag, DIRECT:// address, chain public key (02/03 prefix), or alpha1... L1 address. Amount is in decimal (e.g., 0.5) and is converted to smallest units automatically.', + flags: [ + { flag: '', description: 'Asset symbol (UCT, BTC) as positional argument after amount', default: 'UCT' }, + { flag: '--direct', description: 'Force DirectAddress transfer (requires nametag with directAddress)' }, + { flag: '--proxy', description: 'Force PROXY address transfer (works with any nametag)' }, + { flag: '--instant', description: 'Send immediately via Nostr; receiver gets unconfirmed token', default: 'yes' }, + { flag: '--conservative', description: 'Collect all proofs first; receiver gets confirmed token' }, + { flag: '--no-sync', description: 'Skip IPFS sync after sending' }, + ], + examples: [ + 'npm run cli -- send @alice 10 UCT', + 'npm run cli -- send @alice 0.5 BTC', + 'npm run cli -- send DIRECT://0000be36... 500000 UCT --conservative', + 'npm run cli -- send @bob 100 USDU --no-sync', + ], + notes: [ + 'Cannot use both --direct and --proxy simultaneously.', + 'Cannot use both --instant and --conservative simultaneously.', + ], + }, + 'receive': { + usage: 'receive [--finalize] [--no-sync]', + description: 'Check for incoming token transfers via Nostr. Displays receive addresses and fetches any pending transfers.', + flags: [ + { flag: '--finalize', description: 'Wait for unconfirmed tokens to be finalized before returning' }, + { flag: '--no-sync', description: 'Skip IPFS sync after receiving' }, + ], + examples: [ + 'npm run cli -- receive', + 'npm run cli -- receive --finalize', + ], + }, + 'history': { + usage: 'history [limit] [--no-sync]', + description: 'Show transaction history ordered by most recent first. Each entry shows date, direction, amount, coin, and counterparty.', + flags: [ + { flag: '', description: 'Maximum number of transactions to show', default: '10' }, + { flag: '--no-sync', description: 'Skip sync before showing history' }, + ], + examples: [ + 'npm run cli -- history', + 'npm run cli -- history 20', + ], + }, + + // --- ADDRESSES --- + 'addresses': { + usage: 'addresses', + description: 'List all tracked HD addresses with their index, L1 address, DIRECT address, nametag, and hidden status. The currently active address is marked with an arrow.', + examples: [ + 'npm run cli -- addresses', + ], + }, + 'switch': { + usage: 'switch ', + description: 'Switch to a different HD-derived address by index. Index 0 is the default. New addresses are created on first switch.', + examples: [ + 'npm run cli -- switch 1', + 'npm run cli -- switch 0', + ], + }, + 'hide': { + usage: 'hide ', + description: 'Hide an address from the active address list. Hidden addresses are excluded from getActiveAddresses() but still tracked.', + examples: [ + 'npm run cli -- hide 2', + ], + }, + 'unhide': { + usage: 'unhide ', + description: 'Unhide a previously hidden address, making it visible in the active address list again.', + examples: [ + 'npm run cli -- unhide 2', + ], + }, + + // --- NAMETAGS --- + 'nametag': { + usage: 'nametag ', + description: 'Register a nametag (@name) for the current address. Mints a nametag token on-chain and publishes to Nostr relay. The name should not include the @ prefix.', + examples: [ + 'npm run cli -- nametag alice', + 'npm run cli -- nametag myname', + ], + }, + 'nametag-info': { + usage: 'nametag-info ', + description: 'Look up information about a nametag, including the associated public key and addresses. Resolves via the Nostr relay.', + examples: [ + 'npm run cli -- nametag-info alice', + 'npm run cli -- nametag-info bob', + ], + }, + 'my-nametag': { + usage: 'my-nametag', + description: 'Show the nametag registered for the current address.', + examples: [ + 'npm run cli -- my-nametag', + ], + }, + 'nametag-sync': { + usage: 'nametag-sync', + description: 'Re-publish the current nametag identity binding with chainPubkey. Useful for fixing legacy nametags that were registered without the chainPubkey field.', + examples: [ + 'npm run cli -- nametag-sync', + ], + }, + + // --- MESSAGING --- + 'dm': { + usage: 'dm <@nametag|pubkey> ', + description: 'Send an encrypted direct message to a peer via Nostr (NIP-17 gift-wrapped).', + examples: [ + 'npm run cli -- dm @alice "Hello, how are you?"', + 'npm run cli -- dm @bob "Payment sent for order #42"', + ], + }, + 'dm-inbox': { + usage: 'dm-inbox', + description: 'List all DM conversations with unread counts and last message preview.', + examples: [ + 'npm run cli -- dm-inbox', + ], + }, + 'dm-history': { + usage: 'dm-history <@nametag|pubkey> [--limit ]', + description: 'Show message history for a specific conversation. Resolves @nametag to pubkey automatically.', + flags: [ + { flag: '--limit ', description: 'Maximum number of messages to display', default: '50' }, + ], + examples: [ + 'npm run cli -- dm-history @alice', + 'npm run cli -- dm-history @alice --limit 20', + ], + }, + + // --- GROUP CHAT --- + 'group-create': { + usage: 'group-create [--description ] [--private]', + description: 'Create a new NIP-29 group chat on the relay.', + flags: [ + { flag: '--description ', description: 'Group description text' }, + { flag: '--private', description: 'Create a private (invite-only) group' }, + ], + examples: [ + 'npm run cli -- group-create "Trading Chat"', + 'npm run cli -- group-create "Team Alpha" --description "Internal team chat" --private', + ], + }, + 'group-list': { + usage: 'group-list', + description: 'List all available public groups on the relay with their names, IDs, descriptions, and member counts.', + examples: [ + 'npm run cli -- group-list', + ], + }, + 'group-my': { + usage: 'group-my', + description: 'List groups you have joined, with unread message counts and last message preview.', + examples: [ + 'npm run cli -- group-my', + ], + }, + 'group-join': { + usage: 'group-join [--invite ]', + description: 'Join a group by its ID. Private groups require an invite code.', + flags: [ + { flag: '--invite ', description: 'Invite code for private groups' }, + ], + examples: [ + 'npm run cli -- group-join tradingchat', + 'npm run cli -- group-join privatechat --invite abc123', + ], + }, + 'group-leave': { + usage: 'group-leave ', + description: 'Leave a group you have previously joined.', + examples: [ + 'npm run cli -- group-leave tradingchat', + ], + }, + 'group-send': { + usage: 'group-send [--reply ]', + description: 'Send a message to a group. Optionally reply to a specific message by event ID.', + flags: [ + { flag: '--reply ', description: 'Event ID of the message to reply to' }, + ], + examples: [ + 'npm run cli -- group-send tradingchat "Hello everyone!"', + 'npm run cli -- group-send tradingchat "I agree" --reply abc123def', + ], + }, + 'group-messages': { + usage: 'group-messages [--limit ]', + description: 'Show recent messages in a group. Fetches from relay and marks the group as read.', + flags: [ + { flag: '--limit ', description: 'Maximum number of messages to display', default: '50' }, + ], + examples: [ + 'npm run cli -- group-messages tradingchat', + 'npm run cli -- group-messages tradingchat --limit 20', + ], + }, + 'group-members': { + usage: 'group-members ', + description: 'List members of a group with their nametags, roles (ADMIN/MOD), and join dates.', + examples: [ + 'npm run cli -- group-members tradingchat', + ], + }, + 'group-info': { + usage: 'group-info ', + description: 'Show detailed information about a group including name, visibility, description, member count, creation date, and relay URL.', + examples: [ + 'npm run cli -- group-info tradingchat', + ], + }, + + // --- MARKET --- + 'market-post': { + usage: 'market-post --type [options]', + description: 'Post an intent to the market bulletin board. Type is required.', + flags: [ + { flag: '--type ', description: 'Intent type: buy, sell, service, announcement, other (required)' }, + { flag: '--category ', description: 'Intent category for filtering' }, + { flag: '--price ', description: 'Price amount (numeric)' }, + { flag: '--currency ', description: 'Currency code (USD, UCT, etc.)' }, + { flag: '--location ', description: 'Location for geographic filtering' }, + { flag: '--contact ', description: 'Contact handle (e.g., @nametag, email)' }, + { flag: '--expires ', description: 'Expiration in days', default: '30' }, + ], + examples: [ + 'npm run cli -- market-post "Buying 100 UCT" --type buy', + 'npm run cli -- market-post "Selling ETH" --type sell --price 50 --currency USD', + 'npm run cli -- market-post "Web dev services" --type service --contact @alice', + ], + }, + 'market-search': { + usage: 'market-search [options]', + description: 'Search the market bulletin board using semantic search. Returns intents ranked by relevance score.', + flags: [ + { flag: '--type ', description: 'Filter by intent type' }, + { flag: '--category ', description: 'Filter by category' }, + { flag: '--min-price ', description: 'Minimum price filter' }, + { flag: '--max-price ', description: 'Maximum price filter' }, + { flag: '--min-score <0-1>', description: 'Minimum similarity score threshold' }, + { flag: '--location ', description: 'Location filter' }, + { flag: '--limit ', description: 'Maximum results to return', default: '10' }, + ], + examples: [ + 'npm run cli -- market-search "UCT tokens" --type sell --limit 5', + 'npm run cli -- market-search "tokens" --min-score 0.7', + 'npm run cli -- market-search "services" --min-price 10 --max-price 100', + ], + }, + 'market-my': { + usage: 'market-my', + description: 'List all intents you have posted to the market.', + examples: [ + 'npm run cli -- market-my', + ], + }, + 'market-close': { + usage: 'market-close ', + description: 'Close (delete) one of your market intents by its ID.', + examples: [ + 'npm run cli -- market-close abc123', + ], + }, + 'market-feed': { + usage: 'market-feed [--rest]', + description: 'Watch the live market listing feed via WebSocket. Shows new intents in real time. Use --rest for a one-shot fetch instead.', + flags: [ + { flag: '--rest', description: 'Use REST fallback: fetch recent listings once and exit' }, + ], + examples: [ + 'npm run cli -- market-feed', + 'npm run cli -- market-feed --rest', + ], + notes: [ + 'WebSocket mode runs indefinitely. Press Ctrl+C to stop.', + ], + }, + + // --- INVOICES --- + 'invoice-create': { + usage: 'invoice-create --target
--asset " " [options]', + description: 'Create a new invoice by specifying a target address and requested payment. Alternatively, load full terms from a JSON file with --terms. The invoice is minted as an on-chain token.', + flags: [ + { flag: '--target
', description: 'Target address (@nametag or DIRECT:// address) (required unless --terms)' }, + { flag: '--asset " "', description: 'Requested asset in " " format (e.g., "1000000 UCT")' }, + { flag: '--nft ', description: 'Request a specific NFT by token ID (instead of coin+amount)' }, + { flag: '--due ', description: 'Due date in ISO-8601 format (e.g., 2026-12-31)' }, + { flag: '--memo ', description: 'Invoice memo text' }, + { flag: '--delivery ', description: 'Delivery method description' }, + { flag: '--terms ', description: 'Load full CreateInvoiceRequest from a JSON file (overrides other flags)' }, + ], + examples: [ + 'npm run cli -- invoice-create --target @alice --asset "1000000 UCT"', + 'npm run cli -- invoice-create --target @alice --asset "500000 BTC" --memo "Order #42" --due 2026-12-31', + 'npm run cli -- invoice-create --terms invoice-terms.json', + ], + notes: [ + 'Amounts must be positive integers in smallest units (no decimals, no leading zeros).', + ], + }, + 'invoice-import': { + usage: 'invoice-import ', + description: 'Import an invoice from a TXF token JSON file. Parses the invoice terms from the token data and adds it to local tracking.', + examples: [ + 'npm run cli -- invoice-import ./received-invoice.json', + ], + }, + 'invoice-list': { + usage: 'invoice-list [--state ] [--role ] [--limit ]', + description: 'List invoices with optional filtering by state and role. States can be comma-separated.', + flags: [ + { flag: '--state ', description: 'Filter by state: OPEN, PARTIAL, COVERED, CLOSED, CANCELLED, EXPIRED (comma-separated)' }, + { flag: '--role ', description: 'Filter by role: "creator" (invoices you created) or "payer" (invoices targeting you)' }, + { flag: '--limit ', description: 'Maximum number of invoices to return' }, + ], + examples: [ + 'npm run cli -- invoice-list', + 'npm run cli -- invoice-list --state OPEN,PARTIAL --limit 5', + 'npm run cli -- invoice-list --role creator', + ], + }, + 'invoice-status': { + usage: 'invoice-status ', + description: 'Show detailed invoice status including state, per-target balance breakdown, total forward/back payments, and confirmation status. Accepts full ID or unique prefix.', + examples: [ + 'npm run cli -- invoice-status a1b2c3d4', + 'npm run cli -- invoice-status a1b2c3d4e5f6...', + ], + }, + 'invoice-close': { + usage: 'invoice-close [--auto-return]', + description: 'Close an invoice (terminal state). Freezes balances and stops dynamic recomputation. Optionally triggers auto-return for overpayments.', + flags: [ + { flag: '--auto-return', description: 'Trigger auto-return of excess payments on close' }, + ], + examples: [ + 'npm run cli -- invoice-close a1b2c3d4', + 'npm run cli -- invoice-close a1b2c3d4 --auto-return', + ], + }, + 'invoice-cancel': { + usage: 'invoice-cancel ', + description: 'Cancel an invoice (terminal state). If auto-return is enabled, payments will be automatically refunded.', + examples: [ + 'npm run cli -- invoice-cancel a1b2c3d4', + ], + }, + 'invoice-pay': { + usage: 'invoice-pay [--amount ] [--target-index ]', + description: 'Pay an invoice. By default pays the remaining amount for the first target. For multi-target invoices, specify --target-index.', + flags: [ + { flag: '--amount ', description: 'Amount to pay in smallest units (positive integer). Defaults to remaining amount.' }, + { flag: '--target-index ', description: 'Target index for multi-target invoices (0-based)', default: '0' }, + ], + examples: [ + 'npm run cli -- invoice-pay a1b2c3d4', + 'npm run cli -- invoice-pay a1b2c3d4 --amount 500000', + 'npm run cli -- invoice-pay a1b2c3d4 --target-index 1 --amount 250000', + ], + }, + 'invoice-return': { + usage: 'invoice-return --recipient
--asset " "', + description: 'Manually return a payment to a sender for a specific invoice.', + flags: [ + { flag: '--recipient
', description: 'Recipient address or @nametag (required)' }, + { flag: '--asset " "', description: 'Asset to return in " " format (e.g., "100000 UCT")' }, + ], + examples: [ + 'npm run cli -- invoice-return a1b2c3d4 --recipient @bob --asset "100000 UCT"', + ], + }, + 'invoice-receipts': { + usage: 'invoice-receipts ', + description: 'Send payment receipts via DM to each sender who contributed to this invoice. Typically used after closing an invoice.', + examples: [ + 'npm run cli -- invoice-receipts a1b2c3d4', + ], + }, + 'invoice-notices': { + usage: 'invoice-notices ', + description: 'Send cancellation notice DMs to each sender who contributed to a cancelled invoice.', + examples: [ + 'npm run cli -- invoice-notices a1b2c3d4', + ], + }, + 'invoice-auto-return': { + usage: 'invoice-auto-return [--enable|--disable] [--invoice ]', + description: 'Show or configure auto-return settings. Without flags, displays current settings. Auto-return automatically refunds payments received against closed or cancelled invoices.', + flags: [ + { flag: '--enable', description: 'Enable auto-return' }, + { flag: '--disable', description: 'Disable auto-return' }, + { flag: '--invoice ', description: 'Target a specific invoice (default: global "*" scope)' }, + ], + examples: [ + 'npm run cli -- invoice-auto-return', + 'npm run cli -- invoice-auto-return --enable', + 'npm run cli -- invoice-auto-return --disable --invoice a1b2c3d4', + ], + }, + 'invoice-transfers': { + usage: 'invoice-transfers ', + description: 'List all transfers related to an invoice in chronological order, including forward payments and returns.', + examples: [ + 'npm run cli -- invoice-transfers a1b2c3d4', + ], + }, + 'invoice-export': { + usage: 'invoice-export ', + description: 'Export an invoice to a JSON file. The file is saved as invoice-.json in the current directory.', + examples: [ + 'npm run cli -- invoice-export a1b2c3d4', + ], + }, + 'invoice-parse-memo': { + usage: 'invoice-parse-memo ', + description: 'Parse an invoice memo string (INV:...) and display its decoded fields.', + examples: [ + 'npm run cli -- invoice-parse-memo "INV:a1b2c3d4...:F"', + ], + }, + + // --- SWAPS --- + 'swap-propose': { + usage: 'swap-propose --to --offer " " --want " " [options]', + description: 'Propose a token swap deal to a counterparty. Both parties deposit tokens into an escrow, which executes the swap atomically.', + flags: [ + { flag: '--to ', description: 'Counterparty @nametag or address (required)' }, + { flag: '--offer " "', description: 'Asset you are offering (e.g., "10 BTC", "0.5 ETH")' }, + { flag: '--want " "', description: 'Asset you want in return (e.g., "100 USDU", "5 UCT")' }, + { flag: '--escrow
', description: 'Custom escrow address (optional, uses config default)' }, + { flag: '--timeout ', description: 'Swap timeout in seconds (60-86400)', default: '3600' }, + { flag: '--message ', description: 'Optional message to the counterparty' }, + ], + examples: [ + 'npm run cli -- swap-propose --to @bob --offer "10 UCT" --want "5 USDU"', + 'npm run cli -- swap-propose --to @bob --offer "1 BTC" --want "10 ETH" --timeout 7200 --message "Quick trade?"', + ], + notes: [ + 'Amounts are in human-readable units (e.g., "10 BTC" = 10 whole BTC). Decimals are supported (e.g., "0.5 ETH").', + ], + }, + 'swap-list': { + usage: 'swap-list [--all] [--role ] [--progress ]', + description: 'List swap deals. By default shows only open and in-progress swaps. Use --all to include completed/cancelled/failed swaps.', + flags: [ + { flag: '--all', description: 'Include terminal states (completed, cancelled, failed)' }, + { flag: '--role ', description: 'Filter by your role: "proposer" or "acceptor"' }, + { flag: '--progress ', description: 'Filter by progress state' }, + ], + examples: [ + 'npm run cli -- swap-list', + 'npm run cli -- swap-list --all', + 'npm run cli -- swap-list --role proposer', + 'npm run cli -- swap-list --all --role proposer', + ], + }, + 'swap-accept': { + usage: 'swap-accept [--deposit] [--no-wait]', + description: 'Accept a proposed swap deal. Announces acceptance to the escrow.', + flags: [ + { flag: '--deposit', description: 'Immediately deposit after accepting' }, + { flag: '--no-wait', description: 'Do not wait for swap completion after depositing (only with --deposit)' }, + ], + examples: [ + 'npm run cli -- swap-accept 3611a464', + 'npm run cli -- swap-accept 3611a464 --deposit', + 'npm run cli -- swap-accept 3611a464 --deposit --no-wait', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + 'Without --deposit, run "swap-deposit " separately when ready.', + ], + }, + 'swap-status': { + usage: 'swap-status [--query-escrow]', + description: 'Show detailed status of a swap deal including progress, deal terms, and deposit information.', + flags: [ + { flag: '--query-escrow', description: 'Query the escrow service for the latest status' }, + ], + examples: [ + 'npm run cli -- swap-status 3611a464', + 'npm run cli -- swap-status 3611a464 --query-escrow', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + 'If the swap has an associated deposit invoice, its status is also displayed.', + ], + }, + 'swap-deposit': { + usage: 'swap-deposit ', + description: 'Deposit tokens into an accepted swap. The swap must be in the "announced" state (accepted and awaiting deposits).', + examples: [ + 'npm run cli -- swap-deposit 3611a464', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + ], + }, + + 'swap-reject': { + usage: 'swap-reject [reason]', + description: 'Reject an incoming swap proposal. Sends a rejection DM to the proposer and marks the swap as cancelled.', + examples: [ + 'npm run cli -- swap-reject 3611a464', + 'npm run cli -- swap-reject 3611a464 "Price too high"', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + 'Only works on proposals you received (role: acceptor, progress: proposed).', + 'The optional reason is included in the rejection DM sent to the proposer.', + ], + }, + 'swap-cancel': { + usage: 'swap-cancel ', + description: 'Cancel a swap you proposed or accepted. Works before deposits are confirmed by the escrow.', + examples: [ + 'npm run cli -- swap-cancel 3611a464', + ], + notes: [ + 'Accepts full 64-char swap ID or a unique prefix (min 4 chars).', + 'Pre-deposit cancellation is local only (no escrow notification).', + 'Post-deposit cancellation: escrow handles timeout and returns deposits automatically.', + ], + }, + + // --- DAEMON --- + 'daemon': { + usage: 'daemon [options]', + description: 'Manage the persistent event listener daemon. The daemon listens for wallet events and triggers configured actions.', + examples: [ + 'npm run cli -- daemon start --event transfer:incoming --action auto-receive', + 'npm run cli -- daemon start --detach --event "*" --action "log:./events.jsonl"', + 'npm run cli -- daemon stop', + 'npm run cli -- daemon status', + ], + }, + 'daemon start': { + usage: 'daemon start [options]', + description: 'Start the persistent event listener. Can subscribe to specific events and trigger actions (auto-receive, webhook, bash command, or log file).', + flags: [ + { flag: '--config ', description: 'Config file path', default: '.sphere-cli/daemon.json' }, + { flag: '--detach', description: 'Run in background (fork process, PID file, redirect logs)' }, + { flag: '--log ', description: 'Override log file path' }, + { flag: '--pid ', description: 'Override PID file path' }, + { flag: '--event ', description: 'Event type to subscribe to (repeatable). Use "*" for all events.' }, + { flag: '--action ', description: 'Action to trigger: auto-receive, bash:, webhook:, log:' }, + { flag: '--market-feed', description: 'Also subscribe to the market WebSocket feed' }, + { flag: '--verbose', description: 'Print full event JSON in logs' }, + ], + examples: [ + 'npm run cli -- daemon start --event transfer:incoming --action auto-receive', + 'npm run cli -- daemon start --event "transfer:*" --action "webhook:https://example.com/hook" --detach', + 'npm run cli -- daemon start --event "*" --action "log:./events.jsonl" --verbose', + 'npm run cli -- daemon start --event message:dm --action "bash:echo DM from \\$SPHERE_SENDER"', + 'npm run cli -- daemon start --config ./my-daemon.json --detach', + ], + }, + 'daemon stop': { + usage: 'daemon stop', + description: 'Stop the running daemon process.', + examples: [ + 'npm run cli -- daemon stop', + ], + }, + 'daemon status': { + usage: 'daemon status', + description: 'Check if the daemon is currently running and show its PID.', + examples: [ + 'npm run cli -- daemon status', + ], + }, + + // --- ENCRYPTION --- + 'encrypt': { + usage: 'encrypt ', + description: 'Encrypt a string with AES using a password. Outputs the encrypted result as JSON.', + examples: [ + 'npm run cli -- encrypt "my secret data" mypassword', + ], + }, + 'decrypt': { + usage: 'decrypt ', + description: 'Decrypt an AES-encrypted JSON blob using a password. Outputs the original plaintext.', + examples: [ + 'npm run cli -- decrypt \'{"iv":"...","data":"..."}\' mypassword', + ], + }, + + // --- WALLET PARSING --- + 'parse-wallet': { + usage: 'parse-wallet [password]', + description: 'Parse a wallet backup file (.txt or .dat format). If the file is encrypted, a password is required.', + examples: [ + 'npm run cli -- parse-wallet wallet.txt', + 'npm run cli -- parse-wallet wallet.txt mypassword', + 'npm run cli -- parse-wallet wallet.dat', + ], + }, + 'wallet-info': { + usage: 'wallet-info ', + description: 'Show metadata about a wallet file: format (.txt, .dat, .json), whether it is encrypted, and other format-specific info.', + examples: [ + 'npm run cli -- wallet-info wallet.txt', + 'npm run cli -- wallet-info backup.dat', + ], + }, + + // --- KEY OPERATIONS --- + 'generate-key': { + usage: 'generate-key', + description: 'Generate a random secp256k1 private key and derive the public key, WIF, and L1 address from it.', + examples: [ + 'npm run cli -- generate-key', + ], + }, + 'validate-key': { + usage: 'validate-key ', + description: 'Validate whether a hex string is a valid secp256k1 private key. Exits with code 0 if valid, 1 if invalid.', + examples: [ + 'npm run cli -- validate-key 0a1b2c3d...', + ], + }, + 'hex-to-wif': { + usage: 'hex-to-wif ', + description: 'Convert a hex private key to Wallet Import Format (WIF).', + examples: [ + 'npm run cli -- hex-to-wif 0a1b2c3d...', + ], + }, + 'derive-pubkey': { + usage: 'derive-pubkey ', + description: 'Derive the compressed secp256k1 public key from a private key.', + examples: [ + 'npm run cli -- derive-pubkey 0a1b2c3d...', + ], + }, + 'derive-address': { + usage: 'derive-address [index]', + description: 'Derive an L1 (alpha1...) address from a private key at the given HD derivation index.', + flags: [ + { flag: '', description: 'HD derivation index', default: '0' }, + ], + examples: [ + 'npm run cli -- derive-address 0a1b2c3d...', + 'npm run cli -- derive-address 0a1b2c3d... 3', + ], + }, + + // --- CURRENCY --- + 'to-smallest': { + usage: 'to-smallest ', + description: 'Convert a human-readable amount to the smallest unit. Use the coin symbol to apply the correct decimals for a specific asset. Default uses 8 decimals if no coin specified.', + flags: [ + { flag: '', description: 'Asset symbol (UCT, BTC), name (bitcoin), or hex coin ID. Determines decimal precision.' }, + ], + examples: [ + 'npm run cli -- to-smallest 1.5 UCT', + 'npm run cli -- to-smallest 0.001 BTC', + 'npm run cli -- to-smallest 100.5 USDT', + ], + notes: [ + 'When a coin is provided, the wallet is loaded briefly to access the token registry.', + ], + }, + 'to-human': { + usage: 'to-human ', + description: 'Convert an amount in smallest units back to human-readable format. Use the coin symbol to apply the correct decimals for a specific asset. Default uses 8 decimals if no coin specified.', + flags: [ + { flag: '', description: 'Asset symbol (UCT, BTC), name (bitcoin), or hex coin ID. Determines decimal precision.' }, + ], + examples: [ + 'npm run cli -- to-human 150000000 UCT', + 'npm run cli -- to-human 1000000 BTC', + 'npm run cli -- to-human 1000000 USDT', + ], + notes: [ + 'When a coin is provided, the wallet is loaded briefly to access the token registry.', + ], + }, + 'format': { + usage: 'format [decimals]', + description: 'Format an amount with the specified number of decimal places.', + flags: [ + { flag: '', description: 'Number of decimal places', default: '8' }, + ], + examples: [ + 'npm run cli -- format 150000000', + 'npm run cli -- format 150000000 6', + ], + }, + + // --- ENCODING --- + 'base58-encode': { + usage: 'base58-encode ', + description: 'Encode a hex string to Base58.', + examples: [ + 'npm run cli -- base58-encode 0a1b2c3d', + ], + }, + 'base58-decode': { + usage: 'base58-decode ', + description: 'Decode a Base58 string to hex.', + examples: [ + 'npm run cli -- base58-decode 2NEpo7TZRhna', + ], + }, +}; + +function printCommandHelp(cmdName: string): void { + const help = COMMAND_HELP[cmdName]; + if (!help) { + console.error(`No help available for command: ${cmdName}`); + console.error('Run "npm run cli -- help" for a list of all commands.'); + process.exit(1); + } + + console.log(`\n ${cmdName}\n`); + console.log(` Usage: npm run cli -- ${help.usage}\n`); + console.log(` ${help.description}\n`); + + if (help.flags && help.flags.length > 0) { + console.log(' Flags:'); + const maxFlagLen = Math.max(...help.flags.map(f => f.flag.length)); + for (const f of help.flags) { + const defaultStr = f.default ? ` (default: ${f.default})` : ''; + console.log(` ${f.flag.padEnd(maxFlagLen + 2)}${f.description}${defaultStr}`); + } + console.log(''); + } + + if (help.examples.length > 0) { + console.log(' Examples:'); + for (const ex of help.examples) { + console.log(` ${ex}`); + } + console.log(''); + } + + if (help.notes && help.notes.length > 0) { + console.log(' Notes:'); + for (const note of help.notes) { + console.log(` - ${note}`); + } + console.log(''); + } +} + +function printUsage() { + console.log(` +Sphere SDK CLI v0.3.0 + +Usage: npm run cli -- [args...] + or: npx tsx cli/index.ts [args...] + or: npm run cli -- help Show detailed help for a command + +WALLET: + init Create or import wallet + status Show wallet identity + config Show or set CLI configuration + clear Delete all wallet data + +WALLET PROFILES: + wallet list List all wallet profiles + wallet create Create a new wallet profile + wallet use Switch to a wallet profile + wallet current Show active profile + wallet delete Delete a wallet profile + +BALANCE & TOKENS: + balance Show L3 token balance + tokens List individual tokens + assets List all registered assets (coins & NFTs) + asset-info Show detailed info for an asset + l1-balance L1 (ALPHA) balance + topup [ ] Request test tokens from faucet + verify-balance Detect spent tokens via aggregator + sync Sync tokens with IPFS + +TRANSFERS: + send Send L3 tokens + receive Check for incoming transfers + history [limit] Transaction history + +ADDRESSES: + addresses List tracked addresses + switch Switch to HD address + hide Hide address + unhide Unhide address + +NAMETAGS: + nametag Register a nametag + nametag-info Look up nametag info + my-nametag Show current nametag + nametag-sync Re-publish nametag binding to Nostr + +MESSAGING: + dm <@nametag> Send a direct message + dm-inbox List conversations and unread counts + dm-history <@nametag|pubkey> Show conversation history + +GROUP CHAT: + group-create Create a new group + group-list List available groups on relay + group-my List your joined groups + group-join Join a group + group-leave Leave a group + group-send Send a message to a group + group-messages Show group messages + group-members List group members + group-info Show group details + +MARKET: + market-post --type Post an intent + market-search Search intents (semantic) + market-my List your own intents + market-close Close (delete) an intent + market-feed Watch the live listing feed + +INVOICES: + invoice-create Create invoice + invoice-import Import invoice from token file + invoice-list List invoices + invoice-status Show invoice status + invoice-pay Pay an invoice + invoice-close Close an invoice + invoice-cancel Cancel an invoice + invoice-return Return payment to sender + invoice-receipts Send receipts + invoice-notices Send cancellation notices + invoice-auto-return Show/set auto-return settings + invoice-transfers List related transfers + invoice-export Export invoice to JSON file + invoice-parse-memo Parse invoice memo string + +SWAPS: + swap-propose Propose a token swap deal + swap-list List swap deals + swap-accept Accept a swap deal + swap-status Show swap status + swap-deposit Deposit into a swap + swap-reject [reason] Reject a swap proposal + swap-cancel Cancel a swap + +EVENT DAEMON: + daemon start Start persistent event listener + daemon stop Stop running daemon + daemon status Check if daemon is running + +UTILITIES: + encrypt Encrypt data with password + decrypt Decrypt encrypted JSON data + parse-wallet [password] Parse wallet file (.txt, .dat) + wallet-info Show wallet file info + generate-key Generate new private key + validate-key Validate a private key + hex-to-wif Convert hex to WIF + derive-pubkey Derive public key + derive-address [index] Derive L1 address + to-smallest Convert to smallest unit + to-human Convert to human-readable + format [decimals] Format amount + base58-encode Base58 encode + base58-decode Base58 decode + +Run "npm run cli -- help " for detailed help on any command. + +Examples: + npm run cli -- init --network testnet + npm run cli -- init --mnemonic "word1 word2 ... word24" + npm run cli -- status + npm run cli -- balance + npm run cli -- send @alice 1000000 ETH + npm run cli -- nametag myname + npm run cli -- history 10 + npm run cli -- help send +`); +} + +export async function legacyMain(argv: string[]): Promise { + args = argv; + command = args[0]; + await main(); +} + +async function main(): Promise { + // Intercept process.exit() so we tear down the Sphere instance (Nostr + // relays, IPFS handles, SQLite connections) before the process dies. + // Inside command handlers there are ~25 `process.exit(1)` calls that + // would otherwise skip finally blocks and leak resources. + const originalExit = process.exit.bind(process); + process.exit = ((code?: number) => { + if (sphereInstance) { + const inst = sphereInstance; + sphereInstance = null; + inst.destroy() + .catch(() => { /* best-effort cleanup */ }) + .finally(() => originalExit(code)); + return undefined as never; + } + return originalExit(code); + }) as typeof process.exit; + + // Global flag: --no-nostr disables Nostr transport (uses no-op) + noNostrGlobal = args.includes('--no-nostr'); + + if (!command || command === '--help' || command === '-h') { + printUsage(); + process.exit(0); + } + + if (command === 'help') { + const helpTarget = args[1]; + if (!helpTarget || helpTarget === '--help' || helpTarget === '-h' || helpTarget === 'help') { + printUsage(); + process.exit(0); + } + // Try compound key first (e.g., "wallet create", "daemon start") + const compoundKey = args[2] ? `${helpTarget} ${args[2]}` : undefined; + if (compoundKey && COMMAND_HELP[compoundKey]) { + printCommandHelp(compoundKey); + } else if (COMMAND_HELP[helpTarget]) { + printCommandHelp(helpTarget); + } else { + console.error(`No help available for command: ${helpTarget}`); + console.error('Run "npm run cli -- help" for a list of all commands.'); + process.exit(1); + } + process.exit(0); + } + + try { + switch (command) { + // === WALLET MANAGEMENT === + case 'init': { + const networkIndex = args.indexOf('--network'); + const mnemonicIndex = args.indexOf('--mnemonic'); + + let network: NetworkType = 'testnet'; + let mnemonic: string | undefined; + let nametag: string | undefined; + + if (networkIndex !== -1 && args[networkIndex + 1]) { + network = args[networkIndex + 1] as NetworkType; + } + if (mnemonicIndex !== -1) { + if (args[mnemonicIndex + 1] && !args[mnemonicIndex + 1].startsWith('--')) { + mnemonic = args[mnemonicIndex + 1]; + // Note: mutating `args` does NOT clear /proc//cmdline — that reads + // the original process memory. Passing mnemonics via --mnemonic is inherently + // unsafe on shared systems; prefer --mnemonic-file or interactive prompt. + args[mnemonicIndex + 1] = '***'; + } else { + // Interactive prompt — mnemonic never appears in process args + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + mnemonic = await new Promise((resolve) => { + rl.question('Enter mnemonic phrase: ', (answer) => { rl.close(); resolve(answer.trim()); }); + }); + } + } + + const nametagIndex = args.indexOf('--nametag'); + if (nametagIndex !== -1 && args[nametagIndex + 1]) { + nametag = args[nametagIndex + 1]; + } + + // Save config + const config = loadConfig(); + config.network = network; + saveConfig(config); + + console.log(`Initializing wallet on ${network}...`); + if (noNostrGlobal) console.log(' (Nostr transport disabled)'); + + const sphere = await getSphere({ + autoGenerate: !mnemonic, + mnemonic, + nametag, + }); + + const identity = sphere.identity; + if (!identity) { + console.error('Failed to initialize wallet identity'); + process.exit(1); + } + + console.log('\nWallet initialized successfully!\n'); + console.log('Identity:'); + console.log(JSON.stringify({ + l1Address: identity.l1Address, + directAddress: identity.directAddress, + chainPubkey: identity.chainPubkey, + nametag: identity.nametag, + }, null, 2)); + + if (!mnemonic) { + // Show generated mnemonic for backup — only when stdout is a TTY. + // If stdout is piped to a file or CI log, printing the mnemonic + // would persist it in plaintext on disk / in logs. + const storedMnemonic = sphere.getMnemonic(); + if (storedMnemonic) { + if (process.stdout.isTTY) { + console.log('\n⚠️ BACKUP YOUR MNEMONIC (24 words):'); + console.log('─'.repeat(50)); + console.log(storedMnemonic); + console.log('─'.repeat(50)); + console.log('Store this safely! You will need it to recover your wallet.\n'); + } else { + process.stderr.write('\nWARNING: Mnemonic NOT shown (stdout is not a terminal). Re-run interactively to see it.\n'); + } + } + } + + await closeSphere(); + break; + } + + case 'status': { + try { + const sphere = await getSphere(); + const identity = sphere.identity; + const config = loadConfig(); + + if (!identity) { + console.log('No wallet found. Run: npm run cli -- init'); + break; + } + + console.log('\nWallet Status:'); + console.log('─'.repeat(50)); + if (config.currentProfile) { + console.log(`Profile: ${config.currentProfile}`); + } + console.log(`Network: ${config.network}`); + console.log(`L1 Address: ${identity.l1Address}`); + console.log(`Direct Addr: ${identity.directAddress || '(not set)'}`); + console.log(`Chain Pubkey: ${identity.chainPubkey}`); + console.log(`Nametag: ${identity.nametag || '(not set)'}`); + console.log('─'.repeat(50)); + + await closeSphere(); + } catch { + console.log('No wallet found. Run: npm run cli -- init'); + } + break; + } + + case 'config': { + const [, subCmd, key, value] = args; + const config = loadConfig(); + + if (subCmd === 'set' && key && value) { + if (key === 'network') { + config.network = value as NetworkType; + } else if (key === 'dataDir') { + config.dataDir = value; + } else if (key === 'tokensDir') { + config.tokensDir = value; + } else { + console.error('Unknown config key:', key); + console.error('Valid keys: network, dataDir, tokensDir'); + process.exit(1); + } + saveConfig(config); + console.log(`Set ${key} = ${value}`); + } else { + console.log('\nCurrent Configuration:'); + console.log(JSON.stringify(config, null, 2)); + } + break; + } + + case 'clear': { + if (!args.includes('--yes') && !args.includes('-y')) { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + const answer = await new Promise((resolve) => { + rl.question('This will permanently delete ALL wallet data (keys, tokens, history). Type "yes" to confirm: ', (a) => { rl.close(); resolve(a.trim()); }); + }); + if (answer !== 'yes') { + console.log('Aborted.'); + break; + } + } + + const config = loadConfig(); + const providers = createNodeProviders({ + network: config.network, + dataDir: config.dataDir, + tokensDir: config.tokensDir, + }); + + await providers.storage.connect(); + await providers.tokenStorage.initialize(); + + console.log('Clearing all wallet data...'); + await Sphere.clear({ storage: providers.storage, tokenStorage: providers.tokenStorage }); + console.log('All wallet data cleared.'); + + await providers.storage.disconnect(); + await providers.tokenStorage.shutdown(); + break; + } + + // === WALLET PROFILES === + case 'wallet': { + const [, subCmd, profileName] = args; + + switch (subCmd) { + case 'list': { + const store = loadProfiles(); + const config = loadConfig(); + + console.log('\nWallet Profiles:'); + console.log('─'.repeat(60)); + + if (store.profiles.length === 0) { + console.log('No profiles found. Create one with: npm run cli -- wallet create '); + } else { + for (const profile of store.profiles) { + const isCurrent = config.currentProfile === profile.name; + const marker = isCurrent ? '→ ' : ' '; + console.log(`${marker}${profile.name}`); + console.log(` Network: ${profile.network}`); + console.log(` DataDir: ${profile.dataDir}`); + } + } + console.log('─'.repeat(60)); + break; + } + + case 'use': { + if (!profileName) { + console.error('Usage: wallet use '); + console.error('Example: npm run cli -- wallet use babaika9'); + process.exit(1); + } + + if (switchToProfile(profileName)) { + console.log(`✓ Switched to wallet profile: ${profileName}`); + + // Show wallet status + try { + const sphere = await getSphere(); + const identity = sphere.identity; + if (identity) { + console.log(` Nametag: ${identity.nametag || '(not set)'}`); + console.log(` L1 Addr: ${identity.l1Address}`); + } + await closeSphere(); + } catch { + console.log(' (wallet not initialized in this profile)'); + } + } else { + console.error(`Profile "${profileName}" not found.`); + console.error('Run: npm run cli -- wallet list'); + process.exit(1); + } + break; + } + + case 'create': { + if (!profileName) { + console.error('Usage: wallet create [--network testnet|mainnet|dev]'); + console.error('Example: npm run cli -- wallet create mywalletname'); + process.exit(1); + } + if (!/^[a-zA-Z0-9_-]+$/.test(profileName)) { + console.error('Profile name must contain only letters, digits, hyphens, and underscores.'); + process.exit(1); + } + + // Check if profile already exists + if (getProfile(profileName)) { + console.error(`Profile "${profileName}" already exists.`); + console.error('Run: npm run cli -- wallet use ' + profileName); + process.exit(1); + } + + // Parse optional network + const networkIdx = args.indexOf('--network'); + let network: NetworkType = 'testnet'; + if (networkIdx !== -1 && args[networkIdx + 1]) { + network = args[networkIdx + 1] as NetworkType; + } + + const dataDir = `./.sphere-cli-${profileName}`; + const tokensDir = `${dataDir}/tokens`; + + // Create the profile + const profile: WalletProfile = { + name: profileName, + dataDir, + tokensDir, + network, + createdAt: new Date().toISOString(), + }; + addProfile(profile); + + // Switch to the new profile + switchToProfile(profileName); + + console.log(`✓ Created wallet profile: ${profileName}`); + console.log(` Network: ${network}`); + console.log(` DataDir: ${dataDir}`); + console.log(''); + console.log('Now initialize the wallet:'); + console.log(` npm run cli -- init --nametag ${profileName}`); + break; + } + + case 'current': { + const config = loadConfig(); + const currentName = config.currentProfile; + + console.log('\nCurrent Wallet:'); + console.log('─'.repeat(50)); + + if (currentName) { + const profile = getProfile(currentName); + if (profile) { + console.log(`Profile: ${profile.name}`); + console.log(`Network: ${profile.network}`); + console.log(`DataDir: ${profile.dataDir}`); + } else { + console.log(`Profile: ${currentName} (not found in profiles)`); + } + } else { + console.log('Profile: (default)'); + } + + console.log(`DataDir: ${config.dataDir}`); + console.log(`Network: ${config.network}`); + + // Try to get identity + try { + const sphere = await getSphere(); + const identity = sphere.identity; + if (identity) { + console.log(`Nametag: ${identity.nametag || '(not set)'}`); + console.log(`L1 Addr: ${identity.l1Address}`); + } + await closeSphere(); + } catch { + console.log('Wallet: (not initialized)'); + } + + console.log('─'.repeat(50)); + break; + } + + case 'delete': { + if (!profileName) { + console.error('Usage: wallet delete '); + process.exit(1); + } + + const config = loadConfig(); + if (config.currentProfile === profileName) { + console.error(`Cannot delete the current profile. Switch to another profile first.`); + process.exit(1); + } + + if (deleteProfile(profileName)) { + console.log(`✓ Deleted profile: ${profileName}`); + console.log('Note: Wallet data directory was NOT deleted. Remove manually if needed.'); + } else { + console.error(`Profile "${profileName}" not found.`); + process.exit(1); + } + break; + } + + default: + console.error('Unknown wallet subcommand:', subCmd); + console.log('\nUsage:'); + console.log(' wallet list List all profiles'); + console.log(' wallet use Switch to profile'); + console.log(' wallet create Create new profile'); + console.log(' wallet current Show current profile'); + console.log(' wallet delete Delete profile'); + process.exit(1); + } + break; + } + + // === BALANCE & TOKENS === + case 'balance': { + const finalize = args.includes('--finalize'); + const noSync = args.includes('--no-sync'); + const sphere = await getSphere(); + + if (!noSync) await ensureSync(sphere, 'full'); + + console.log(finalize ? '\nFetching and finalizing tokens...' : '\nFetching tokens...'); + const result = await sphere.payments.receive({ + finalize, + onProgress: (resolution) => { + if (resolution.stillPending > 0) { + const currentBalances = sphere.payments.getBalance(); + for (const bal of currentBalances) { + if (BigInt(bal.unconfirmedAmount) > 0n) { + console.log(` ${bal.symbol}: ${bal.unconfirmedTokenCount} token(s) still unconfirmed...`); + } + } + } + }, + }); + + if (finalize) { + if (result.timedOut) { + console.log(' Warning: finalization timed out, some tokens still unconfirmed.'); + } else if (result.finalization && result.finalization.resolved > 0) { + console.log(`All tokens finalized in ${((result.finalizationDurationMs ?? 0) / 1000).toFixed(1)}s.`); + } else { + console.log('All tokens are already confirmed.'); + } + } + + const assets = sphere.payments.getBalance(); + const totalUsd = await sphere.payments.getFiatBalance(); + + console.log('\nL3 Balance:'); + console.log('─'.repeat(50)); + + if (assets.length === 0) { + console.log('No tokens found.'); + } else { + for (const asset of assets) { + const decimals = asset.decimals ?? 8; + const confirmedFormatted = toHumanReadable(asset.confirmedAmount, decimals); + const unconfirmedBigInt = BigInt(asset.unconfirmedAmount); + + let line = `${asset.symbol}: ${confirmedFormatted}`; + if (unconfirmedBigInt > 0n) { + const unconfirmedFormatted = toHumanReadable(asset.unconfirmedAmount, decimals); + line += ` (+ ${unconfirmedFormatted} unconfirmed) [${asset.confirmedTokenCount}+${asset.unconfirmedTokenCount} tokens]`; + } else { + line += ` (${asset.tokenCount} token${asset.tokenCount !== 1 ? 's' : ''})`; + } + if (asset.fiatValueUsd != null) { + line += ` ≈ $${asset.fiatValueUsd.toFixed(2)}`; + } + console.log(line); + } + } + console.log('─'.repeat(50)); + if (totalUsd != null) { + console.log(`Total: $${totalUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); + } + + await closeSphere(); + break; + } + + case 'tokens': { + const noSync = args.includes('--no-sync'); + const sphere = await getSphere(); + + if (!noSync) await ensureSync(sphere, 'full'); + + const tokens = sphere.payments.getTokens(); + const registry = TokenRegistry.getInstance(); + + console.log('\nTokens:'); + console.log('─'.repeat(50)); + + if (tokens.length === 0) { + console.log('No tokens found.'); + } else { + for (const token of tokens) { + const def = registry.getDefinition(token.coinId); + const symbol = def?.symbol || token.symbol || 'UNK'; + const decimals = def?.decimals ?? token.decimals ?? 8; + const formatted = toHumanReadable(token.amount || '0', decimals); + console.log(`ID: ${token.id.slice(0, 16)}...`); + console.log(` Coin: ${symbol} (${token.coinId.slice(0, 8)}...)`); + console.log(` Amount: ${formatted} ${symbol}`); + console.log(` Status: ${token.status || 'active'}`); + console.log(''); + } + } + console.log('─'.repeat(50)); + + await closeSphere(); + break; + } + + case 'assets': { + // Initialize Sphere so TokenRegistry is loaded with remote data + const _sphere = await getSphere(); + const registry = TokenRegistry.getInstance(); + const allDefs = registry.getAllDefinitions(); + + const typeFilter = args.indexOf('--type'); + const filterValue = typeFilter !== -1 ? args[typeFilter + 1] : undefined; + + let defs = allDefs; + if (filterValue === 'fungible' || filterValue === 'coin' || filterValue === 'coins') { + defs = registry.getFungibleTokens(); + } else if (filterValue === 'nft' || filterValue === 'nfts' || filterValue === 'non-fungible') { + defs = registry.getNonFungibleTokens(); + } + + if (defs.length === 0) { + console.log('No registered assets found.'); + console.log('The token registry may not have loaded yet. Try again in a moment.'); + } else { + // Table header + console.log(''); + console.log(`${'SYMBOL'.padEnd(10)} ${'NAME'.padEnd(20)} ${'KIND'.padEnd(14)} ${'DECIMALS'.padEnd(10)} ${'COIN ID'}`); + console.log('─'.repeat(90)); + + // Sort: fungible first (by symbol), then NFTs (by name) + const sorted = [...defs].sort((a, b) => { + if (a.assetKind !== b.assetKind) return a.assetKind === 'fungible' ? -1 : 1; + const aLabel = a.symbol || a.name || a.id; + const bLabel = b.symbol || b.name || b.id; + return aLabel.localeCompare(bLabel); + }); + + for (const def of sorted) { + const symbol = (def.symbol || '—').padEnd(10); + const name = (def.name ? def.name.charAt(0).toUpperCase() + def.name.slice(1) : '—').padEnd(20); + const kind = def.assetKind.padEnd(14); + const decimals = (def.decimals !== undefined ? String(def.decimals) : '—').padEnd(10); + const coinId = def.id.slice(0, 16) + '...'; + console.log(`${symbol} ${name} ${kind} ${decimals} ${coinId}`); + } + + console.log('─'.repeat(90)); + console.log(`Total: ${defs.length} asset(s)`); + } + + await closeSphere(); + break; + } + + case 'asset-info': { + const identifier = args[1]; + if (!identifier) { + console.error('Usage: asset-info '); + console.error('Examples:'); + console.error(' npm run cli -- asset-info UCT'); + console.error(' npm run cli -- asset-info bitcoin'); + console.error(' npm run cli -- asset-info 0a1b2c3d...'); + process.exit(1); + } + + // Initialize Sphere so TokenRegistry is loaded + const _sphere = await getSphere(); + const registry = TokenRegistry.getInstance(); + + // Try multiple lookup strategies + let def = registry.getDefinitionBySymbol(identifier); + if (!def) def = registry.getDefinitionByName(identifier); + if (!def) def = registry.getDefinition(identifier); // by coinId hex + + if (!def) { + console.error(`Asset not found: "${identifier}"`); + console.error('Use "assets" command to list all registered assets.'); + process.exit(1); + } + + console.log(''); + console.log('Asset Details'); + console.log('─'.repeat(50)); + console.log(` Symbol: ${def.symbol || '—'}`); + console.log(` Name: ${def.name ? def.name.charAt(0).toUpperCase() + def.name.slice(1) : '—'}`); + console.log(` Kind: ${def.assetKind}`); + console.log(` Decimals: ${def.decimals !== undefined ? def.decimals : '—'}`); + console.log(` Coin ID: ${def.id}`); + console.log(` Network: ${def.network}`); + console.log(` Description: ${def.description || '—'}`); + if (def.icons && def.icons.length > 0) { + console.log(` Icons:`); + for (const icon of def.icons) { + console.log(` - ${icon.url}`); + } + } + console.log('─'.repeat(50)); + + await closeSphere(); + break; + } + + case 'l1-balance': { + const sphere = await getSphere(); + + if (!sphere.payments.l1) { + console.error('L1 module not available. Initialize with L1 config.'); + process.exit(1); + } + + const balance = await sphere.payments.l1.getBalance(); + + console.log('\nL1 (ALPHA) Balance:'); + console.log('─'.repeat(50)); + console.log(`Confirmed: ${toHumanReadable(balance.confirmed.toString())} ALPHA`); + console.log(`Unconfirmed: ${toHumanReadable(balance.unconfirmed.toString())} ALPHA`); + console.log('─'.repeat(50)); + + await closeSphere(); + break; + } + + case 'verify-balance': { + // Verify tokens against the aggregator to detect spent tokens + // Uses SDK Token.fromJSON() to calculate current state hash (per TOKEN_INVENTORY_SPEC.md) + const removeSpent = args.includes('--remove'); + const verbose = args.includes('--verbose') || args.includes('-v'); + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + const tokens = sphere.payments.getTokens(); + const identity = sphere.identity; + + if (!identity) { + console.error('No wallet identity found.'); + process.exit(1); + } + + console.log(`\nVerifying ${tokens.length} token(s) against aggregator...`); + console.log('─'.repeat(60)); + + // Get aggregator client from the initialized oracle provider in Sphere + const oracle = sphere.getAggregator(); + const aggregatorClient = (oracle as { getAggregatorClient?: () => unknown }).getAggregatorClient?.(); + + if (!aggregatorClient) { + console.error('Aggregator client not available. Cannot verify tokens.'); + await closeSphere(); + process.exit(1); + } + + // Create validator with aggregator client + const validator = new TokenValidator({ + aggregatorClient: aggregatorClient as Parameters[0], + }); + + // Use checkSpentTokens which properly calculates state hash using SDK + // (following TOKEN_INVENTORY_SPEC.md Step 7: Spent Token Detection) + const result = await validator.checkSpentTokens( + tokens, + identity.chainPubkey, + { + batchSize: 5, + onProgress: (completed, total) => { + if (verbose && (completed % 10 === 0 || completed === total)) { + console.log(` Checked ${completed}/${total} tokens...`); + } + } + } + ); + + // Build result maps for display + const registry = TokenRegistry.getInstance(); + const spentTokenIds = new Set(result.spentTokens.map(s => s.localId)); + + const spentDisplay: { id: string; tokenId: string; symbol: string; amount: string }[] = []; + const validDisplay: { id: string; tokenId: string; symbol: string; amount: string }[] = []; + + for (const token of tokens) { + const def = registry.getDefinition(token.coinId); + const symbol = def?.symbol || token.symbol || 'UNK'; + const decimals = def?.decimals ?? token.decimals ?? 8; + const formatted = toHumanReadable(token.amount || '0', decimals); + + const txf = tokenToTxf(token); + const tokenId = txf?.genesis?.data?.tokenId || token.id; + + if (spentTokenIds.has(token.id)) { + spentDisplay.push({ + id: token.id, + tokenId: tokenId.slice(0, 16), + symbol, + amount: formatted, + }); + console.log(`✗ SPENT: ${formatted} ${symbol} (${tokenId.slice(0, 12)}...)`); + } else { + validDisplay.push({ + id: token.id, + tokenId: tokenId.slice(0, 16), + symbol, + amount: formatted, + }); + if (verbose) { + console.log(`✓ Valid: ${formatted} ${symbol} (${tokenId.slice(0, 12)}...)`); + } + } + } + + console.log('─'.repeat(60)); + console.log(`\nSummary:`); + console.log(` Valid tokens: ${validDisplay.length}`); + console.log(` Spent tokens: ${spentDisplay.length}`); + if (result.errors.length > 0) { + console.log(` Errors: ${result.errors.length}`); + if (verbose) { + for (const err of result.errors) { + console.log(` - ${err}`); + } + } + } + + // Move spent tokens to Sent folder if requested (per TOKEN_INVENTORY_SPEC.md) + if (removeSpent && spentDisplay.length > 0) { + console.log(`\nMoving ${spentDisplay.length} spent token(s) to Sent folder...`); + + // Access PaymentsModule's removeToken which: + // 1. Archives token to Sent folder (archivedTokens) + // 2. Creates tombstone to prevent re-sync + // 3. Removes from active tokens + const paymentsModule = sphere.payments as unknown as { + removeToken?: (tokenId: string, recipientNametag?: string, skipHistory?: boolean) => Promise; + }; + + if (!paymentsModule.removeToken) { + console.error(' Error: removeToken method not available'); + } else { + for (const spent of spentDisplay) { + try { + // Use removeToken which archives to Sent folder and creates tombstone + // skipHistory=true since this is spent detection, not a new send + await paymentsModule.removeToken(spent.id, undefined, true); + console.log(` Archived: ${spent.amount} ${spent.symbol} (${spent.tokenId}...)`); + } catch (err) { + console.error(` Failed to archive ${spent.id}: ${err}`); + } + } + console.log(' Tokens moved to Sent folder.'); + } + } else if (spentDisplay.length > 0) { + console.log(`\nTo move spent tokens to Sent folder, run: npm run cli -- verify-balance --remove`); + } + + await closeSphere(); + break; + } + + case 'sync': { + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + await closeSphere(); + break; + } + + // === TRANSFERS === + case 'send': { + const [, recipient, amountStr] = args; + if (!recipient || !amountStr) { + console.error('Usage: send [--direct|--proxy] [--instant|--conservative]'); + console.error(' recipient: @nametag or DIRECT:// address'); + console.error(' amount: decimal amount (e.g., 0.5, 100)'); + console.error(' coin: token symbol (e.g., UCT, BTC, ETH, SOL) - default: UCT'); + console.error(' --direct: force DirectAddress transfer (requires new nametag with directAddress)'); + console.error(' --proxy: force PROXY address transfer (works with any nametag)'); + console.error(' --instant: send via Nostr immediately (default, receiver gets unconfirmed token)'); + console.error(' --conservative: collect all proofs first, receiver gets confirmed token'); + process.exit(1); + } + + // Parse coin: positional arg[3] + let coinSymbol: string; + if (args[3] && !args[3].startsWith('--')) { + coinSymbol = args[3]; + } else { + coinSymbol = 'UCT'; // default + } + + // Parse --direct and --proxy options + const forceDirect = args.includes('--direct'); + const forceProxy = args.includes('--proxy'); + if (forceDirect && forceProxy) { + console.error('Cannot use both --direct and --proxy'); + process.exit(1); + } + const addressMode = forceDirect ? 'direct' : forceProxy ? 'proxy' : 'auto'; + + // Parse --instant and --conservative options + const forceInstant = args.includes('--instant'); + const forceConservative = args.includes('--conservative'); + if (forceInstant && forceConservative) { + console.error('Cannot use both --instant and --conservative'); + process.exit(1); + } + const transferMode = forceConservative ? 'conservative' as const : 'instant' as const; + + // Initialize Sphere first so TokenRegistry is loaded + const noSyncSend = args.includes('--no-sync'); + const sphere = await getSphere(); + + // Sync token inventory (need latest state for spending) + if (!noSyncSend) await ensureSync(sphere, 'full'); + + // Resolve symbol/name/hex to coinId and get decimals + const { coinId: coinIdHex, symbol: resolvedSymbol, decimals } = resolveCoin(coinSymbol); + + // Convert amount to smallest units (supports decimal input like "0.2") + const amountSmallest = toSmallestUnit(amountStr, decimals).toString(); + + const modeLabel = addressMode === 'auto' ? '' : ` (${addressMode})`; + const txModeLabel = forceConservative ? ' [conservative]' : ''; + console.log(`\nSending ${amountStr} ${resolvedSymbol} to ${recipient}${modeLabel}${txModeLabel}...`); + + const result = await sphere.payments.send({ + recipient, + amount: amountSmallest, + coinId: coinIdHex, + addressMode, + transferMode, + }); + + if (result.status === 'completed' || result.status === 'submitted') { + console.log('\n✓ Transfer successful!'); + console.log(` Transfer ID: ${result.id}`); + console.log(` Status: ${result.status}`); + } else { + console.error('\n✗ Transfer failed:', result.error || result.status); + } + + // Wait for background tasks (e.g., change token creation from instant split) + await sphere.payments.waitForPendingOperations(); + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'receive': { + const finalize = args.includes('--finalize'); + const noSyncRecv = args.includes('--no-sync'); + const sphere = await getSphere(); + + // Sync from Nostr to get incoming transfers + if (!noSyncRecv) await ensureSync(sphere, 'nostr'); + + const identity = sphere.identity; + + if (!identity) { + console.error('No wallet initialized.'); + process.exit(1); + } + + // Show addresses + console.log('\nReceive Address:'); + console.log('─'.repeat(50)); + console.log(`L3 (Direct): ${identity.directAddress || '(not available)'}`); + console.log(`L1 (ALPHA): ${identity.l1Address}`); + if (identity.nametag) { + console.log(`Nametag: @${identity.nametag}`); + } + console.log('─'.repeat(50)); + + // Fetch pending transfers + console.log('\nChecking for incoming transfers...'); + const registry = TokenRegistry.getInstance(); + const result = await sphere.payments.receive({ + finalize, + onProgress: (resolution) => { + if (resolution.stillPending > 0) { + console.log(` ${resolution.stillPending} token(s) still finalizing...`); + } + }, + }); + + if (result.transfers.length === 0) { + console.log('No new transfers found.'); + } else { + console.log(`\nReceived ${result.transfers.length} new transfer(s):`); + for (const transfer of result.transfers) { + for (const token of transfer.tokens) { + const def = registry.getDefinition(token.coinId); + const decimals = def?.decimals ?? token.decimals ?? 8; + const symbol = def?.symbol || token.symbol; + const formatted = toHumanReadable(token.amount, decimals); + const statusTag = token.status === 'confirmed' ? '' : ` [${token.status}]`; + console.log(` ${formatted} ${symbol}${statusTag}`); + } + } + } + + if (finalize && result.timedOut) { + console.log('\nWarning: finalization timed out, some tokens still unconfirmed.'); + } else if (finalize && result.finalizationDurationMs) { + console.log(`\nAll tokens finalized in ${(result.finalizationDurationMs / 1000).toFixed(1)}s.`); + } + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'history': { + const limitStr = (args[1] && !args[1].startsWith('--')) ? args[1] : '10'; + const limit = parseInt(limitStr); + const noSync = args.includes('--no-sync'); + + const sphere = await getSphere(); + if (!noSync) await ensureSync(sphere, 'full'); + const history = sphere.payments.getHistory(); + const limited = history.slice(0, limit); + + console.log(`\nTransaction History (last ${limit}):`) + console.log('─'.repeat(60)); + + if (limited.length === 0) { + console.log('No transactions found.'); + } else { + const registry = TokenRegistry.getInstance(); + for (const tx of limited) { + const date = new Date(tx.timestamp).toLocaleString(); + const direction = tx.type === 'SENT' ? '→' : '←'; + // Look up decimals from registry, default to 8 + const coinDef = registry.getDefinition(tx.coinId); + const decimals = coinDef?.decimals ?? 8; + const amount = toHumanReadable(tx.amount?.toString() || '0', decimals); + console.log(`${date} ${direction} ${amount} ${tx.symbol}`); + const counterparty = tx.type === 'SENT' ? tx.recipientNametag : tx.senderPubkey; + console.log(` ${tx.type === 'SENT' ? 'To' : 'From'}: ${counterparty || 'unknown'}`); + console.log(''); + } + } + console.log('─'.repeat(60)); + + await closeSphere(); + break; + } + + // === ADDRESSES === + case 'addresses': { + const sphere = await getSphere(); + const all = sphere.getAllTrackedAddresses(); + const currentIndex = sphere.getCurrentAddressIndex(); + + console.log('\nTracked Addresses:'); + console.log('─'.repeat(70)); + + if (all.length === 0) { + console.log('No tracked addresses.'); + } else { + for (const addr of all) { + const marker = addr.index === currentIndex ? '→ ' : ' '; + const hidden = addr.hidden ? ' [hidden]' : ''; + const tag = addr.nametag ? ` @${addr.nametag}` : ''; + console.log(`${marker}#${addr.index}: ${addr.l1Address}${tag}${hidden}`); + console.log(` DIRECT: ${addr.directAddress}`); + } + } + + console.log('─'.repeat(70)); + await closeSphere(); + break; + } + + case 'switch': { + const [, indexStr] = args; + if (!indexStr) { + console.error('Usage: switch '); + console.error(' index: HD address index (0, 1, 2, ...)'); + process.exit(1); + } + + const index = parseInt(indexStr); + if (isNaN(index) || index < 0) { + console.error('Invalid index. Must be a non-negative integer.'); + process.exit(1); + } + + const sphere = await getSphere(); + await sphere.switchToAddress(index); + + const identity = sphere.identity; + console.log(`\nSwitched to address #${index}`); + console.log(` L1: ${identity?.l1Address}`); + console.log(` DIRECT: ${identity?.directAddress}`); + console.log(` Nametag: ${identity?.nametag || '(not set)'}`); + + await closeSphere(); + break; + } + + case 'hide': { + const [, indexStr] = args; + if (!indexStr) { + console.error('Usage: hide '); + process.exit(1); + } + + const sphere = await getSphere(); + await sphere.setAddressHidden(parseInt(indexStr), true); + console.log(`Address #${indexStr} hidden.`); + await closeSphere(); + break; + } + + case 'unhide': { + const [, indexStr] = args; + if (!indexStr) { + console.error('Usage: unhide '); + process.exit(1); + } + + const sphere = await getSphere(); + await sphere.setAddressHidden(parseInt(indexStr), false); + console.log(`Address #${indexStr} unhidden.`); + await closeSphere(); + break; + } + + // === NAMETAGS === + case 'nametag': { + const [, name] = args; + if (!name) { + console.error('Usage: nametag '); + console.error(' name: desired nametag (without @)'); + process.exit(1); + } + + const cleanName = name.replace('@', ''); + const sphere = await getSphere(); + + console.log(`\nRegistering nametag @${cleanName}...`); + + try { + await sphere.registerNametag(cleanName); + console.log(`\n✓ Nametag @${cleanName} registered successfully!`); + } catch (err) { + console.error('\n✗ Registration failed:', err instanceof Error ? err.message : err); + await closeSphere(); + // Exit non-zero so downstream scripts know the registration failed. + process.exit(1); + } + + await closeSphere(); + break; + } + + case 'nametag-info': { + const [, name] = args; + if (!name) { + console.error('Usage: nametag-info '); + process.exit(1); + } + + const cleanName = name.replace('@', ''); + const sphere = await getSphere(); + + // Use transport provider to resolve nametag + const transport = (sphere as unknown as { _transport?: { resolveNametagInfo?: (n: string) => Promise } })._transport; + const info = await transport?.resolveNametagInfo?.(cleanName); + + if (info) { + console.log(`\nNametag Info: @${cleanName}`); + console.log('─'.repeat(50)); + console.log(JSON.stringify(info, null, 2)); + console.log('─'.repeat(50)); + } else { + console.log(`\nNametag @${cleanName} not found.`); + } + + await closeSphere(); + break; + } + + case 'my-nametag': { + const sphere = await getSphere(); + const identity = sphere.identity; + + if (identity?.nametag) { + console.log(`\nYour nametag: @${identity.nametag}`); + } else { + console.log('\nNo nametag registered.'); + console.log('Register one with: npm run cli -- nametag '); + } + + await closeSphere(); + break; + } + + case 'nametag-sync': { + // Force re-publish nametag binding with chainPubkey + // Useful for legacy nametags that were registered without chainPubkey + const sphere = await getSphere(); + const identity = sphere.identity; + + if (!identity?.nametag) { + console.error('\nNo nametag to sync.'); + console.error('Register one first with: npm run cli -- nametag '); + process.exit(1); + } + + console.log(`\nRe-publishing nametag @${identity.nametag} with chainPubkey...`); + + // Get transport provider and force re-publish + const transport = (sphere as unknown as { _transport?: { publishIdentityBinding?: (ck: string, l1: string, da: string, nt: string) => Promise } })._transport; + if (!transport?.publishIdentityBinding) { + console.error('Transport provider does not support identity binding'); + process.exit(1); + } + + try { + const success = await transport.publishIdentityBinding( + identity.chainPubkey, + identity.l1Address, + identity.directAddress || '', + identity.nametag, + ); + + if (success) { + console.log(`\n✓ Nametag @${identity.nametag} synced successfully!`); + console.log(` chainPubkey: ${identity.chainPubkey.slice(0, 16)}...`); + } else { + console.error('\n✗ Nametag sync failed. The nametag may be taken by another pubkey.'); + process.exit(1); + } + } catch (err) { + console.error('\n✗ Sync failed:', err instanceof Error ? err.message : err); + process.exit(1); + } + + await closeSphere(); + break; + } + + // === ENCRYPTION === + case 'encrypt': { + const [, data, password] = args; + if (!data || !password) { + console.error('Usage: encrypt '); + process.exit(1); + } + const result = encrypt(data, password); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'decrypt': { + const [, encrypted, password] = args; + if (!encrypted || !password) { + console.error('Usage: decrypt '); + process.exit(1); + } + const encryptedData = JSON.parse(encrypted); + const result = decrypt(encryptedData, password); + console.log(result); + break; + } + + // === WALLET PARSING === + case 'parse-wallet': { + const [, filePath, password] = args; + if (!filePath) { + console.error('Usage: parse-wallet [password]'); + process.exit(1); + } + + if (!fs.existsSync(filePath)) { + console.error('File not found:', filePath); + process.exit(1); + } + + if (filePath.endsWith('.dat')) { + const data = fs.readFileSync(filePath); + if (!isSQLiteDatabase(data)) { + console.error('Not a valid wallet.dat (SQLite) file'); + process.exit(1); + } + const result = parseWalletDat(data); + console.log(JSON.stringify(result, null, 2)); + } else { + const content = fs.readFileSync(filePath, 'utf8'); + const isEncrypted = isTextWalletEncrypted(content); + + if (isEncrypted && !password) { + console.log('Wallet is encrypted. Provide password: parse-wallet '); + process.exit(0); + } + + const result = password + ? parseAndDecryptWalletText(content, password) + : parseWalletText(content); + console.log(JSON.stringify(result, null, 2)); + } + break; + } + + case 'wallet-info': { + const [, filePath] = args; + if (!filePath) { + console.error('Usage: wallet-info '); + process.exit(1); + } + + if (!fs.existsSync(filePath)) { + console.error('File not found:', filePath); + process.exit(1); + } + + const info: Record = { file: filePath }; + + if (filePath.endsWith('.dat')) { + const data = fs.readFileSync(filePath); + info.format = 'dat'; + info.isSQLite = isSQLiteDatabase(data); + info.isEncrypted = isWalletDatEncrypted(data); + } else if (filePath.endsWith('.txt')) { + const content = fs.readFileSync(filePath, 'utf8'); + info.format = 'txt'; + info.isEncrypted = isTextWalletEncrypted(content); + } else if (filePath.endsWith('.json')) { + const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + info.format = 'json'; + info.isEncrypted = !!content.encrypted; + info.hasChainCode = !!content.chainCode; + } + + console.log(JSON.stringify(info, null, 2)); + break; + } + + // === KEY OPERATIONS === + case 'generate-key': { + const privateKey = generatePrivateKey(); + const publicKey = getPublicKey(privateKey); + const wif = hexToWIF(privateKey); + const addressInfo = generateAddressFromMasterKey(privateKey, 0); + + if (args.includes('--unsafe-print')) { + // Refuse to print the private key unless stdout is a terminal. + // Piping to a file or CI log would persist the secret in plaintext. + if (!process.stdout.isTTY) { + process.stderr.write('sphere generate-key: refusing to print private key to non-TTY stdout. Use --allow-non-tty to override.\n'); + process.exit(1); + } + console.log(JSON.stringify({ + privateKey, + publicKey, + wif, + address: addressInfo.address, + }, null, 2)); + } else { + console.log(`Public Key: ${publicKey}`); + console.log(`Address: ${addressInfo.address}`); + console.log('(private key and WIF hidden — use --unsafe-print to display)'); + } + break; + } + + case 'validate-key': { + const [, hex] = args; + if (!hex) { + console.error('Usage: validate-key '); + process.exit(1); + } + const valid = isValidPrivateKey(hex); + console.log(JSON.stringify({ valid, length: hex.length })); + process.exit(valid ? 0 : 1); + break; + } + + case 'hex-to-wif': { + const [, hex] = args; + if (!hex) { + console.error('Usage: hex-to-wif '); + process.exit(1); + } + console.log(hexToWIF(hex)); + break; + } + + case 'derive-pubkey': { + const [, privateKey] = args; + if (!privateKey) { + console.error('Usage: derive-pubkey '); + process.exit(1); + } + const publicKey = getPublicKey(privateKey); + console.log(publicKey); + break; + } + + case 'derive-address': { + const [, privateKey, index = '0'] = args; + if (!privateKey) { + console.error('Usage: derive-address [index]'); + console.error('Index: address derivation index (default: 0)'); + process.exit(1); + } + const addressInfo = generateAddressFromMasterKey(privateKey, parseInt(index)); + console.log(addressInfo.address); + break; + } + + // === CURRENCY === + case 'to-smallest': { + const [, amount] = args; + if (!amount) { + console.error('Usage: to-smallest '); + process.exit(1); + } + const coinArgSmallest: string | undefined = (args[2] && !args[2].startsWith('--')) ? args[2] : undefined; + let decimalsSmallest = 8; + if (coinArgSmallest) { + await getSphere(); + decimalsSmallest = resolveCoin(coinArgSmallest).decimals; + await closeSphere(); + } + console.log(toSmallestUnit(amount, decimalsSmallest)); + break; + } + + case 'to-human': { + const [, amount] = args; + if (!amount) { + console.error('Usage: to-human '); + process.exit(1); + } + const coinArgHuman: string | undefined = (args[2] && !args[2].startsWith('--')) ? args[2] : undefined; + let decimalsHuman = 8; + if (coinArgHuman) { + await getSphere(); + decimalsHuman = resolveCoin(coinArgHuman).decimals; + await closeSphere(); + } + console.log(toHumanReadable(amount, decimalsHuman)); + break; + } + + case 'format': { + const [, amount, decimals = '8'] = args; + if (!amount) { + console.error('Usage: format [decimals]'); + process.exit(1); + } + console.log(formatAmount(amount, { decimals: parseInt(decimals) })); + break; + } + + // === ENCODING === + case 'base58-encode': { + const [, hex] = args; + if (!hex) { + console.error('Usage: base58-encode '); + process.exit(1); + } + console.log(base58Encode(hex)); + break; + } + + case 'base58-decode': { + const [, str] = args; + if (!str) { + console.error('Usage: base58-decode '); + process.exit(1); + } + const bytes = base58Decode(str); + console.log(Buffer.from(bytes).toString('hex')); + break; + } + + // === FAUCET / TOPUP === + case 'topup': + case 'top-up': + case 'faucet': { + // Get nametag from wallet + const sphere = await getSphere(); + const nametag = sphere.getNametag(); + if (!nametag) { + console.error('Error: No nametag registered. Use "nametag " first.'); + await closeSphere(); + process.exit(1); + } + + // Parse options: topup [ ] + const amountArg: string | undefined = args[1]; + const coinArg: string | undefined = args[2]; + + const FAUCET_URL = 'https://faucet.unicity.network/api/v1/faucet/request'; + + // Default amounts in whole coins (the faucet API converts to smallest units internally). + const DEFAULT_COINS: Record = { + 'unicity': 100, + 'bitcoin': 1, + 'ethereum': 42, + 'solana': 1000, + 'tether': 1000, + 'usd-coin': 1000, + 'unicity-usd': 1000, + }; + + async function requestFaucet(coin: string, amount: number): Promise<{ success: boolean; message?: string }> { + try { + const response = await fetch(FAUCET_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + unicityId: nametag, // Without @ prefix - faucet API expects raw nametag + coin, + amount, + }), + }); + const result = await response.json() as { success: boolean; message?: string; error?: string }; + // API returns 'error' field on failure, normalize to 'message' + return { + success: result.success, + message: result.message || result.error, + }; + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : 'Request failed' }; + } + } + + if (coinArg) { + // Request specific coin — accept symbols (UCT, BTC) as well as faucet names (unicity, bitcoin) + const coin = FAUCET_COIN_MAP[coinArg.toUpperCase()] || coinArg.toLowerCase(); + const amount = amountArg ? parseFloat(amountArg) : (DEFAULT_COINS[coin] || 1); + + // The faucet API converts whole-coin amounts to smallest units internally. + console.log(`Requesting ${amount} ${coin} from faucet for @${nametag}...`); + const result = await requestFaucet(coin, amount); + + if (result.success) { + console.log(`\n✓ Received ${amount} ${coin}`); + } else { + console.error(`\n✗ Failed: ${result.message || 'Unknown error'}`); + } + } else { + // Request all coins + console.log(`Requesting all test tokens for @${nametag}...`); + console.log('─'.repeat(50)); + + const results = await Promise.all( + Object.entries(DEFAULT_COINS).map(async ([coin, amount]) => { + const result = await requestFaucet(coin, amount); + return { coin, amount, ...result }; + }) + ); + + for (const result of results) { + if (result.success) { + console.log(`✓ ${result.coin}: ${result.amount}`); + } else { + console.log(`✗ ${result.coin}: Failed - ${result.message || 'Unknown error'}`); + } + } + + console.log('─'.repeat(50)); + console.log('TopUp complete! Run "balance" to see updated balances.'); + } + + await closeSphere(); + break; + } + + // === MESSAGING (Direct Messages) === + case 'dm': { + const [, recipient, ...messageParts] = args; + // Collect message: everything after recipient that isn't a flag + const message = messageParts.filter(p => !p.startsWith('--')).join(' '); + if (!recipient || !message) { + console.error('Usage: dm <@nametag|pubkey> '); + console.error(' Example: npm run cli -- dm @alice "Hello!"'); + process.exit(1); + } + + const sphere = await getSphere(); + const dm = await sphere.communications.sendDM(recipient, message); + console.log(`\n✓ Message sent to ${recipient}`); + console.log(` ID: ${dm.id}`); + console.log(` Time: ${new Date(dm.timestamp).toLocaleString()}`); + await closeSphere(); + break; + } + + case 'dm-inbox': { + const sphere = await getSphere(); + await ensureSync(sphere, 'nostr'); + const conversations = sphere.communications.getConversations(); + const totalUnread = sphere.communications.getUnreadCount(); + + console.log(`\nInbox (${conversations.size} conversation${conversations.size !== 1 ? 's' : ''}, ${totalUnread} unread):`); + console.log('─'.repeat(60)); + + if (conversations.size === 0) { + console.log('No conversations found.'); + } else { + for (const [peerPubkey, messages] of conversations) { + const unread = sphere.communications.getUnreadCount(peerPubkey); + const lastMsg = messages[messages.length - 1]; + const time = new Date(lastMsg.timestamp).toLocaleString(); + const peerLabel = lastMsg.senderPubkey === peerPubkey + ? (lastMsg.senderNametag ? `@${lastMsg.senderNametag}` : peerPubkey.slice(0, 16) + '...') + : peerPubkey.slice(0, 16) + '...'; + const unreadBadge = unread > 0 ? ` [${unread} unread]` : ''; + const preview = lastMsg.content.length > 60 + ? lastMsg.content.slice(0, 60) + '...' + : lastMsg.content; + + console.log(`${peerLabel}${unreadBadge}`); + console.log(` Last: ${preview}`); + console.log(` Time: ${time}`); + console.log(''); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + case 'dm-history': { + const [, peer] = args; + if (!peer) { + console.error('Usage: dm-history <@nametag|pubkey> [--limit ]'); + console.error(' Example: npm run cli -- dm-history @alice --limit 20'); + process.exit(1); + } + + const limitIndex = args.indexOf('--limit'); + const limit = limitIndex !== -1 && args[limitIndex + 1] ? parseInt(args[limitIndex + 1]) : 50; + + const sphere = await getSphere(); + await ensureSync(sphere, 'nostr'); + + // Resolve @nametag to pubkey if needed + let peerPubkey = peer; + if (peer.startsWith('@')) { + const resolved = await sphere.resolve(peer); + if (!resolved) { + console.error(`Could not resolve ${peer}`); + process.exit(1); + } + peerPubkey = resolved.chainPubkey; + } + + const messages = sphere.communications.getConversation(peerPubkey); + const limited = messages.slice(-limit); + const myPubkey = sphere.identity?.chainPubkey; + + console.log(`\nConversation with ${peer} (${limited.length} message${limited.length !== 1 ? 's' : ''}):`); + console.log('─'.repeat(60)); + + if (limited.length === 0) { + console.log('No messages found.'); + } else { + for (const msg of limited) { + const time = new Date(msg.timestamp).toLocaleString(); + const isMe = msg.senderPubkey === myPubkey; + const sender = isMe ? 'You' : (msg.senderNametag ? `@${msg.senderNametag}` : msg.senderPubkey.slice(0, 12) + '...'); + console.log(`[${time}] ${sender}: ${msg.content}`); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + // === GROUP CHAT (NIP-29) === + case 'group-create': { + const groupName = args[1]; + if (!groupName) { + console.error('Usage: group-create [--description ] [--private]'); + console.error(' Example: npm run cli -- group-create "Trading Chat" --description "Discuss trades"'); + process.exit(1); + } + + const descIndex = args.indexOf('--description'); + const description = descIndex !== -1 && args[descIndex + 1] ? args[descIndex + 1] : undefined; + const isPrivate = args.includes('--private'); + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const group = await sphere.groupChat.createGroup({ + name: groupName, + description, + visibility: isPrivate ? 'PRIVATE' : 'PUBLIC', + }); + + if (group) { + console.log('\n✓ Group created!'); + console.log(` ID: ${group.id}`); + console.log(` Name: ${group.name}`); + console.log(` Visibility: ${group.visibility}`); + if (group.description) console.log(` Description: ${group.description}`); + } else { + console.error('\n✗ Failed to create group.'); + } + + await closeSphere(); + break; + } + + case 'group-list': { + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const groups = await sphere.groupChat.fetchAvailableGroups(); + + console.log(`\nAvailable Groups (${groups.length}):`); + console.log('─'.repeat(60)); + + if (groups.length === 0) { + console.log('No groups found on relay.'); + } else { + for (const group of groups) { + console.log(`${group.name} [${group.visibility}]`); + console.log(` ID: ${group.id}`); + if (group.description) console.log(` Description: ${group.description}`); + if (group.memberCount != null) console.log(` Members: ${group.memberCount}`); + console.log(''); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + case 'group-my': { + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const groups = sphere.groupChat.getGroups(); + + console.log(`\nYour Groups (${groups.length}):`); + console.log('─'.repeat(60)); + + if (groups.length === 0) { + console.log('You have not joined any groups.'); + } else { + for (const group of groups) { + const unreadBadge = (group.unreadCount || 0) > 0 ? ` [${group.unreadCount} unread]` : ''; + console.log(`${group.name}${unreadBadge}`); + console.log(` ID: ${group.id}`); + if (group.lastMessageText) { + const preview = group.lastMessageText.length > 60 + ? group.lastMessageText.slice(0, 60) + '...' + : group.lastMessageText; + console.log(` Last: ${preview}`); + } + console.log(''); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + case 'group-join': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-join [--invite ]'); + console.error(' Example: npm run cli -- group-join tradingchat'); + process.exit(1); + } + + const inviteIndex = args.indexOf('--invite'); + const inviteCode = inviteIndex !== -1 && args[inviteIndex + 1] ? args[inviteIndex + 1] : undefined; + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const success = await sphere.groupChat.joinGroup(groupId, inviteCode); + + if (success) { + console.log(`\n✓ Joined group: ${groupId}`); + } else { + console.error(`\n✗ Failed to join group: ${groupId}`); + } + + await closeSphere(); + break; + } + + case 'group-leave': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-leave '); + console.error(' Example: npm run cli -- group-leave tradingchat'); + process.exit(1); + } + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const success = await sphere.groupChat.leaveGroup(groupId); + + if (success) { + console.log(`\n✓ Left group: ${groupId}`); + } else { + console.error(`\n✗ Failed to leave group: ${groupId}`); + } + + await closeSphere(); + break; + } + + case 'group-send': { + const groupId = args[1]; + // Collect message: everything after groupId that isn't a flag or flag value + const msgParts: string[] = []; + let skipNext = false; + for (let i = 2; i < args.length; i++) { + if (skipNext) { skipNext = false; continue; } + if (args[i] === '--reply') { skipNext = true; continue; } + if (args[i].startsWith('--')) continue; + msgParts.push(args[i]); + } + const msgContent = msgParts.join(' '); + + if (!groupId || !msgContent) { + console.error('Usage: group-send [--reply ]'); + console.error(' Example: npm run cli -- group-send tradingchat "Hello everyone!"'); + process.exit(1); + } + + const replyIndex = args.indexOf('--reply'); + const replyToId = replyIndex !== -1 && args[replyIndex + 1] ? args[replyIndex + 1] : undefined; + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const sent = await sphere.groupChat.sendMessage(groupId, msgContent, replyToId); + + if (sent) { + console.log('\n✓ Message sent!'); + console.log(` ID: ${sent.id}`); + console.log(` Group: ${groupId}`); + } else { + console.error('\n✗ Failed to send message.'); + } + + await closeSphere(); + break; + } + + case 'group-messages': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-messages [--limit ]'); + console.error(' Example: npm run cli -- group-messages tradingchat --limit 20'); + process.exit(1); + } + + const limitIndex = args.indexOf('--limit'); + const limit = limitIndex !== -1 && args[limitIndex + 1] ? parseInt(args[limitIndex + 1]) : 50; + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + + // Fetch latest from relay + await sphere.groupChat.fetchMessages(groupId, undefined, limit); + + // Get from local state (sorted) + const messages = sphere.groupChat.getMessages(groupId).slice(-limit); + + const group = sphere.groupChat.getGroup(groupId); + const groupLabel = group?.name || groupId; + + console.log(`\nMessages in "${groupLabel}" (${messages.length}):`); + console.log('─'.repeat(60)); + + if (messages.length === 0) { + console.log('No messages found.'); + } else { + for (const msg of messages) { + const time = new Date(msg.timestamp).toLocaleString(); + const sender = msg.senderNametag ? `@${msg.senderNametag}` : msg.senderPubkey.slice(0, 12) + '...'; + const replyTag = msg.replyToId ? ` (reply)` : ''; + console.log(`[${time}] ${sender}${replyTag}: ${msg.content}`); + } + } + console.log('─'.repeat(60)); + + // Mark as read + sphere.groupChat.markGroupAsRead(groupId); + await closeSphere(); + break; + } + + case 'group-members': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-members '); + console.error(' Example: npm run cli -- group-members tradingchat'); + process.exit(1); + } + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const members = sphere.groupChat.getMembers(groupId); + const group = sphere.groupChat.getGroup(groupId); + const groupLabel = group?.name || groupId; + + console.log(`\nMembers of "${groupLabel}" (${members.length}):`); + console.log('─'.repeat(60)); + + if (members.length === 0) { + console.log('No members found. Try joining the group first.'); + } else { + for (const member of members) { + const label = member.nametag ? `@${member.nametag}` : member.pubkey.slice(0, 16) + '...'; + const roleBadge = member.role === 'ADMIN' ? ' [ADMIN]' : member.role === 'MODERATOR' ? ' [MOD]' : ''; + const joined = new Date(member.joinedAt).toLocaleDateString(); + console.log(` ${label}${roleBadge} (joined ${joined})`); + } + } + console.log('─'.repeat(60)); + await closeSphere(); + break; + } + + case 'group-info': { + const groupId = args[1]; + if (!groupId) { + console.error('Usage: group-info '); + console.error(' Example: npm run cli -- group-info tradingchat'); + process.exit(1); + } + + const sphere = await getSphere(); + + if (!sphere.groupChat) { + console.error('Group chat module not available.'); + process.exit(1); + } + + await sphere.groupChat.connect(); + const group = sphere.groupChat.getGroup(groupId); + + if (!group) { + console.error(`Group "${groupId}" not found. You may need to join it first.`); + process.exit(1); + } + + const myRole = sphere.groupChat.getCurrentUserRole(groupId); + const members = sphere.groupChat.getMembers(groupId); + + console.log('\nGroup Info:'); + console.log('─'.repeat(50)); + console.log(`ID: ${group.id}`); + console.log(`Name: ${group.name}`); + console.log(`Visibility: ${group.visibility}`); + if (group.description) console.log(`Description: ${group.description}`); + console.log(`Members: ${members.length}`); + console.log(`Created: ${new Date(group.createdAt).toLocaleString()}`); + if (group.lastMessageTime) console.log(`Last Active: ${new Date(group.lastMessageTime).toLocaleString()}`); + if (myRole) console.log(`Your Role: ${myRole}`); + console.log(`Relay: ${group.relayUrl}`); + console.log('─'.repeat(50)); + await closeSphere(); + break; + } + + // === MARKET (Intent Bulletin Board) === + case 'market-post': { + const description = args[1]; + if (!description) { + console.error('Usage: market-post --type [--category ] [--price ] [--currency ] [--location ] [--contact ] [--expires ]'); + process.exit(1); + } + + const typeIndex = args.indexOf('--type'); + const intentType = typeIndex !== -1 ? args[typeIndex + 1] : undefined; + if (!intentType) { + console.error('Error: --type is required (buy, sell, service, announcement, other)'); + process.exit(1); + } + + const categoryIndex = args.indexOf('--category'); + const category = categoryIndex !== -1 ? args[categoryIndex + 1] : undefined; + + const priceIndex = args.indexOf('--price'); + const price = priceIndex !== -1 ? parseFloat(args[priceIndex + 1]) : undefined; + + const currencyIndex = args.indexOf('--currency'); + const currency = currencyIndex !== -1 ? args[currencyIndex + 1] : undefined; + + const locationIndex = args.indexOf('--location'); + const location = locationIndex !== -1 ? args[locationIndex + 1] : undefined; + + const contactIndex = args.indexOf('--contact'); + const contactHandle = contactIndex !== -1 ? args[contactIndex + 1] : undefined; + + const expiresIndex = args.indexOf('--expires'); + const expiresInDays = expiresIndex !== -1 ? parseInt(args[expiresIndex + 1]) : undefined; + + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + const result = await sphere.market.postIntent({ + description, + intentType, + category, + price, + currency, + location, + contactHandle, + expiresInDays, + }); + + console.log('✓ Intent posted!'); + console.log(` ID: ${result.intentId}`); + console.log(` Expires: ${result.expiresAt}`); + + await closeSphere(); + break; + } + + case 'market-search': { + const query = args[1]; + if (!query) { + console.error('Usage: market-search [--type ] [--category ] [--min-price ] [--max-price ] [--min-score <0-1>] [--location ] [--limit ]'); + process.exit(1); + } + + const typeIndex = args.indexOf('--type'); + const intentType = typeIndex !== -1 ? args[typeIndex + 1] : undefined; + + const categoryIndex = args.indexOf('--category'); + const category = categoryIndex !== -1 ? args[categoryIndex + 1] : undefined; + + const minPriceIndex = args.indexOf('--min-price'); + const minPrice = minPriceIndex !== -1 ? parseFloat(args[minPriceIndex + 1]) : undefined; + + const maxPriceIndex = args.indexOf('--max-price'); + const maxPrice = maxPriceIndex !== -1 ? parseFloat(args[maxPriceIndex + 1]) : undefined; + + const minScoreIndex = args.indexOf('--min-score'); + const minScore = minScoreIndex !== -1 ? parseFloat(args[minScoreIndex + 1]) : undefined; + + const locationIndex = args.indexOf('--location'); + const location = locationIndex !== -1 ? args[locationIndex + 1] : undefined; + + const limitIndex = args.indexOf('--limit'); + const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 10; + + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + const result = await sphere.market.search(query, { + filters: { + intentType, + category, + minPrice, + maxPrice, + minScore, + location, + }, + limit, + }); + + console.log(`Found ${result.count} intent(s):`); + console.log('─'.repeat(50)); + + for (const intent of result.intents) { + const scoreStr = intent.score != null ? `[${intent.score.toFixed(2)}] ` : ''; + console.log(`${scoreStr}${intent.description}`); + const byStr = intent.agentNametag ? `@${intent.agentNametag}` : intent.agentPublicKey.slice(0, 12) + '...'; + console.log(` By: ${byStr}`); + let details = ` Type: ${intent.intentType}`; + if (intent.category) details += ` | Category: ${intent.category}`; + if (intent.price != null) details += ` | Price: ${intent.price} ${intent.currency || ''}`; + console.log(details); + let extra = ''; + if (intent.contactHandle) extra += ` Contact: ${intent.contactHandle}`; + if (intent.expiresAt) extra += `${extra ? ' | ' : ' '}Expires: ${intent.expiresAt.split('T')[0]}`; + if (extra) console.log(extra); + console.log('─'.repeat(50)); + } + + await closeSphere(); + break; + } + + case 'market-my': { + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + const intents = await sphere.market.getMyIntents(); + + console.log(`Your intents (${intents.length}):`); + for (const intent of intents) { + const desc = intent.id; + const cat = intent.category || ''; + const expires = intent.expiresAt ? intent.expiresAt.split('T')[0] : ''; + console.log(` ${desc} ${intent.intentType} ${intent.status} ${cat} expires ${expires}`); + } + + await closeSphere(); + break; + } + + case 'market-close': { + const intentId = args[1]; + if (!intentId) { + console.error('Usage: market-close '); + process.exit(1); + } + + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + await sphere.market.closeIntent(intentId); + console.log(`✓ Intent ${intentId} closed.`); + + await closeSphere(); + break; + } + + case 'market-feed': { + const useRest = args.includes('--rest'); + const sphere = await getSphere(); + + if (!sphere.market) { + console.error('Market module not available.'); + process.exit(1); + } + + if (useRest) { + // REST fallback: fetch recent listings once + const listings = await sphere.market.getRecentListings(); + console.log(`Recent listings (${listings.length}):`); + console.log('─'.repeat(50)); + for (const listing of listings) { + console.log(`[${listing.type.toUpperCase()}] ${listing.agentName}: ${listing.title}`); + if (listing.descriptionPreview !== listing.title) { + console.log(` ${listing.descriptionPreview}`); + } + console.log(` ID: ${listing.id} Posted: ${listing.createdAt}`); + console.log(''); + } + await closeSphere(); + } else { + // WebSocket live feed + console.log('Connecting to live feed... (Ctrl+C to stop)'); + const unsubscribe = sphere.market.subscribeFeed((message) => { + if (message.type === 'initial') { + console.log(`Connected — ${message.listings.length} recent listing(s):`); + console.log('─'.repeat(50)); + for (const listing of message.listings) { + console.log(`[${listing.type.toUpperCase()}] ${listing.agentName}: ${listing.title}`); + } + console.log('─'.repeat(50)); + console.log('Watching for new listings...\n'); + } else { + const l = message.listing; + console.log(`[NEW] [${l.type.toUpperCase()}] ${l.agentName}: ${l.title}`); + if (l.descriptionPreview !== l.title) { + console.log(` ${l.descriptionPreview}`); + } + } + }); + + // Keep alive until Ctrl+C + process.on('SIGINT', () => { + console.log('\nDisconnecting...'); + unsubscribe(); + closeSphere().then(() => process.exit(0)); + }); + + // Prevent the process from exiting + await new Promise(() => {}); + } + break; + } + + // ================================================================= + // INVOICE MANAGEMENT (AccountingModule) + // ================================================================= + + case 'invoice-create': { + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled. Initialize with accounting support.'); + process.exit(1); + } + + // Parse options + const targetIdx = args.indexOf('--target'); + const assetIdx = args.indexOf('--asset'); + const nftIdx = args.indexOf('--nft'); + const dueIdx = args.indexOf('--due'); + const memoIdx = args.indexOf('--memo'); + const deliveryIdx = args.indexOf('--delivery'); + const termsIdx = args.indexOf('--terms'); + + if (termsIdx !== -1 && args[termsIdx + 1]) { + // Load terms from JSON file + const termsFile = args[termsIdx + 1]; + let termsJson: unknown; + try { + const resolvedPath = path.resolve(termsFile); + const raw = fs.readFileSync(resolvedPath, 'utf8'); + termsJson = JSON.parse(raw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Sanitize: only show whether it was a file read or JSON parse error + if (msg.includes('ENOENT')) { + console.error(`File not found: "${termsFile}"`); + } else if (msg.includes('EACCES') || msg.includes('EPERM')) { + console.error(`Access denied: "${termsFile}"`); + } else if (msg.includes('Unexpected token') || msg.includes('JSON')) { + console.error(`Invalid JSON in terms file "${termsFile}"`); + } else { + console.error(`Failed to read terms file "${termsFile}"`); + } + process.exit(1); + } + let result; + try { + result = await sphere.accounting.createInvoice(stripDangerousKeys(termsJson) as CreateInvoiceRequest); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Failed to create invoice from terms file: ${msg}`); + process.exit(1); + } + console.log('Invoice created:'); + console.log(JSON.stringify(result, null, 2)); + } else { + // Build from individual options + if (targetIdx === -1 || !args[targetIdx + 1]) { + console.error('Usage: invoice-create --target
--asset " " [--nft ] [--due ] [--memo ] [--delivery ] [--terms ]'); + process.exit(1); + } + const targetAddress = args[targetIdx + 1]; + const nftId = nftIdx !== -1 ? args[nftIdx + 1] : undefined; + const dueDate = dueIdx !== -1 ? new Date(args[dueIdx + 1]).getTime() : undefined; + if (dueDate !== undefined && isNaN(dueDate)) { + console.error('Invalid due date format. Use ISO-8601, e.g. 2026-12-31'); + process.exit(1); + } + const memo = memoIdx !== -1 ? args[memoIdx + 1] : undefined; + const delivery = deliveryIdx !== -1 ? args[deliveryIdx + 1] : undefined; + + const assets: InvoiceRequestedAsset[] = []; + if (assetIdx !== -1 && args[assetIdx + 1]) { + const parsed = parseAssetArg(args[assetIdx + 1]); + if (!/^[1-9][0-9]*$/.test(parsed.amount)) { + console.error(`Invalid amount "${parsed.amount}" — must be a positive integer in smallest units (no decimals, no leading zeros)`); + process.exit(1); + } + const { coinId: resolvedCoinId } = resolveCoin(parsed.coin); + assets.push({ coin: [resolvedCoinId, parsed.amount] }); + } else if (nftId) { + assets.push({ nft: { tokenId: nftId } }); + } + + const request: CreateInvoiceRequest = { + targets: [{ address: targetAddress, assets }], + dueDate, + memo, + deliveryMethods: delivery ? [delivery] : undefined, + }; + const result = await sphere.accounting.createInvoice(request); + console.log('Invoice created:'); + console.log(JSON.stringify(result, null, 2)); + } + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-import': { + const tokenFile = args[1]; + if (!tokenFile) { + console.error('Usage: invoice-import '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + let tokenJson: unknown; + try { + tokenJson = JSON.parse(fs.readFileSync(path.resolve(tokenFile), 'utf8')); + } catch (err: unknown) { + // W23-R2 fix: Sanitize error messages to avoid leaking file system paths + const code = (err as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') { + console.error(`Token file not found: "${tokenFile}"`); + } else if (code === 'EACCES') { + console.error(`Permission denied reading: "${tokenFile}"`); + } else if (err instanceof SyntaxError) { + console.error(`Invalid JSON in token file: "${tokenFile}"`); + } else { + console.error(`Failed to read token file: "${tokenFile}"`); + } + process.exit(1); + } + + let terms; + try { + terms = await sphere.accounting.importInvoice(stripDangerousKeys(tokenJson) as TxfToken); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Failed to import invoice: ${msg}`); + process.exit(1); + } + console.log('Invoice imported:'); + console.log(JSON.stringify(terms, null, 2)); + + await closeSphere(); + break; + } + + case 'invoice-list': { + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const stateIdx = args.indexOf('--state'); + const limitIdx2 = args.indexOf('--limit'); + const createdByMe = args.includes('--role') && args[args.indexOf('--role') + 1] === 'creator'; + const targetingMe = args.includes('--role') && args[args.indexOf('--role') + 1] === 'payer'; + const stateFilter = stateIdx !== -1 ? args[stateIdx + 1] : undefined; + + const validStates = new Set(['OPEN', 'PARTIAL', 'COVERED', 'CLOSED', 'CANCELLED', 'EXPIRED']); + const optionsMut: Record = {}; + if (createdByMe) optionsMut['createdByMe'] = true; + if (targetingMe) optionsMut['targetingMe'] = true; + if (stateFilter) { + const stateValues = stateFilter.split(',').map(s => s.trim()); + const invalid = stateValues.filter(s => !validStates.has(s)); + if (invalid.length > 0) { + console.error(`Invalid state(s): ${invalid.join(', ')}. Valid: ${[...validStates].join(', ')}`); + process.exit(1); + } + optionsMut['state'] = stateValues.length === 1 ? stateValues[0] : stateValues; + } + if (limitIdx2 !== -1 && args[limitIdx2 + 1]) { + const limit = parseInt(args[limitIdx2 + 1]!, 10); + if (!Number.isNaN(limit) && limit > 0) { + optionsMut['limit'] = limit; + } + } + const options = optionsMut as GetInvoicesOptions; + + const invoices = await sphere.accounting.getInvoices(options); + + if (invoices.length === 0) { + console.log('No invoices found.'); + } else { + console.log(`Invoices (${invoices.length}):`); + console.log('─'.repeat(60)); + for (const inv of invoices) { + console.log(`ID: ${inv.invoiceId}`); + console.log(`Creator: ${inv.isCreator ? 'yes' : 'no'}`); + console.log(`Cancelled: ${inv.cancelled}`); + console.log(`Closed: ${inv.closed}`); + if (inv.terms.dueDate) console.log(`Due: ${new Date(inv.terms.dueDate).toISOString()}`); + if (inv.terms.memo) console.log(`Memo: ${inv.terms.memo}`); + console.log('─'.repeat(60)); + } + } + + await closeSphere(); + break; + } + + case 'invoice-status': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-status '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + // Resolve ID from prefix + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices. Use more characters.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const status = await sphere.accounting.getInvoiceStatus(invoiceId); + console.log('Invoice Status:'); + console.log(JSON.stringify(status, null, 2)); + + await closeSphere(); + break; + } + + case 'invoice-close': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-close '); + process.exit(1); + } + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const autoReturn = args.includes('--auto-return'); + await sphere.accounting.closeInvoice(invoiceId, autoReturn ? { autoReturn: true } : undefined); + console.log(`Invoice ${invoiceId} closed.${autoReturn ? ' Auto-return triggered.' : ''}`); + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-cancel': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-cancel '); + process.exit(1); + } + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + await sphere.accounting.cancelInvoice(invoiceId); + console.log(`Invoice ${invoiceId} cancelled.`); + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-pay': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-pay [--amount ] [--target-index ]'); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'full'); + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const amountIdx2 = args.indexOf('--amount'); + const targetIndexIdx = args.indexOf('--target-index'); + + const rawTargetIdx = targetIndexIdx !== -1 ? args[targetIndexIdx + 1] : undefined; + const targetIndex = rawTargetIdx !== undefined ? parseInt(rawTargetIdx, 10) : 0; + if (isNaN(targetIndex) || targetIndex < 0) { + console.error('--target-index must be a non-negative integer'); + process.exit(1); + } + + const payParamsMut: Record = { targetIndex }; + if (amountIdx2 !== -1 && args[amountIdx2 + 1]) { + const rawAmount = args[amountIdx2 + 1]; + if (!/^[1-9][0-9]*$/.test(rawAmount!)) { + console.error(`Invalid amount "${rawAmount}" — must be a positive integer in smallest units (no decimals, no leading zeros)`); + process.exit(1); + } + payParamsMut['amount'] = rawAmount; + } + const payParams = payParamsMut as unknown as PayInvoiceParams; + + const result = await sphere.accounting.payInvoice(invoiceId, payParams); + console.log('Payment result:'); + console.log(JSON.stringify({ id: result.id, status: result.status }, null, 2)); + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-return': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-return --recipient
--asset " "'); + process.exit(1); + } + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const recipientIdx = args.indexOf('--recipient'); + const assetIdx3 = args.indexOf('--asset'); + + if (recipientIdx === -1 || !args[recipientIdx + 1]) { + console.error('--recipient
is required for invoice-return'); + process.exit(1); + } + + let returnAmount: string; + let returnCoinId: string; + + if (assetIdx3 !== -1 && args[assetIdx3 + 1]) { + const parsed = parseAssetArg(args[assetIdx3 + 1]); + returnAmount = parsed.amount; + returnCoinId = resolveCoin(parsed.coin).coinId; + } else { + console.error('--asset " " is required for invoice-return'); + process.exit(1); + } + + if (!/^[1-9][0-9]*$/.test(returnAmount)) { + console.error(`Invalid amount "${returnAmount}" — must be a positive integer string (smallest unit, no leading zeros, e.g. 1000000)`); + process.exit(1); + } + + const returnParams: ReturnPaymentParams = { + recipient: args[recipientIdx + 1], + amount: returnAmount, + coinId: returnCoinId, + }; + + const result = await sphere.accounting.returnInvoicePayment(invoiceId, returnParams); + console.log('Return payment result:'); + console.log(JSON.stringify({ id: result.id, status: result.status }, null, 2)); + + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'invoice-receipts': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-receipts '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const result = await sphere.accounting.sendInvoiceReceipts(invoiceId); + console.log('Receipts result:'); + console.log(JSON.stringify(result, null, 2)); + + await closeSphere(); + break; + } + + case 'invoice-notices': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-notices '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const result = await sphere.accounting.sendCancellationNotices(invoiceId); + console.log('Cancellation notices result:'); + console.log(JSON.stringify(result, null, 2)); + + await closeSphere(); + break; + } + + case 'invoice-auto-return': { + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const enableFlag = args.includes('--enable'); + const disableFlag = args.includes('--disable'); + const invoiceIdx = args.indexOf('--invoice'); + // W7-R17 fix: Validate --invoice has a following argument + if (invoiceIdx !== -1 && !args[invoiceIdx + 1]) { + console.error('--invoice requires an invoice ID'); + process.exit(1); + } + const specificInvoice = invoiceIdx !== -1 ? args[invoiceIdx + 1] : undefined; + + if (!enableFlag && !disableFlag) { + // Show current settings + const settings = sphere.accounting.getAutoReturnSettings(); + console.log('Auto-return settings:'); + console.log(JSON.stringify(settings, null, 2)); + } else if (enableFlag && disableFlag) { + console.error('Cannot use both --enable and --disable'); + process.exit(1); + } else { + const enabled = enableFlag; + const invoiceId = specificInvoice ?? '*'; + await sphere.accounting.setAutoReturn(invoiceId, enabled); + const scope = invoiceId === '*' ? 'globally' : `for invoice ${invoiceId}`; + console.log(`Auto-return ${enabled ? 'enabled' : 'disabled'} ${scope}.`); + } + + await closeSphere(); + break; + } + + case 'invoice-transfers': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-transfers '); + process.exit(1); + } + + const sphere = await getSphere(); + await ensureSync(sphere, 'full'); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + const transfers = sphere.accounting.getRelatedTransfers(invoiceId); + if (transfers.length === 0) { + console.log('No related transfers found.'); + } else { + console.log(`Related transfers (${transfers.length}):`); + console.log(JSON.stringify(transfers, null, 2)); + } + + await closeSphere(); + break; + } + + case 'invoice-export': { + const idOrPrefix = args[1]; + if (!idOrPrefix) { + console.error('Usage: invoice-export '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const allInvoices = await sphere.accounting.getInvoices(); + const matched = allInvoices.filter(inv => inv.invoiceId.startsWith(idOrPrefix)); + if (matched.length === 0) { + console.error(`No invoice found matching prefix: ${idOrPrefix}`); + process.exit(1); + } + if (matched.length > 1) { + console.error(`Ambiguous prefix "${idOrPrefix}" matches ${matched.length} invoices.`); + process.exit(1); + } + const invoiceId = matched[0].invoiceId; + + // Get the invoice ref via getInvoice + const invoiceRef = sphere.accounting.getInvoice(invoiceId); + if (!invoiceRef) { + console.error(`Invoice ${invoiceId} not found in memory.`); + process.exit(1); + } + + const outFile = `invoice-${invoiceId.slice(0, 8)}.json`; + writeAtomic(outFile, JSON.stringify(invoiceRef, null, 2)); + console.log(`Invoice exported to: ${outFile}`); + + await closeSphere(); + break; + } + + case 'invoice-parse-memo': { + const memoStr = args[1]; + if (!memoStr) { + console.error('Usage: invoice-parse-memo '); + process.exit(1); + } + + const sphere = await getSphere(); + if (!sphere.accounting) { + console.error('Accounting module not enabled.'); + process.exit(1); + } + + const parsed = sphere.accounting.parseInvoiceMemo(memoStr); + if (!parsed) { + console.log('Not a valid invoice memo.'); + } else { + console.log('Parsed invoice memo:'); + console.log(JSON.stringify(parsed, null, 2)); + } + + await closeSphere(); + break; + } + + // ===================================================================== + // Swap Commands + // ===================================================================== + + case 'swap-propose': { + const toIdx = args.indexOf('--to'); + const escrowIdx = args.indexOf('--escrow'); + const timeoutIdx = args.indexOf('--timeout'); + const messageIdx = args.indexOf('--message'); + + // Combined format: --offer " " --want " " + const offerIdx = args.indexOf('--offer'); + const wantIdx = args.indexOf('--want'); + + if (toIdx === -1 || !args[toIdx + 1] || + offerIdx === -1 || !args[offerIdx + 1] || args[offerIdx + 1].startsWith('--') || + wantIdx === -1 || !args[wantIdx + 1] || args[wantIdx + 1].startsWith('--')) { + console.error('Usage: swap-propose --to --offer " " --want " " [--escrow
] [--timeout ] [--message ]'); + process.exit(1); + } + + const offer = parseAssetArg(args[offerIdx + 1]); + const want = parseAssetArg(args[wantIdx + 1]); + + let timeout = 3600; + if (timeoutIdx !== -1 && args[timeoutIdx + 1]) { + timeout = parseInt(args[timeoutIdx + 1], 10); + if (isNaN(timeout) || timeout < 60 || timeout > 86400) { + console.error('--timeout must be an integer between 60 and 86400 seconds'); + process.exit(1); + } + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled. Initialize with swap support.'); + process.exit(1); + } + + // Resolve coin symbols and convert human-readable amounts to smallest units + // (e.g., "10 BTC" → 10 * 10^8 = 1000000000 smallest units) + const offerCoin = resolveCoin(offer.coin); + const wantCoin = resolveCoin(want.coin); + const offerSmallest = toSmallestUnit(offer.amount, offerCoin.decimals); + const wantSmallest = toSmallestUnit(want.amount, wantCoin.decimals); + if (offerSmallest <= 0n) { + console.error(`Invalid offer amount "${offer.amount}" — must be a positive number`); + process.exit(1); + } + if (wantSmallest <= 0n) { + console.error(`Invalid want amount "${want.amount}" — must be a positive number`); + process.exit(1); + } + + const escrow = escrowIdx !== -1 ? args[escrowIdx + 1] : undefined; + const message = messageIdx !== -1 ? args[messageIdx + 1] : undefined; + + const deal = { + partyA: sphere.identity!.directAddress!, + partyB: args[toIdx + 1], + partyACurrency: offerCoin.symbol, + partyAAmount: offerSmallest.toString(), + partyBCurrency: wantCoin.symbol, + partyBAmount: wantSmallest.toString(), + timeout: timeout, + escrowAddress: escrow, + }; + + const result = await swapModule.proposeSwap(deal, message ? { message } : undefined); + console.log('Swap proposed:'); + console.log(JSON.stringify({ + swap_id: result.swapId, + counterparty: args[toIdx + 1], + offer: `${offer.amount} ${offerCoin.symbol}`, + want: `${want.amount} ${wantCoin.symbol}`, + escrow: deal.escrowAddress ?? '(config default)', + timeout: timeout, + status: result.swap?.progress ?? 'proposed', + }, null, 2)); + + await closeSphere(); + break; + } + + case 'swap-list': { + const allFlag = args.includes('--all'); + const roleIdx = args.indexOf('--role'); + const progressIdx = args.indexOf('--progress'); + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filter: any = {}; + if (roleIdx !== -1 && args[roleIdx + 1]) { + const role = args[roleIdx + 1]; + if (role !== 'proposer' && role !== 'acceptor') { + console.error('--role must be "proposer" or "acceptor"'); + process.exit(1); + } + filter.role = role; + } + if (progressIdx !== -1 && args[progressIdx + 1]) { + filter.progress = args[progressIdx + 1]; + } + if (!allFlag && !filter.progress) { + filter.excludeTerminal = true; + } + + const swaps = await swapModule.getSwaps(filter); + if (!swaps || swaps.length === 0) { + console.log('No swaps found.'); + } else { + const isTTY = process.stdout.isTTY; + const green = isTTY ? '\x1b[32m' : ''; + const red = isTTY ? '\x1b[31m' : ''; + const yellow = isTTY ? '\x1b[33m' : ''; + const reset = isTTY ? '\x1b[0m' : ''; + + const header = [ + 'SWAP ID'.padEnd(10), + 'ROLE'.padEnd(12), + 'PROGRESS'.padEnd(20), + 'OFFER'.padEnd(18), + 'WANT'.padEnd(18), + 'COUNTERPARTY'.padEnd(16), + 'CREATED', + ].join(''); + console.log(header); + + for (const swap of swaps) { + const id = (swap.swapId || '').slice(0, 8); + const role = swap.role || ''; + const progress = swap.progress || ''; + + // Determine counterparty display + const isProposer = role === 'proposer'; + // Format amounts back to human-readable (manifest stores smallest units) + const fmtSwapAmt = (amount: string | undefined, currency: string | undefined): string => { + if (!amount || !currency) return ''; + try { + const def = resolveCoin(currency); + return `${toHumanReadable(amount, def.decimals)} ${def.symbol}`; + } catch { + return `${amount} ${currency} (raw)`; + } + }; + const offerStr = isProposer + ? fmtSwapAmt(swap.deal?.partyAAmount, swap.deal?.partyACurrency) + : fmtSwapAmt(swap.deal?.partyBAmount, swap.deal?.partyBCurrency); + const wantStr = isProposer + ? fmtSwapAmt(swap.deal?.partyBAmount, swap.deal?.partyBCurrency) + : fmtSwapAmt(swap.deal?.partyAAmount, swap.deal?.partyACurrency); + // Show nametag if available, otherwise truncated address + let counterparty: string; + if (swap.counterpartyNametag) { + counterparty = '@' + swap.counterpartyNametag; + } else if (isProposer) { + counterparty = (swap.deal?.partyB ?? '').slice(0, 14); + } else { + counterparty = (swap.deal?.partyA ?? '').slice(0, 14); + } + + // Relative time + const elapsed = Date.now() - (swap.createdAt || 0); + let timeStr: string; + if (elapsed < 60_000) timeStr = 'just now'; + else if (elapsed < 3_600_000) timeStr = `${Math.floor(elapsed / 60_000)} min ago`; + else if (elapsed < 86_400_000) timeStr = `${Math.floor(elapsed / 3_600_000)} hour ago`; + else timeStr = `${Math.floor(elapsed / 86_400_000)} days ago`; + + // Color progress + let coloredProgress = progress; + if (progress === 'completed') coloredProgress = `${green}${progress}${reset}`; + else if (progress === 'failed' || progress === 'cancelled') coloredProgress = `${red}${progress}${reset}`; + else coloredProgress = `${yellow}${progress}${reset}`; + + const row = [ + id.padEnd(10), + role.padEnd(12), + // Pad the raw progress (color codes are zero-width for terminal) + coloredProgress + ' '.repeat(Math.max(0, 20 - progress.length)), + offerStr.padEnd(18), + wantStr.padEnd(18), + counterparty.padEnd(16), + timeStr, + ].join(''); + console.log(row); + } + } + + await closeSphere(); + break; + } + + case 'swap-accept': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-accept [--deposit] [--no-wait]'); + process.exit(1); + } + + const depositFlag = args.includes('--deposit'); + const noWaitFlag = args.includes('--no-wait'); + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + await swapModule.acceptSwap(swapId); + console.log('Swap accepted. Announced to escrow. Waiting for deposit invoice...'); + + if (depositFlag) { + // acceptSwap() sends the announce to the escrow but the escrow's invoice_delivery + // DM arrives asynchronously. Wait for swap:announced before depositing. + const acceptAnnounced = await new Promise((resolve) => { + const acceptUnsubs: (() => void)[] = []; + const timeout = setTimeout(() => { + acceptUnsubs.forEach(u => u()); + resolve(false); + }, 60_000); + const done = (ok: boolean) => { + clearTimeout(timeout); + acceptUnsubs.forEach(u => u()); + resolve(ok); + }; + acceptUnsubs.push(sphere.on('swap:announced', (e: { swapId: string }) => { + if (e.swapId === swapId) done(true); + })); + acceptUnsubs.push(sphere.on('swap:failed', (e: { swapId: string }) => { + if (e.swapId === swapId) done(false); + })); + acceptUnsubs.push(sphere.on('swap:cancelled', (e: { swapId: string }) => { + if (e.swapId === swapId) done(false); + })); + }); + if (!acceptAnnounced) { + const finalStatus = await swapModule.getSwapStatus(swapId); + console.error(`Swap did not reach 'announced' state (current: ${finalStatus?.progress ?? 'unknown'}). Check swap-status for details.`); + await closeSphere(); + process.exit(1); + } + + const depositResult = await swapModule.deposit(swapId); + console.log(`Deposit sent: ${depositResult.id}`); + + if (!noWaitFlag) { + // Wait for terminal event + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapRef = await swapModule.getSwapStatus(swapId); + const waitTimeout = 2 * (swapRef?.deal?.timeout ?? 3600) * 1000; + const unsubs: (() => void)[] = []; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubs.forEach(u => u()); + reject(new Error(`Swap did not complete within timeout. Current progress: ${swapRef?.progress ?? 'unknown'}`)); + }, waitTimeout); + + const done = (msg: string) => { + clearTimeout(timer); + unsubs.forEach(u => u()); + console.log(msg); + resolve(); + }; + + unsubs.push(sphere.on('swap:completed', (e: { swapId: string }) => { + if (e.swapId === swapId) done('[swap] Swap completed!'); + })); + unsubs.push(sphere.on('swap:cancelled', (e: { swapId: string }) => { + if (e.swapId === swapId) done('[swap] Swap cancelled.'); + })); + unsubs.push(sphere.on('swap:failed', (e: { swapId: string; error: string }) => { + if (e.swapId === swapId) done(`[swap] Swap failed: ${e.error}`); + })); + unsubs.push(sphere.on('swap:deposit_confirmed', (e: { swapId: string }) => { + if (e.swapId === swapId) console.log('[swap] Deposit confirmed by escrow.'); + })); + unsubs.push(sphere.on('swap:deposits_covered', (e: { swapId: string }) => { + if (e.swapId === swapId) console.log('[swap] All deposits covered. Escrow concluding...'); + })); + unsubs.push(sphere.on('swap:payout_received', (e: { swapId: string }) => { + if (e.swapId === swapId) console.log('[swap] Payout received. Verifying...'); + })); + }); + } + } else { + console.log(`Run 'swap-deposit ${swapId}' to deposit when ready.`); + } + + await closeSphere(); + break; + } + + case 'swap-ping': { + const escrowAddr = args[1]; + if (!escrowAddr) { + console.error('Usage: swap-ping <@nametag_or_address>'); + process.exit(1); + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + await closeSphere(); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + try { + const pong = await swapModule.pingEscrow(escrowAddr); + console.log('Escrow is online:'); + console.log(JSON.stringify(pong, null, 2)); + } catch (err) { + console.error('Escrow ping failed:', err instanceof Error ? err.message : err); + await closeSphere(); + process.exit(1); + } + + await closeSphere(); + break; + } + + case 'swap-status': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-status [--query-escrow]'); + process.exit(1); + } + + const queryEscrow = args.includes('--query-escrow'); + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + const status = await swapModule.getSwapStatus(swapId, queryEscrow ? { queryEscrow: true } : undefined); + console.log('Swap Status:'); + console.log(JSON.stringify(status, null, 2)); + + if (status.depositInvoiceId && sphere.accounting) { + try { + const invoiceStatus = await sphere.accounting.getInvoiceStatus(status.depositInvoiceId); + console.log('\nDeposit Invoice Status:'); + console.log(JSON.stringify(invoiceStatus, null, 2)); + } catch { + // Non-fatal: invoice may not be imported yet + } + } + + await closeSphere(); + break; + } + + case 'swap-deposit': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-deposit '); + process.exit(1); + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'full'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + + // If the swap is still in 'accepted' (crash recovery re-announced to escrow but + // the escrow's invoice_delivery DM hasn't arrived yet), wait for swap:announced + // before calling deposit(). Crash recovery fires fire-and-forget after module load + // so the escrow response may arrive slightly after ensureSync completes. + const swapStatusBefore = await swapModule.getSwapStatus(swapId); + // Guard: if already deposited, don't re-deposit + if (swapStatusBefore?.progress === 'depositing' || swapStatusBefore?.progress === 'awaiting_counter' || + swapStatusBefore?.progress === 'concluding' || swapStatusBefore?.progress === 'completed') { + console.log(`Deposit already submitted (current state: ${swapStatusBefore.progress})`); + await closeSphere(); + break; + } + if (swapStatusBefore?.progress === 'proposed' || swapStatusBefore?.progress === 'accepted') { + const waitLabel = swapStatusBefore.progress === 'proposed' + ? 'Waiting for Bob to accept and escrow to confirm (up to 120s)...' + : 'Waiting for escrow to deliver deposit invoice (up to 60s)...'; + console.log(waitLabel); + const waitMs = swapStatusBefore.progress === 'proposed' ? 120_000 : 60_000; + const announced = await new Promise((resolve) => { + const announceUnsubs: (() => void)[] = []; + const timeout = setTimeout(() => { + announceUnsubs.forEach(u => u()); + resolve(false); + }, waitMs); + const done = (ok: boolean) => { + clearTimeout(timeout); + announceUnsubs.forEach(u => u()); + resolve(ok); + }; + announceUnsubs.push(sphere.on('swap:announced', (e: { swapId: string }) => { + if (e.swapId === swapId) done(true); + })); + announceUnsubs.push(sphere.on('swap:failed', (e: { swapId: string }) => { + if (e.swapId === swapId) done(false); + })); + announceUnsubs.push(sphere.on('swap:cancelled', (e: { swapId: string }) => { + if (e.swapId === swapId) done(false); + })); + }); + if (!announced) { + const finalStatus = await swapModule.getSwapStatus(swapId); + console.error(`Swap did not reach 'announced' state (current: ${finalStatus?.progress ?? 'unknown'}). Try again shortly or check swap-status.`); + await closeSphere(); + process.exit(1); + } + } + + const result = await swapModule.deposit(swapId); + console.log('Deposit result:'); + console.log(JSON.stringify({ id: result.id, status: result.status }, null, 2)); + + // Wait for background tasks (e.g., change token creation from instant split) + await sphere.payments.waitForPendingOperations(); + await syncAfterWrite(sphere); + await closeSphere(); + break; + } + + case 'swap-reject': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-reject [reason]'); + process.exit(1); + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + // Optional reason from remaining args + const reason = args.slice(2).filter((a: string) => !a.startsWith('--')).join(' ') || undefined; + + await swapModule.rejectSwap(swapId, reason); + console.log(`Swap ${swapId.slice(0, 8)}... rejected.${reason ? ` Reason: ${reason}` : ''}`); + + await closeSphere(); + break; + } + + case 'swap-cancel': { + const swapIdArg = args[1]; + if (!swapIdArg) { + console.error('Usage: swap-cancel '); + process.exit(1); + } + + const sphere = await getSphere(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const swapModule = (sphere as any).swap; + if (!swapModule) { + console.error('Swap module not enabled.'); + process.exit(1); + } + await ensureSync(sphere, 'nostr'); + + const swapId = swapModule.resolveSwapId(swapIdArg); + await swapModule.cancelSwap(swapId); + console.log(`Swap ${swapId.slice(0, 8)}... cancelled.`); + + await closeSphere(); + break; + } + + case 'daemon': { + const sub = args[1] || 'start'; + const { runDaemon, stopDaemon, statusDaemon } = await import('./daemon.js'); + switch (sub) { + case 'start': + await runDaemon(args.slice(2), getSphere, closeSphere); + break; + case 'stop': + await stopDaemon(args.slice(2)); + break; + case 'status': + await statusDaemon(args.slice(2)); + break; + default: + console.error(`Unknown daemon sub-command: ${sub}`); + console.error('Usage: daemon start|stop|status'); + process.exit(1); + } + break; + } + + case 'completions': { + const shell = args[1]; + if (!shell || !['bash', 'zsh', 'fish'].includes(shell)) { + console.error('Usage: completions '); + console.error('Generate shell completion script for tab-completion.'); + console.error('\nSetup:'); + console.error(' sphere-cli completions bash >> ~/.bashrc'); + console.error(' sphere-cli completions zsh > ~/.zsh/completions/_sphere-cli'); + console.error(' sphere-cli completions fish > ~/.config/fish/completions/sphere-cli.fish'); + process.exit(1); + } + switch (shell) { + case 'bash': console.log(generateBashCompletions()); break; + case 'zsh': console.log(generateZshCompletions()); break; + case 'fish': console.log(generateFishCompletions()); break; + } + break; + } + + default: + console.error('Unknown command:', command); + console.error('Run with --help for usage'); + process.exit(1); + } + } catch (e) { + console.error('Error:', e instanceof Error ? e.message : e); + await closeSphere().catch(() => { /* best-effort cleanup */ }); + process.exit(1); + } +} + +// ============================================================================= +// Shell Completion Generators +// ============================================================================= + +interface CompletionCommand { + name: string; + description: string; + flags?: string[]; + subcommands?: CompletionCommand[]; +} + +function getCompletionCommands(): CompletionCommand[] { + return [ + { name: 'init', description: 'Create or import wallet', flags: ['--network', '--mnemonic', '--nametag', '--password', '--no-nostr'] }, + { name: 'status', description: 'Show wallet identity' }, + { name: 'config', description: 'Show or set CLI configuration' }, + { name: 'clear', description: 'Delete all wallet data' }, + { name: 'wallet', description: 'Manage wallet profiles', subcommands: [ + { name: 'list', description: 'List all wallet profiles' }, + { name: 'create', description: 'Create a new wallet profile', flags: ['--network'] }, + { name: 'use', description: 'Switch to a wallet profile' }, + { name: 'current', description: 'Show active profile' }, + { name: 'delete', description: 'Delete a wallet profile' }, + ]}, + { name: 'balance', description: 'Show L3 token balance', flags: ['--finalize', '--no-sync'] }, + { name: 'tokens', description: 'List all tokens', flags: ['--no-sync'] }, + { name: 'assets', description: 'List registered assets (coins & NFTs)', flags: ['--type'] }, + { name: 'asset-info', description: 'Show detailed info for an asset' }, + { name: 'l1-balance', description: 'Show L1 (ALPHA) balance' }, + { name: 'topup', description: 'Request test tokens from faucet' }, + { name: 'top-up', description: 'Alias for topup' }, + { name: 'faucet', description: 'Alias for topup' }, + { name: 'verify-balance', description: 'Verify tokens against aggregator', flags: ['--remove', '-v', '--verbose'] }, + { name: 'sync', description: 'Sync tokens with IPFS' }, + { name: 'send', description: 'Send L3 tokens', flags: ['--direct', '--proxy', '--instant', '--conservative', '--no-sync'] }, + { name: 'receive', description: 'Check for incoming transfers', flags: ['--finalize', '--no-sync'] }, + { name: 'history', description: 'Show transaction history' }, + { name: 'addresses', description: 'List tracked addresses' }, + { name: 'switch', description: 'Switch to HD address' }, + { name: 'hide', description: 'Hide address' }, + { name: 'unhide', description: 'Unhide address' }, + { name: 'nametag', description: 'Register a nametag' }, + { name: 'nametag-info', description: 'Look up nametag info' }, + { name: 'my-nametag', description: 'Show current nametag' }, + { name: 'nametag-sync', description: 'Re-publish nametag binding' }, + { name: 'dm', description: 'Send a direct message' }, + { name: 'dm-inbox', description: 'List conversations' }, + { name: 'dm-history', description: 'Show conversation history', flags: ['--limit'] }, + { name: 'group-create', description: 'Create a new group', flags: ['--description', '--private'] }, + { name: 'group-list', description: 'List available groups' }, + { name: 'group-my', description: 'List your joined groups' }, + { name: 'group-join', description: 'Join a group', flags: ['--invite'] }, + { name: 'group-leave', description: 'Leave a group' }, + { name: 'group-send', description: 'Send a message to a group', flags: ['--reply'] }, + { name: 'group-messages', description: 'Show group messages', flags: ['--limit'] }, + { name: 'group-members', description: 'List group members' }, + { name: 'group-info', description: 'Show group details' }, + { name: 'invoice-create', description: 'Create an invoice', flags: ['--target', '--asset', '--nft', '--due', '--memo', '--delivery', '--anonymous', '--terms'] }, + { name: 'invoice-import', description: 'Import invoice from token file' }, + { name: 'invoice-list', description: 'List invoices', flags: ['--state', '--limit'] }, + { name: 'invoice-status', description: 'Show invoice status' }, + { name: 'invoice-close', description: 'Close an invoice' }, + { name: 'invoice-cancel', description: 'Cancel an invoice' }, + { name: 'invoice-pay', description: 'Pay an invoice', flags: ['--target-index', '--amount'] }, + { name: 'invoice-return', description: 'Return payment to sender', flags: ['--recipient', '--asset'] }, + { name: 'invoice-receipts', description: 'Send payment receipts' }, + { name: 'invoice-notices', description: 'Send cancellation notices' }, + { name: 'invoice-auto-return', description: 'Show/set auto-return settings', flags: ['--enable', '--disable', '--invoice'] }, + { name: 'invoice-transfers', description: 'List related transfers' }, + { name: 'invoice-export', description: 'Export invoice to JSON file' }, + { name: 'invoice-parse-memo', description: 'Parse invoice memo string' }, + { name: 'swap-propose', description: 'Propose a token swap', flags: ['--to', '--offer', '--want', '--escrow', '--timeout', '--message'] }, + { name: 'swap-list', description: 'List swap deals', flags: ['--all', '--role', '--progress'] }, + { name: 'swap-accept', description: 'Accept a swap deal', flags: ['--deposit', '--no-wait'] }, + { name: 'swap-status', description: 'Show swap status', flags: ['--query-escrow'] }, + { name: 'swap-deposit', description: 'Deposit into a swap' }, + { name: 'swap-reject', description: 'Reject a swap proposal' }, + { name: 'swap-cancel', description: 'Cancel a swap' }, + { name: 'market-post', description: 'Post a market intent', flags: ['--type', '--category', '--price', '--currency', '--location', '--contact', '--expires'] }, + { name: 'market-search', description: 'Search market intents', flags: ['--type', '--category', '--min-price', '--max-price', '--limit'] }, + { name: 'market-my', description: 'List your intents' }, + { name: 'market-close', description: 'Close an intent' }, + { name: 'market-feed', description: 'Watch live listing feed', flags: ['--rest'] }, + { name: 'daemon', description: 'Manage event daemon', subcommands: [ + { name: 'start', description: 'Start daemon', flags: ['--config', '--detach', '--log', '--pid', '--event', '--action', '--verbose'] }, + { name: 'stop', description: 'Stop daemon' }, + { name: 'status', description: 'Check daemon status' }, + ]}, + { name: 'encrypt', description: 'Encrypt data with password' }, + { name: 'decrypt', description: 'Decrypt encrypted data' }, + { name: 'parse-wallet', description: 'Parse wallet file' }, + { name: 'wallet-info', description: 'Show wallet file info' }, + { name: 'generate-key', description: 'Generate random private key' }, + { name: 'validate-key', description: 'Validate a private key' }, + { name: 'hex-to-wif', description: 'Convert hex to WIF' }, + { name: 'derive-pubkey', description: 'Derive public key' }, + { name: 'derive-address', description: 'Derive L1 address' }, + { name: 'to-smallest', description: 'Convert to smallest unit' }, + { name: 'to-human', description: 'Convert to human-readable' }, + { name: 'format', description: 'Format amount' }, + { name: 'base58-encode', description: 'Encode hex to base58' }, + { name: 'base58-decode', description: 'Decode base58 to hex' }, + { name: 'completions', description: 'Generate shell completion script' }, + { name: 'help', description: 'Show help for a command' }, + ]; +} + +function generateBashCompletions(): string { + const cmds = getCompletionCommands(); + const topLevel = cmds.map(c => c.name).join(' '); + + const subcommandCases: string[] = []; + const flagCases: string[] = []; + + for (const cmd of cmds) { + if (cmd.subcommands) { + const subs = cmd.subcommands.map(s => s.name).join(' '); + subcommandCases.push(` ${cmd.name})\n if [[ $cword -eq 2 ]]; then\n COMPREPLY=($(compgen -W "${subs}" -- "$cur"))\n return\n fi\n ;;`); + for (const sub of cmd.subcommands) { + if (sub.flags?.length) { + flagCases.push(` "${cmd.name} ${sub.name}") flags="${sub.flags.join(' ')}" ;;`); + } + } + } + if (cmd.flags?.length) { + flagCases.push(` ${cmd.name}) flags="${cmd.flags.join(' ')}" ;;`); + } + } + + return `# Bash completion for sphere-cli +# Generated by: sphere-cli completions bash +# +# Installation: +# sphere-cli completions bash >> ~/.bashrc +# # or +# sphere-cli completions bash > /etc/bash_completion.d/sphere-cli +# # or with npm: +# eval "$(npm run --silent cli -- completions bash)" + +_sphere_cli() { + local cur prev words cword + _init_completion || return + + local commands="${topLevel}" + + if [[ $cword -eq 1 ]]; then + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + return + fi + + # Subcommands + case "\${words[1]}" in +${subcommandCases.join('\n')} + esac + + # Flag completion + if [[ "$cur" == -* ]]; then + local flags="" + local cmd_key="\${words[1]}" + if [[ $cword -ge 3 ]]; then + cmd_key="\${words[1]} \${words[2]}" + fi + case "$cmd_key" in +${flagCases.join('\n')} + esac + if [[ -n "$flags" ]]; then + COMPREPLY=($(compgen -W "$flags" -- "$cur")) + fi + fi +} + +complete -F _sphere_cli sphere-cli +# Also complete for npm run cli -- usage: +complete -F _sphere_cli npx +`; +} + +function generateZshCompletions(): string { + const cmds = getCompletionCommands(); + + const commandList = cmds.map(c => ` '${c.name}:${c.description.replace(/'/g, "'\\''")}'`).join('\n'); + + const subcases: string[] = []; + for (const cmd of cmds) { + if (cmd.subcommands) { + const subList = cmd.subcommands.map(s => `'${s.name}:${s.description.replace(/'/g, "'\\''")}'`).join(' '); + subcases.push(` ${cmd.name})\n _describe 'subcommand' ${subList}\n ;;`); + } else { + const flagArgs = (cmd.flags || []).map(f => `'${f}[${cmd.description.replace(/'/g, "'\\''").slice(0, 30)}]'`).join(' '); + if (flagArgs) { + subcases.push(` ${cmd.name})\n _arguments ${flagArgs}\n ;;`); + } + } + } + + return `#compdef sphere-cli +# Zsh completion for sphere-cli +# Generated by: sphere-cli completions zsh +# +# Installation: +# mkdir -p ~/.zsh/completions +# sphere-cli completions zsh > ~/.zsh/completions/_sphere-cli +# # Add to .zshrc: fpath=(~/.zsh/completions $fpath) && autoload -Uz compinit && compinit + +_sphere_cli() { + local -a commands + commands=( +${commandList} + ) + + _arguments -C \\ + '1:command:->command' \\ + '*::arg:->args' + + case "$state" in + command) + _describe 'command' commands + ;; + args) + case "\${words[1]}" in +${subcases.join('\n')} + esac + ;; + esac +} + +_sphere_cli "$@" +`; +} + +function generateFishCompletions(): string { + const cmds = getCompletionCommands(); + const lines: string[] = [ + '# Fish completion for sphere-cli', + '# Generated by: sphere-cli completions fish', + '#', + '# Installation:', + '# sphere-cli completions fish > ~/.config/fish/completions/sphere-cli.fish', + '', + ]; + + for (const cmd of cmds) { + if (cmd.subcommands) { + lines.push(`complete -c sphere-cli -n '__fish_use_subcommand' -a '${cmd.name}' -d '${cmd.description}'`); + for (const sub of cmd.subcommands) { + lines.push(`complete -c sphere-cli -n '__fish_seen_subcommand_from ${cmd.name}' -a '${sub.name}' -d '${sub.description}'`); + for (const flag of sub.flags || []) { + lines.push(`complete -c sphere-cli -n '__fish_seen_subcommand_from ${cmd.name}' -l '${flag.replace(/^--/, '')}' -d '${sub.description}'`); + } + } + } else { + lines.push(`complete -c sphere-cli -n '__fish_use_subcommand' -a '${cmd.name}' -d '${cmd.description}'`); + for (const flag of cmd.flags || []) { + lines.push(`complete -c sphere-cli -n '__fish_seen_subcommand_from ${cmd.name}' -l '${flag.replace(/^--/, '')}' -d '${flag}'`); + } + } + } + + return lines.join('\n') + '\n'; +} + +// Auto-run removed: legacyMain() is called from src/index.ts dispatcher. diff --git a/src/transport/dm-transport.test.ts b/src/transport/dm-transport.test.ts new file mode 100644 index 0000000..ccbdbb2 --- /dev/null +++ b/src/transport/dm-transport.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createDmTransport } from './dm-transport.js'; +import { createHmcpRequest, HMCP_VERSION } from './hmcp-types.js'; +import { TimeoutError, TransportError } from './errors.js'; +import type { DirectMessage } from '@unicitylabs/sphere-sdk'; +import type { SphereComms } from './dm-transport.js'; +import type { HmcpResponse } from './hmcp-types.js'; + +// Flush the microtask queue without touching the fake timer macrotask queue. +const flushPromises = () => new Promise((resolve) => queueMicrotask(resolve)); + +// ============================================================================= +// Helpers +// ============================================================================= + +const MANAGER_PUBKEY = 'a'.repeat(64); // 64-char x-only hex + +function makeResponse(inReplyTo: string, type: HmcpResponse['type'], payload = {}): string { + return JSON.stringify({ hmcp_version: HMCP_VERSION, in_reply_to: inReplyTo, type, payload }); +} + +function makeDM(content: string, senderPubkey = MANAGER_PUBKEY): DirectMessage { + return { + id: crypto.randomUUID(), + senderPubkey, + recipientPubkey: 'b'.repeat(64), + content, + timestamp: Date.now(), + isRead: false, + }; +} + +function buildMockComms() { + const handlers: Array<(msg: DirectMessage) => void> = []; + + const comms: SphereComms = { + sendDM: vi.fn().mockResolvedValue({ recipientPubkey: MANAGER_PUBKEY }), + onDirectMessage: vi.fn((handler) => { + handlers.push(handler); + return () => { const idx = handlers.indexOf(handler); if (idx !== -1) handlers.splice(idx, 1); }; + }), + }; + + const deliver = (msg: DirectMessage) => handlers.forEach((h) => h(msg)); + + return { comms, deliver }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('DmTransport', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + // -------------------------------------------------------------------------- + // sendRequest — happy path + // -------------------------------------------------------------------------- + + it('sends request JSON and returns correlated response', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + + // Simulate network: after send resolves, manager replies + await flushPromises(); + deliver(makeDM(makeResponse(request.msg_id, 'hm.list_result', { instances: [] }))); + + const response = await responsePromise; + expect(response.type).toBe('hm.list_result'); + expect(response.in_reply_to).toBe(request.msg_id); + + const sentBody = JSON.parse((comms.sendDM as ReturnType).mock.calls[0][1] as string); + expect(sentBody.msg_id).toBe(request.msg_id); + expect(sentBody.type).toBe('hm.list'); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // sendRequest — timeout + // -------------------------------------------------------------------------- + + it('throws TimeoutError when no response arrives', async () => { + const { comms } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 1_000 }); + + const request = createHmcpRequest('hm.help', {}); + const responsePromise = transport.sendRequest(request); + + await flushPromises(); + vi.advanceTimersByTime(1_001); + + await expect(responsePromise).rejects.toThrow(TimeoutError); + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // sendRequest — send failure + // -------------------------------------------------------------------------- + + it('throws TransportError when sendDM rejects', async () => { + const { comms } = buildMockComms(); + (comms.sendDM as ReturnType).mockRejectedValueOnce(new Error('relay offline')); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + const request = createHmcpRequest('hm.list', {}); + await expect(transport.sendRequest(request)).rejects.toThrow(TransportError); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Auth: ignore DMs from unknown senders + // -------------------------------------------------------------------------- + + it('ignores DMs from senders other than the resolved manager', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 200 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + // Deliver from wrong sender — should be ignored + deliver(makeDM(makeResponse(request.msg_id, 'hm.list_result', { instances: [] }), 'f'.repeat(64))); + + vi.advanceTimersByTime(201); + await expect(responsePromise).rejects.toThrow(TimeoutError); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Correlation: only routes to matching msg_id + // -------------------------------------------------------------------------- + + it('routes responses to correct correlator when multiple requests in-flight', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + const req1 = createHmcpRequest('hm.list', {}); + const req2 = createHmcpRequest('hm.help', {}); + + const [p1, p2] = [transport.sendRequest(req1), transport.sendRequest(req2)]; + await flushPromises(); + + deliver(makeDM(makeResponse(req2.msg_id, 'hm.help_result', { commands: [], version: '0.1' }))); + deliver(makeDM(makeResponse(req1.msg_id, 'hm.list_result', { instances: [] }))); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1.type).toBe('hm.list_result'); + expect(r2.type).toBe('hm.help_result'); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // sendRequestStream — multi-response (spawn flow) + // -------------------------------------------------------------------------- + + it('streams multiple responses until onResponse returns true', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', streamTimeoutMs: 10_000 }); + + const request = createHmcpRequest('hm.spawn', { template_id: 'tpl-1', instance_name: 'bot' }); + const received: string[] = []; + + const streamPromise = transport.sendRequestStream(request, (response) => { + received.push(response.type); + return response.type === 'hm.spawn_ready' || response.type === 'hm.spawn_failed'; + }); + + await flushPromises(); + deliver(makeDM(makeResponse(request.msg_id, 'hm.spawn_ack', { accepted: true, instance_id: 'i1', instance_name: 'bot', state: 'BOOTING' }))); + await flushPromises(); + deliver(makeDM(makeResponse(request.msg_id, 'hm.spawn_ready', { instance_id: 'i1', instance_name: 'bot', state: 'RUNNING', tenant_pubkey: 'c'.repeat(64), tenant_direct_address: 'DIRECT://cccc', tenant_nametag: null }))); + + await streamPromise; + expect(received).toEqual(['hm.spawn_ack', 'hm.spawn_ready']); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // sendRequestStream — timeout + // -------------------------------------------------------------------------- + + it('throws TimeoutError when stream stalls before done', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', streamTimeoutMs: 500 }); + + const request = createHmcpRequest('hm.spawn', { template_id: 'tpl-1', instance_name: 'bot' }); + const received: string[] = []; + + const streamPromise = transport.sendRequestStream(request, (response) => { + received.push(response.type); + return response.type === 'hm.spawn_ready'; + }); + + await flushPromises(); + // Only ack arrives — ready never comes + deliver(makeDM(makeResponse(request.msg_id, 'hm.spawn_ack', { accepted: true, instance_id: 'i1', instance_name: 'bot', state: 'BOOTING' }))); + await flushPromises(); + + vi.advanceTimersByTime(501); + await expect(streamPromise).rejects.toThrow(TimeoutError); + expect(received).toEqual(['hm.spawn_ack']); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // dispose rejects in-flight requests + // -------------------------------------------------------------------------- + + it('rejects in-flight requests when disposed', async () => { + const { comms } = buildMockComms(); + // sendDM never resolves — simulates a stuck network + (comms.sendDM as ReturnType).mockReturnValue(new Promise(() => {})); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 60_000 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + await transport.dispose(); + await expect(responsePromise).rejects.toThrow(TransportError); + }); + + // -------------------------------------------------------------------------- + // Ignores malformed / oversized messages + // -------------------------------------------------------------------------- + + it('ignores malformed JSON from manager', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 200 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + deliver(makeDM('not json at all')); + deliver(makeDM(JSON.stringify({ hmcp_version: HMCP_VERSION, in_reply_to: request.msg_id, type: 'UNKNOWN_TYPE', payload: {} }))); + + vi.advanceTimersByTime(201); + await expect(responsePromise).rejects.toThrow(TimeoutError); + + await transport.dispose(); + }); + + it('ignores oversized messages', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 200 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + // 64 KiB + 1 byte + deliver(makeDM('x'.repeat(64 * 1024 + 1))); + + vi.advanceTimersByTime(201); + await expect(responsePromise).rejects.toThrow(TimeoutError); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Dangerous keys rejected (prototype pollution) + // -------------------------------------------------------------------------- + + it('ignores messages containing dangerous keys', async () => { + const { comms, deliver } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 200 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + // Use a raw JSON string: `{ __proto__: ... }` in JS sets the prototype, + // not an own key, so JSON.stringify would silently drop it. + const poisoned = `{"hmcp_version":"${HMCP_VERSION}","in_reply_to":"${request.msg_id}","type":"hm.list_result","payload":{"__proto__":{"isAdmin":true}}}`; + deliver(makeDM(poisoned)); + + vi.advanceTimersByTime(201); + await expect(responsePromise).rejects.toThrow(TimeoutError); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Compressed pubkey normalisation + // -------------------------------------------------------------------------- + + it('matches manager pubkey when transport returns compressed 66-char form', async () => { + const { comms, deliver } = buildMockComms(); + // sendDM returns a compressed (02-prefixed) pubkey + const compressed = '02' + MANAGER_PUBKEY; + (comms.sendDM as ReturnType).mockResolvedValue({ recipientPubkey: compressed }); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + const request = createHmcpRequest('hm.list', {}); + const responsePromise = transport.sendRequest(request); + await flushPromises(); + + // Reply comes with x-only pubkey — should still be recognised + deliver(makeDM(makeResponse(request.msg_id, 'hm.list_result', { instances: [] }), MANAGER_PUBKEY)); + + const response = await responsePromise; + expect(response.type).toBe('hm.list_result'); + + await transport.dispose(); + }); + + // -------------------------------------------------------------------------- + // Already-disposed transport + // -------------------------------------------------------------------------- + + it('rejects immediately when transport is already disposed', async () => { + const { comms } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager' }); + await transport.dispose(); + + const request = createHmcpRequest('hm.list', {}); + await expect(transport.sendRequest(request)).rejects.toThrow(TransportError); + await expect(transport.sendRequestStream(request, () => true)).rejects.toThrow(TransportError); + }); + + // -------------------------------------------------------------------------- + // Send-side MAX_MESSAGE_SIZE cap + // -------------------------------------------------------------------------- + + it('rejects send with TransportError when serialized request exceeds MAX_MESSAGE_SIZE', async () => { + const { comms } = buildMockComms(); + const transport = createDmTransport(comms, { managerAddress: '@manager', timeoutMs: 5_000 }); + + // Construct a request whose serialized form exceeds 64 KiB. + // MAX_MESSAGE_SIZE = 65_536 bytes; envelope overhead is ~100 bytes, so a + // 70k-char string puts us solidly over. + const huge = 'x'.repeat(70_000); + const request = createHmcpRequest('hm.command', { command: 'big', payload: huge }); + + await expect(transport.sendRequest(request)).rejects.toThrow(/exceeds MAX_MESSAGE_SIZE/); + expect((comms.sendDM as ReturnType).mock.calls.length).toBe(0); + + await transport.dispose(); + }); +}); diff --git a/src/transport/dm-transport.ts b/src/transport/dm-transport.ts new file mode 100644 index 0000000..79bc99f --- /dev/null +++ b/src/transport/dm-transport.ts @@ -0,0 +1,318 @@ +/** + * DmTransport — send HMCP-0 requests over Sphere DMs and await correlated responses. + * + * Usage: + * const transport = createDmTransport(sphere.communications, { managerAddress: '@mymanager' }); + * const response = await transport.sendRequest(createHmcpRequest('hm.list', {})); + * await transport.dispose(); + * + * For commands that emit multiple responses (e.g. hm.spawn → ack + ready/failed): + * await transport.sendRequestStream(request, (response) => { + * process(response); + * return isTerminal(response.type); // return true to stop + * }); + */ + +import type { DirectMessage } from '@unicitylabs/sphere-sdk'; +import type { HmcpRequest, HmcpResponse } from './hmcp-types.js'; +import { parseHmcpResponse, byteLength, MAX_MESSAGE_SIZE } from './hmcp-types.js'; +import { TimeoutError, TransportError } from './errors.js'; + +export type { HmcpRequest, HmcpResponse } from './hmcp-types.js'; +export { createHmcpRequest, HMCP_RESPONSE_TYPES } from './hmcp-types.js'; +export { TimeoutError, AuthError, TransportError } from './errors.js'; + +// ============================================================================= +// Config / Interface +// ============================================================================= + +export interface DmTransportConfig { + /** + * Address of the host manager: @nametag, DIRECT://hex, or raw 64-char hex pubkey. + * Sphere resolves nametags and DIRECT:// internally via sendDM. + */ + managerAddress: string; + + /** + * Default timeout for single-response requests (ms). Default: 30 000. + */ + timeoutMs?: number; + + /** + * Default timeout for streaming requests, e.g. spawn that waits for container + * to become RUNNING. Default: 120 000. + */ + streamTimeoutMs?: number; +} + +/** Narrow slice of Sphere.communications needed by DmTransport — injectable for testing. */ +export interface SphereComms { + sendDM(recipient: string, content: string): Promise<{ recipientPubkey: string }>; + onDirectMessage(handler: (message: DirectMessage) => void): () => void; +} + +export interface DmTransport { + /** + * Send a request and return the first correlated response. + * Throws TimeoutError if no response arrives within timeoutMs. + * Throws TransportError if the send itself fails or the transport is disposed. + */ + sendRequest(request: HmcpRequest, timeoutMs?: number): Promise; + + /** + * Send a request and receive all correlated responses until onResponse returns + * true (done) or the stream timeout elapses. + * + * onResponse receives each HmcpResponse in order. Return true to signal done + * (cleans up the correlator). Return false to keep listening. + * + * Throws TimeoutError or TransportError. + */ + sendRequestStream( + request: HmcpRequest, + onResponse: (response: HmcpResponse) => boolean, + timeoutMs?: number, + ): Promise; + + /** Release the DM subscription. Any in-flight requests reject with TransportError. */ + dispose(): Promise; +} + +// ============================================================================= +// Pubkey normalisation +// ============================================================================= + +/** Strip 02/03 prefix to get x-only 64-char hex — matches CommunicationsModule._normalizeKey. */ +function normalizeKey(key: string): string { + if (key.length === 66 && (key.startsWith('02') || key.startsWith('03'))) { + return key.slice(2); + } + return key; +} + +// ============================================================================= +// Implementation +// ============================================================================= + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_STREAM_TIMEOUT_MS = 120_000; + +interface Correlator { + handler: (response: HmcpResponse) => void; + cancel: (err: Error) => void; +} + +class DmTransportImpl implements DmTransport { + private readonly timeoutMs: number; + private readonly streamTimeoutMs: number; + + /** + * Resolved x-only pubkey of the manager. + * Set after the first successful sendDM call and cached for sender auth. + * Concurrent first-sends write the same pubkey — safe. + */ + private resolvedPubkey: string | null = null; + + /** + * Messages that arrived before resolvedPubkey was set are buffered here and + * replayed once the pubkey is known. Capped at 32 to bound memory if an + * attacker floods DMs before the first send completes. + */ + private readonly earlyMessages: DirectMessage[] = []; + private static readonly EARLY_MESSAGE_CAP = 32; + + private readonly correlators = new Map(); + private readonly unsubscribeDMs: () => void; + private disposed = false; + + constructor( + private readonly comms: SphereComms, + private readonly managerAddress: string, + config: { timeoutMs: number; streamTimeoutMs: number }, + ) { + this.timeoutMs = config.timeoutMs; + this.streamTimeoutMs = config.streamTimeoutMs; + + // Subscribe once — route all incoming DMs through the correlator map. + this.unsubscribeDMs = comms.onDirectMessage((msg) => this.handleIncoming(msg)); + } + + // --------------------------------------------------------------------------- + + private handleIncoming(msg: DirectMessage): void { + // Race guard: a DM can arrive between `this.disposed = true` and + // `this.unsubscribeDMs()` returning in dispose(). Short-circuit so a + // late-arriving message cannot touch a disposed transport. + if (this.disposed) return; + + // Early size cap — a malformed relay delivering a 10 MB DM never enters + // the early-message buffer. parseHmcpResponse also enforces this on the + // post-pubkey-resolution path, but catching it here saves memory when + // the buffer is in use. + if (byteLength(msg.content) > MAX_MESSAGE_SIZE) return; + + if (!this.resolvedPubkey) { + // Buffer early messages so a fast manager reply isn't lost while sendDM + // is still in-flight. Capped to avoid unbounded memory on DM floods. + if (this.earlyMessages.length < DmTransportImpl.EARLY_MESSAGE_CAP) { + this.earlyMessages.push(msg); + } else if (process.env['DEBUG']) { + // Cap hit: one DEBUG line per overflow so a legitimate chatty manager + // during the handshake window is diagnosable. Silent drop otherwise. + process.stderr.write( + `dm-transport: early-message buffer full (cap=${DmTransportImpl.EARLY_MESSAGE_CAP}), dropping DM\n`, + ); + } + return; + } + if (normalizeKey(msg.senderPubkey) !== this.resolvedPubkey) return; + + const response = parseHmcpResponse(msg.content); + if (!response) return; + + const correlator = this.correlators.get(response.in_reply_to); + correlator?.handler(response); + } + + // --------------------------------------------------------------------------- + + async sendRequest(request: HmcpRequest, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + if (this.disposed) { + reject(new TransportError('Transport has been disposed')); + return; + } + + const timeout = timeoutMs ?? this.timeoutMs; + + const cleanup = () => { + clearTimeout(timer); + this.correlators.delete(request.msg_id); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new TimeoutError(`No response for ${request.type} within ${timeout} ms`)); + }, timeout); + + this.correlators.set(request.msg_id, { + handler: (response) => { cleanup(); resolve(response); }, + cancel: (err) => { cleanup(); reject(err); }, + }); + + this.send(request).catch((err: unknown) => { + cleanup(); + reject(new TransportError(`Failed to send ${request.type}: ${String((err as Error).message ?? err)}`)); + }); + }); + } + + // --------------------------------------------------------------------------- + + async sendRequestStream( + request: HmcpRequest, + onResponse: (response: HmcpResponse) => boolean, + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + if (this.disposed) { + reject(new TransportError('Transport has been disposed')); + return; + } + + const timeout = timeoutMs ?? this.streamTimeoutMs; + + const cleanup = () => { + clearTimeout(timer); + this.correlators.delete(request.msg_id); + }; + + const timer = setTimeout(() => { + cleanup(); + reject(new TimeoutError(`Stream for ${request.type} timed out after ${timeout} ms`)); + }, timeout); + + this.correlators.set(request.msg_id, { + handler: (response) => { + let done: boolean; + try { + done = onResponse(response); + } catch (err) { + cleanup(); + reject(err); + return; + } + if (done) { cleanup(); resolve(); } + // else: keep correlator — more responses expected + }, + cancel: (err) => { cleanup(); reject(err); }, + }); + + this.send(request).catch((err: unknown) => { + cleanup(); + reject(new TransportError(`Failed to send ${request.type}: ${String((err as Error).message ?? err)}`)); + }); + }); + } + + // --------------------------------------------------------------------------- + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.unsubscribeDMs(); + + const err = new TransportError('Transport disposed'); + // Snapshot before iterating — cancel() calls cleanup() which deletes from + // the map. Clearing the snapshot first avoids modifying during iteration. + const pending = Array.from(this.correlators.values()); + this.correlators.clear(); + for (const { cancel } of pending) { + cancel(err); + } + } + + // --------------------------------------------------------------------------- + + private async send(request: HmcpRequest): Promise { + // Send-side size cap — symmetric with the receive-side MAX_MESSAGE_SIZE + // check in parseHmcpResponse. Prevents a local caller (e.g. `sphere host + // cmd --params ''`) from handing a 10 MB payload to the relay + // and getting an opaque TransportError in response. Fail fast with a + // clear size-limit message before hitting the transport. + const serialized = JSON.stringify(request); + if (byteLength(serialized) > MAX_MESSAGE_SIZE) { + throw new TransportError( + `Request too large: ${byteLength(serialized)} bytes exceeds MAX_MESSAGE_SIZE (${MAX_MESSAGE_SIZE})`, + ); + } + const sent = await this.comms.sendDM(this.managerAddress, serialized); + // Cache the resolved pubkey on first send. Subsequent sends for the same + // manager address produce the same pubkey, so concurrent writes are safe. + if (!this.resolvedPubkey) { + this.resolvedPubkey = normalizeKey(sent.recipientPubkey); + // Replay any DMs that arrived before we knew the manager's pubkey. + const pending = this.earlyMessages.splice(0); + for (const msg of pending) { + this.handleIncoming(msg); + } + } + } +} + +// ============================================================================= +// Factory +// ============================================================================= + +/** + * Create a DmTransport connected to a specific manager address. + * + * Subscribes to incoming DMs immediately. The manager's pubkey is resolved + * lazily on the first send — no network round-trip at construction. + */ +export function createDmTransport(comms: SphereComms, config: DmTransportConfig): DmTransport { + return new DmTransportImpl(comms, config.managerAddress, { + timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + streamTimeoutMs: config.streamTimeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS, + }); +} diff --git a/src/transport/errors.ts b/src/transport/errors.ts new file mode 100644 index 0000000..ba8f69e --- /dev/null +++ b/src/transport/errors.ts @@ -0,0 +1,20 @@ +export class TimeoutError extends Error { + readonly name = 'TimeoutError'; + constructor(message: string) { + super(message); + } +} + +export class AuthError extends Error { + readonly name = 'AuthError'; + constructor(message: string) { + super(message); + } +} + +export class TransportError extends Error { + readonly name = 'TransportError'; + constructor(message: string) { + super(message); + } +} diff --git a/src/transport/hmcp-types.ts b/src/transport/hmcp-types.ts new file mode 100644 index 0000000..d874e9f --- /dev/null +++ b/src/transport/hmcp-types.ts @@ -0,0 +1,285 @@ +/** + * HMCP-0 (Host Manager Control Protocol) types, constructors, and validators. + * Mirrors the protocol definition in agentic-hosting — kept in sync by hand. + */ + +export const HMCP_VERSION = '0.1'; + +export const HMCP_REQUEST_TYPES = [ + 'hm.spawn', + 'hm.list', + 'hm.stop', + 'hm.start', + 'hm.inspect', + 'hm.help', + 'hm.command', + 'hm.remove', + 'hm.pause', + 'hm.resume', +] as const; +export type HmcpRequestType = (typeof HMCP_REQUEST_TYPES)[number]; + +export const HMCP_RESPONSE_TYPES = [ + 'hm.spawn_ack', + 'hm.spawn_ready', + 'hm.spawn_failed', + 'hm.list_result', + 'hm.stop_result', + 'hm.start_ack', + 'hm.start_ready', + 'hm.start_failed', + 'hm.inspect_result', + 'hm.help_result', + 'hm.error', + 'hm.command_result', + 'hm.remove_result', + 'hm.pause_result', + 'hm.resume_result', + 'hm.resume_ready', + 'hm.resume_failed', +] as const; +export type HmcpResponseType = (typeof HMCP_RESPONSE_TYPES)[number]; + +// ---- Core message types ---- + +export interface HmcpRequest { + readonly hmcp_version: string; + readonly msg_id: string; + readonly ts_ms: number; + readonly type: HmcpRequestType; + readonly payload: Record; +} + +export interface HmcpResponse { + readonly hmcp_version: string; + readonly in_reply_to: string; + readonly type: HmcpResponseType; + readonly payload: Record; +} + +// ---- Spawn ---- + +export interface HmSpawnPayload { + readonly template_id: string; + readonly instance_name: string; + readonly nametag?: string | null; + readonly env?: Readonly>; +} + +export interface HmSpawnAckPayload { + readonly accepted: boolean; + readonly instance_id: string; + readonly instance_name: string; + readonly state: string; +} + +export interface HmSpawnReadyPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'RUNNING'; + readonly tenant_pubkey: string; + readonly tenant_direct_address: string; + readonly tenant_nametag: string | null; +} + +export interface HmSpawnFailedPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'FAILED'; + readonly reason: string; +} + +// ---- List ---- + +export interface HmInstanceSummary { + readonly instance_id: string; + readonly instance_name: string; + readonly template_id: string; + readonly state: string; + readonly tenant_pubkey: string | null; + readonly created_at: string; +} + +export interface HmListResultPayload { + readonly instances: readonly HmInstanceSummary[]; +} + +// ---- Stop / Start ---- + +export interface HmStopResultPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'STOPPED'; +} + +export interface HmStartAckPayload { + readonly accepted: boolean; + readonly instance_id: string; + readonly instance_name: string; + readonly state: string; +} + +export interface HmStartReadyPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'RUNNING'; + readonly tenant_pubkey: string; + readonly tenant_direct_address: string; + readonly tenant_nametag: string | null; +} + +export interface HmStartFailedPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly state: 'FAILED'; + readonly reason: string; +} + +// ---- Inspect ---- + +export interface HmInspectResultPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly template_id: string; + readonly state: string; + readonly created_at: string; + readonly tenant_pubkey: string | null; + readonly tenant_direct_address: string | null; + readonly tenant_nametag: string | null; + readonly last_heartbeat_at: string | null; + readonly docker_container_id: string | null; +} + +// ---- Help ---- + +export interface HmHelpResultPayload { + readonly commands: readonly string[]; + readonly version: string; +} + +// ---- Command ---- + +export interface HmCommandPayload { + readonly instance_id?: string; + readonly instance_name?: string; + readonly command: string; + readonly params?: Record; + readonly timeout_ms?: number; +} + +export interface HmCommandResultPayload { + readonly instance_id: string; + readonly instance_name: string; + readonly command: string; + readonly result: Record; +} + +// ---- Error ---- + +export interface HmErrorPayload { + readonly error_code: string; + readonly message: string; +} + +// ---- Constructors ---- + +export function createHmcpRequest(type: HmcpRequestType, payload: Record): HmcpRequest { + return { + hmcp_version: HMCP_VERSION, + msg_id: crypto.randomUUID(), + ts_ms: Date.now(), + type, + payload, + }; +} + +// ---- Validators ---- + +const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Use a JSON.parse reviver to detect dangerous keys at parse time, regardless + * of nesting depth. This avoids the depth-20 cliff of a recursive walk and + * runs in O(n) with a single parse pass. If a dangerous key is found the + * entire message is rejected (not silently cleaned) — we set `value` to null + * so downstream mishandling cannot accidentally use a half-stripped object. + */ +function safeParse(data: string): { value: unknown; hadDangerousKeys: boolean } { + let hadDangerousKeys = false; + const value = JSON.parse(data, (key, val) => { + if (DANGEROUS_KEYS.has(key)) { hadDangerousKeys = true; return undefined; } + return val; + }); + // Reject-not-sanitize: if any dangerous key was seen, null the output so a + // caller that forgets to check `hadDangerousKeys` cannot accidentally use + // a partially-scrubbed object. parseHmcpResponse also short-circuits on + // the flag, but defense-in-depth costs nothing. + return hadDangerousKeys ? { value: null, hadDangerousKeys } : { value, hadDangerousKeys }; +} + +/** + * Structural check used by host-commands.ts to validate --params arguments. + * + * Iterative, bounded walk — originally recursive which was vulnerable to a + * stack-overflow self-DoS on pathological 10k-deep JSON from `--params`. The + * CLI parses --params with regular JSON.parse (not our safeParse reviver) so + * this guard IS the primary defense for the local command-line path. + */ +const MAX_PARAMS_DEPTH = 64; + +export function hasDangerousKeys(value: unknown): boolean { + // Explicit stack instead of recursion: `{ value, depth }` frames. The depth + // cap guards against deeply-nested attacker input; 64 is comfortably deeper + // than any realistic hand-written --params payload while bounded enough to + // prevent a stack-allocation DoS via the interpreter itself. + const stack: Array<{ v: unknown; d: number }> = [{ v: value, d: 0 }]; + while (stack.length > 0) { + const { v, d } = stack.pop()!; + if (d > MAX_PARAMS_DEPTH) return true; // too deep to inspect — conservative reject + if (typeof v !== 'object' || v === null) continue; + if (Array.isArray(v)) { + for (const item of v) stack.push({ v: item, d: d + 1 }); + continue; + } + const obj = v as Record; + for (const key of Object.keys(obj)) { + if (DANGEROUS_KEYS.has(key)) return true; + stack.push({ v: obj[key], d: d + 1 }); + } + } + return false; +} + +// 64 KiB measured in UTF-8 bytes, not JS string code units (which are UTF-16). +export const MAX_MESSAGE_SIZE = 64 * 1024; + +export function byteLength(s: string): number { + // Buffer.byteLength is available in Node; fall back to approximate for other runtimes. + if (typeof Buffer !== 'undefined') return Buffer.byteLength(s, 'utf8'); + return new TextEncoder().encode(s).length; +} + +export function isValidHmcpResponse(msg: unknown): msg is HmcpResponse { + if (typeof msg !== 'object' || msg === null) return false; + const obj = msg as Record; + return ( + obj['hmcp_version'] === HMCP_VERSION && + typeof obj['in_reply_to'] === 'string' && obj['in_reply_to'] !== '' && + typeof obj['type'] === 'string' && + (HMCP_RESPONSE_TYPES as readonly string[]).includes(obj['type'] as string) && + typeof obj['payload'] === 'object' && + obj['payload'] !== null + ); +} + +export function parseHmcpResponse(data: string): HmcpResponse | null { + if (byteLength(data) > MAX_MESSAGE_SIZE) return null; + let result: { value: unknown; hadDangerousKeys: boolean }; + try { + result = safeParse(data); + } catch { + return null; + } + if (result.hadDangerousKeys) return null; + return isValidHmcpResponse(result.value) ? result.value : null; +} diff --git a/tsconfig.json b/tsconfig.json index 8a1e645..f866704 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "dist", "rootDir": "src", "strict": true, - "noUncheckedIndexedAccess": true, + "noUncheckedIndexedAccess": false, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true,