diff --git a/README.md b/README.md index 2468588..b4887d4 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Commands below assume you linked the binary and can run `igloo`. Swap in `igloo- | `igloo about` | Summarize the FROSTR architecture and sibling projects. | | `igloo status --share vault-share-1` | Decrypt a saved share and ping peers via the default relays. | | `igloo signer --share vault-share-1 --password-file ./pass.txt` | Bring a decrypted share online as a signer until you quit. | +| `igloo relays` | Show effective and configured default relays. | +| `igloo relays set wss://a wss://b` | Set the default relays for status/signer. | | `igloo policy --share vault-share-1` | Configure default send/receive rules and peer overrides for a share (alias of `igloo share policy`). | | `igloo keyset create --name team --threshold 2 --total 3` | Interactive or flag-driven flow to generate, encrypt, and save shares. | | `igloo keys convert --from nsec --value nsec1example...` | Convert between npub/nsec/hex formats using `@frostr/igloo-core`. | @@ -106,6 +108,7 @@ Keyset commands and the signer flow support non-interactive execution: - `--output ./directory` — change where encrypted share JSON is written. - `--share id` — target a saved share by id/name when loading, diagnosing, or running the signer. - `--relays wss://relay1,wss://relay2` — override the relay list for status checks and the signer. + Configure persistent defaults via `igloo relays set …`. - `--verbose` — stream signer diagnostics (toggleable at runtime with the `l` key). - `--log-level level` — pick signer log verbosity (`debug`, `info`, `warn`, or `error`). diff --git a/package.json b/package.json index 2da817b..b74f860 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Command-line companion for the FROSTR ecosystem.", "main": "dist/cli.js", "scripts": { - "test": "npm run typecheck", + "test": "npm run typecheck && tsx --test tests/relays.test.ts", "build": "tsup", "dev": "tsx src/cli.tsx", "start": "node dist/cli.js", diff --git a/src/App.tsx b/src/App.tsx index 2d66bab..e72f69d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import {ShareLoad} from './components/share/ShareLoad.js'; import {ShareStatus} from './components/share/ShareStatus.js'; import {ShareSigner} from './components/share/ShareSigner.js'; import {ShareAdd} from './components/share/ShareAdd.js'; +import {Relays} from './components/relays/Relays.js'; type AppProps = { command: string; @@ -131,6 +132,8 @@ export function App({command, args, flags, version}: AppProps) { return ; case 'about': return ; + case 'relays': + return ; case 'status': return ; case 'signer': diff --git a/src/cli.tsx b/src/cli.tsx index 10da6b9..55fb7e2 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -106,7 +106,19 @@ if (shouldShowVersion) { } if (showHelp) { - showHelpScreen(packageJson.version); + const helpable = new Set(['share', 'keyset', 'keys', 'relays']); + if (helpable.has((command ?? '').toLowerCase())) { + render( + + ); + } else { + showHelpScreen(packageJson.version); + } } else { render( - igloo-cli v{version} - Usage: igloo-cli [command] [options] + + IGLOO CLI + FROSTR remote signing toolkit + version {version} + + + + Core commands + - igloo-cli setup -- bootstrap a fresh keyset + - igloo-cli keyset -- create keysets and shares + - igloo-cli share -- manage saved shares + - igloo-cli signer -- bring a share online as a signer + - igloo-cli status -- check relay and peer reachability + - igloo-cli keys -- translate between npub/nsec/hex + - igloo-cli relays -- manage default relay endpoints + - igloo-cli about -- outline the FROSTR stack + + - Commands - - intro (default) Show the animated welcome. - - setup Step through signer bootstrapping. - - about Outline the FROSTR stack. - - signer Decrypt a share and run it as a signer. - - status Check peer reachability with a saved share. - - policy Configure send/receive permissions per peer. - - keyset Manage keyset creation, saving, loading, status. - - keys Convert between npub/nsec/hex formats. + See subcommands + - igloo-cli share → lists add | list | load | status | signer | policy + - igloo-cli keyset → lists create + - igloo-cli keys → lists convert (with flag variants) + - igloo-cli relays → lists set | add | remove | reset + - Options - -h, --help Print this message. - -v, --version Print the version. - --threshold n Override default share threshold. - --total n Override total number of shares. - --name value Provide a keyset name during creation. - --nsec value Provide secret material during creation. - --password value Use a password non-interactively. - --password-file Read password from file. - --output path Save encrypted shares to a custom directory. - --share value Identify which saved share to load/status. - --relays list Override relay list (comma-separated). - --verbose Stream signer diagnostics to the console. - --log-level val Set signer log verbosity (debug|info|warn|error). - --from type Specify input type for keys convert (npub|nsec|hex-public|hex-private). - --value key Provide the key value for conversion. - --npub key Convert from an npub value. - --nsec key Convert from an nsec value. - --hex-public key Convert from a public hex key. - --hex-private key Convert from a private hex key. - --hex key Generic hex input (requires --kind public|private). - --kind type Pair with --hex to set the kind (public|private). + Common options + -h, --help Show this help + -v, --version Print version + --share value Target a saved share by id/name + --password value Supply password non-interactively + --password-file Read password from file + --relays list Override relay list (comma-separated) + --verbose Stream signer diagnostics + --log-level level Signer log level (debug|info|warn|error) ); diff --git a/src/components/Intro.tsx b/src/components/Intro.tsx index 3a31421..129e858 100644 --- a/src/components/Intro.tsx +++ b/src/components/Intro.tsx @@ -17,14 +17,14 @@ export function Intro({version, commandExamples}: IntroProps) { Core commands - - igloo-cli setup -- bootstrap a fresh keyset - - igloo-cli keyset create -- generate & encrypt shares headlessly - - igloo-cli share add -- import a share using its group - - igloo-cli share list -- review saved shares on this device - - igloo-cli share status -- check relay and peer reachability - - igloo-cli share policy -- tune defaults and peer overrides - - igloo-cli keys convert -- translate between npub/nsec/hex - - igloo-cli signer -- bring a share online as a signer + - igloo-cli setup -- bootstrap a fresh keyset + - igloo-cli keyset -- create keysets and shares + - igloo-cli share -- manage saved shares + - igloo-cli signer -- bring a share online as a signer + - igloo-cli status -- check relay and peer reachability + - igloo-cli keys -- translate between npub/nsec/hex + - igloo-cli relays -- manage default relay endpoints + - igloo-cli about -- outline the FROSTR stack diff --git a/src/components/keyset/KeysetSigner.tsx b/src/components/keyset/KeysetSigner.tsx index 7823033..9401ff5 100644 --- a/src/components/keyset/KeysetSigner.tsx +++ b/src/components/keyset/KeysetSigner.tsx @@ -10,7 +10,6 @@ import fs from 'node:fs/promises'; import { createAndConnectNode, cleanupBifrostNode, - DEFAULT_PING_RELAYS, decodeGroup, decodeShare, extractSelfPubkeyFromCredentials, @@ -25,6 +24,7 @@ import { decryptShareCredential, ShareMetadata } from '../../keyset/index.js'; +import {resolveRelaysWithFallbackSync, DEFAULT_SIGNER_RELAYS} from '../../keyset/relays.js'; export type KeysetSignerProps = { args: string[]; @@ -65,6 +65,7 @@ type LogEntry = { const LOG_LEVELS: LogLevel[] = ['debug', 'info', 'warn', 'error']; + function parseBooleanFlag(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') { return value; @@ -248,7 +249,7 @@ export function KeysetSigner({args, flags}: KeysetSignerProps) { const shareToken = typeof flags.share === 'string' ? flags.share : args[0]; const relayOverrides = parseRelayFlags(flags); - const relays = relayOverrides && relayOverrides.length > 0 ? relayOverrides : DEFAULT_PING_RELAYS; + const relays = resolveRelaysWithFallbackSync(relayOverrides, DEFAULT_SIGNER_RELAYS); const nodeRef = useRef(null); const peerManagerRef = useRef(null); diff --git a/src/components/keyset/KeysetStatus.tsx b/src/components/keyset/KeysetStatus.tsx index 5f95116..b8e3539 100644 --- a/src/components/keyset/KeysetStatus.tsx +++ b/src/components/keyset/KeysetStatus.tsx @@ -14,6 +14,7 @@ import { import {convert_pubkey} from '@frostr/bifrost/util'; import {readShareFiles, decryptShareCredential, ShareMetadata} from '../../keyset/index.js'; import {Prompt} from '../ui/Prompt.js'; +import {resolveRelaysWithFallbackSync} from '../../keyset/relays.js'; export type KeysetStatusProps = { flags: Record; @@ -272,7 +273,7 @@ export function KeysetStatus({flags, args}: KeysetStatusProps) { const shareToken = typeof flags.share === 'string' ? flags.share : args[0]; const relayOverrides = parseRelayFlags(flags); - const relays = relayOverrides && relayOverrides.length > 0 ? relayOverrides : DEFAULT_PING_RELAYS; + const relays = resolveRelaysWithFallbackSync(relayOverrides, DEFAULT_PING_RELAYS); useEffect(() => { void (async () => { diff --git a/src/components/keyset/useShareEchoListener.ts b/src/components/keyset/useShareEchoListener.ts index 8da2b83..83a4eb5 100644 --- a/src/components/keyset/useShareEchoListener.ts +++ b/src/components/keyset/useShareEchoListener.ts @@ -1,5 +1,12 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {awaitShareEcho, decodeGroup, createAndConnectNode, cleanupBifrostNode} from '@frostr/igloo-core'; +import { + awaitShareEcho, + decodeGroup, + createAndConnectNode, + cleanupBifrostNode, + DEFAULT_ECHO_RELAYS +} from '@frostr/igloo-core'; +import {resolveRelaysWithFallbackSync} from '../../keyset/relays.js'; export type EchoStatus = 'idle' | 'listening' | 'success'; @@ -76,11 +83,14 @@ export function useShareEchoListener( } try { const decoded = decodeGroup(groupCredential); - return extractRelays(decoded); + const fromGroup = extractRelays(decoded); + if (fromGroup && fromGroup.length > 0) return fromGroup; + // fall back to configured relays, or DEFAULT_ECHO_RELAYS + return resolveRelaysWithFallbackSync(undefined, DEFAULT_ECHO_RELAYS); } catch { // ignore decode failures; we'll fall back to default relays } - return undefined; + return resolveRelaysWithFallbackSync(undefined, DEFAULT_ECHO_RELAYS); }, [groupCredential]); const clearPending = useCallback(() => { diff --git a/src/components/relays/Relays.tsx b/src/components/relays/Relays.tsx new file mode 100644 index 0000000..ad33518 --- /dev/null +++ b/src/components/relays/Relays.tsx @@ -0,0 +1,197 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {Box, Text} from 'ink'; +import { + readConfiguredRelays, + writeConfiguredRelays, + removeConfiguredRelays, + readConfiguredRelaysSync, +} from '../../keyset/relays.js'; +import {DEFAULT_PING_RELAYS} from '@frostr/igloo-core'; +import {DEFAULT_SIGNER_RELAYS} from '../../keyset/relays.js'; + +export type RelaysProps = { + flags: Record; + args: string[]; +}; + +function parseRelayFlags(flags: Record): string[] | undefined { + const relayString = + typeof flags.relays === 'string' + ? flags.relays + : typeof flags.relay === 'string' + ? flags.relay + : undefined; + + if (!relayString) return undefined; + return relayString + .split(',') + .map(v => v.trim()) + .filter(Boolean); +} + +function parseRelayArgs(args: string[]): string[] | undefined { + if (!args || args.length === 0) return undefined; + // Support comma or space separated + if (args.length === 1 && args[0].includes(',')) { + return args[0].split(',').map(s => s.trim()).filter(Boolean); + } + return args.map(s => s.trim()).filter(Boolean); +} + +function uniqueLower(list: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const x of list) { + const k = x.toLowerCase(); + if (!seen.has(k)) { + seen.add(k); + out.push(x); + } + } + return out; +} + +export function Relays({flags, args}: RelaysProps) { + const [configured, setConfigured] = useState(undefined); + const [message, setMessage] = useState(null); + const mutatedRef = useRef(false); + const sub = args[0]?.toLowerCase(); + const rest = sub ? args.slice(1) : args; + + // Recompute effective defaults for both Status and Signer on each render. + const effectiveSigner = (configured && configured.length > 0) ? configured : DEFAULT_SIGNER_RELAYS; + const effectiveStatus = (configured && configured.length > 0) ? configured : DEFAULT_PING_RELAYS; + + useEffect(() => { + let cancelled = false; + void (async () => { + const latest = await readConfiguredRelays(); + if (cancelled || mutatedRef.current) return; + setConfigured(latest); + })(); + return () => { + cancelled = true; + }; + }, []); + + async function handleSet(list: string[]) { + const written = await writeConfiguredRelays(list); + mutatedRef.current = true; + setConfigured(written); + setMessage(`Saved ${written.length} default relay${written.length === 1 ? '' : 's'}.`); + } + + async function handleAdd(list: string[]) { + const disk = readConfiguredRelaysSync(); + const current = (disk && disk.length > 0) + ? disk + : (configured && configured.length > 0) + ? configured + : DEFAULT_PING_RELAYS; + const next = uniqueLower([...current, ...list]); + await handleSet(next); + } + + async function handleRemove(list: string[]) { + const disk = readConfiguredRelaysSync(); + const current = (disk && disk.length > 0) + ? disk + : (configured && configured.length > 0) + ? configured + : DEFAULT_PING_RELAYS; + const toRemove = new Set(list.map(v => v.toLowerCase())); + const next = current.filter(v => !toRemove.has(v.toLowerCase())); + await handleSet(next); + } + + async function handleReset() { + await removeConfiguredRelays(); + mutatedRef.current = true; + setConfigured(undefined); + setMessage('Reset relay defaults to built-in values.'); + } + + // Command execution + useEffect(() => { + void (async () => { + if (sub === 'set') { + const fromFlags = parseRelayFlags(flags); + const fromArgs = parseRelayArgs(rest); + const list = fromArgs ?? fromFlags; + if (!list || list.length === 0) { + setMessage('Provide relays via args or --relays.'); + return; + } + await handleSet(list); + return; + } + if (sub === 'add') { + const list = parseRelayArgs(rest) ?? parseRelayFlags(flags); + if (!list || list.length === 0) { + setMessage('Provide one or more relay URLs to add.'); + return; + } + await handleAdd(list); + return; + } + if (sub === 'remove' || sub === 'rm' || sub === 'del') { + const list = parseRelayArgs(rest) ?? parseRelayFlags(flags); + if (!list || list.length === 0) { + setMessage('Provide one or more relay URLs to remove.'); + return; + } + await handleRemove(list); + return; + } + if (sub === 'reset' || sub === 'clear') { + await handleReset(); + return; + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sub, JSON.stringify(rest), JSON.stringify(flags)]); + + const mode = sub ?? 'list'; + + return ( + + Relay Defaults + {message ? {message} : null} + + Effective defaults + Used when no per-run --relays and no configured defaults exist. + - Signer fallback + {effectiveSigner.length === 0 ? ( + (none) + ) : ( + effectiveSigner.map((r, i) => {i + 1}. {r}) + )} + - Status fallback + {effectiveStatus.length === 0 ? ( + (none) + ) : ( + effectiveStatus.map((r, i) => {i + 1}. {r}) + )} + + + Configured override + {configured && configured.length > 0 ? ( + configured.map((r, i) => {i + 1}. {r}) + ) : ( + (none — using built-in defaults) + )} + + + Usage + - igloo-cli relays List current defaults + - igloo-cli relays set wss://a wss://b Set defaults + - igloo-cli relays add wss://c Add relay(s) + - igloo-cli relays remove wss://a Remove relay(s) + - igloo-cli relays reset Revert to built-in defaults + Override per run: igloo-cli signer --relays wss://a,wss://b + + + ); +} + +export default Relays; diff --git a/src/keyset/paths.ts b/src/keyset/paths.ts index aac1908..a4b973d 100644 --- a/src/keyset/paths.ts +++ b/src/keyset/paths.ts @@ -2,6 +2,10 @@ import os from 'node:os'; import path from 'node:path'; export function getAppDataPath(): string { + const override = process.env.IGLOO_APPDATA; + if (override && override.length > 0) { + return override; + } const platform = os.platform(); if (platform === 'win32') { diff --git a/src/keyset/relays.ts b/src/keyset/relays.ts new file mode 100644 index 0000000..fa70e51 --- /dev/null +++ b/src/keyset/relays.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import {getAppDataPath} from './paths.js'; + +export type RelaysConfig = { + relays: string[]; + updatedAt?: string; +}; + +// Built-in signer fallback (when no overrides and no configured defaults) +export const DEFAULT_SIGNER_RELAYS = ['wss://relay.primal.net']; + +function getConfigDirectory(): string { + return path.join(getAppDataPath(), 'igloo'); +} + +export function getRelaysConfigPath(): string { + return path.join(getConfigDirectory(), 'relays.json'); +} + +function isWsUrl(value: string): boolean { + try { + const u = new URL(value); + return u.protocol === 'wss:' || u.protocol === 'ws:'; + } catch { + return false; + } +} + +export function normalizeRelays(input: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const raw of input) { + if (typeof raw !== 'string') continue; + const trimmed = raw.trim(); + if (!trimmed) continue; + if (!isWsUrl(trimmed)) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result; +} + +export async function readConfiguredRelays(): Promise { + const file = getRelaysConfigPath(); + try { + const raw = await fsp.readFile(file, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + const relays = Array.isArray(parsed.relays) ? parsed.relays.filter(r => typeof r === 'string') : []; + const normalized = normalizeRelays(relays); + return normalized.length > 0 ? normalized : undefined; + } catch (err: any) { + if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) { + return undefined; + } + // On malformed JSON, treat as no config rather than crashing the CLI + return undefined; + } +} + +export function readConfiguredRelaysSync(): string[] | undefined { + const file = getRelaysConfigPath(); + try { + const raw = fs.readFileSync(file, 'utf8'); + const parsed = JSON.parse(raw) as Partial; + const relays = Array.isArray(parsed.relays) ? parsed.relays.filter(r => typeof r === 'string') : []; + const normalized = normalizeRelays(relays); + return normalized.length > 0 ? normalized : undefined; + } catch (err: any) { + return undefined; + } +} + +export async function writeConfiguredRelays(relays: string[]): Promise { + const normalized = normalizeRelays(relays); + const dir = getConfigDirectory(); + await fsp.mkdir(dir, {recursive: true}); + const file = getRelaysConfigPath(); + const payload: RelaysConfig = {relays: normalized, updatedAt: new Date().toISOString()}; + await fsp.writeFile(file, JSON.stringify(payload, null, 2), 'utf8'); + return normalized; +} + +export async function removeConfiguredRelays(): Promise { + const file = getRelaysConfigPath(); + try { + await fsp.unlink(file); + } catch (err: any) { + if (err && err.code !== 'ENOENT') { + throw err; + } + } +} + +// Resolve with precedence: explicit override → configured defaults → supplied fallback +export function resolveRelaysWithFallbackSync( + override: string[] | undefined, + fallback: string[] +): string[] { + if (Array.isArray(override) && override.length > 0) { + const normalizedOverride = normalizeRelays(override); + if (normalizedOverride.length > 0) { + return normalizedOverride; + } + // fall through to configured/fallback if overrides normalize away + } + const configured = readConfiguredRelaysSync(); + if (configured && configured.length > 0) { + return configured; + } + return normalizeRelays(fallback); +} diff --git a/tests/relays.test.ts b/tests/relays.test.ts new file mode 100644 index 0000000..aaae7e2 --- /dev/null +++ b/tests/relays.test.ts @@ -0,0 +1,92 @@ +import {test, beforeEach, afterEach} from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +import { + normalizeRelays, + resolveRelaysWithFallbackSync, + writeConfiguredRelays, + readConfiguredRelaysSync, + removeConfiguredRelays, + getRelaysConfigPath +} from '../src/keyset/relays.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'igloo-tests-')); + process.env.IGLOO_APPDATA = tmpDir; + // ensure starting clean + await removeConfiguredRelays(); +}); + +afterEach(async () => { + delete process.env.IGLOO_APPDATA; + await fs.rm(tmpDir, {recursive: true, force: true}); +}); + +test('normalizeRelays filters invalid and de-duplicates', () => { + const input = [ + ' wss://A.example ', // valid + 'ws://b.example', // valid + 'https://nope', // invalid scheme + 'WSS://a.example' // duplicate (case-insensitive) + ]; + const out = normalizeRelays(input); + assert.deepEqual(out, ['wss://A.example', 'ws://b.example']); +}); + +test('resolve precedence: override > configured > fallback', async () => { + // No config yet: use fallback + let resolved = resolveRelaysWithFallbackSync(undefined, ['wss://fallback']); + assert.deepEqual(resolved, ['wss://fallback']); + + // Configure one relay, should win over fallback + await writeConfiguredRelays(['wss://foo']); + resolved = resolveRelaysWithFallbackSync(undefined, ['wss://fallback']); + assert.deepEqual(resolved, ['wss://foo']); + + // Explicit override should win over configured + resolved = resolveRelaysWithFallbackSync(['wss://bar'], ['wss://fallback']); + assert.deepEqual(resolved, ['wss://bar']); +}); + +test('invalid override falls back to configured', async () => { + await writeConfiguredRelays(['wss://foo']); + const resolved = resolveRelaysWithFallbackSync(['wss//typo', 'ftp://host'], ['wss://fallback']); + assert.deepEqual(resolved, ['wss://foo']); +}); + +test('invalid override falls back to fallback when no config', () => { + const resolved = resolveRelaysWithFallbackSync(['wss//typo', 'ftp://host'], ['wss://fallback']); + assert.deepEqual(resolved, ['wss://fallback']); +}); + +test('disk-first add preserves existing configured relays', async () => { + await writeConfiguredRelays(['wss://foo']); + const disk = readConfiguredRelaysSync() ?? []; + const next = normalizeRelays([...disk, 'wss://bar']); + await writeConfiguredRelays(next); + const final = readConfiguredRelaysSync(); + assert.deepEqual(final, ['wss://foo', 'wss://bar']); +}); + +test('disk-first remove prunes targeted relays', async () => { + await writeConfiguredRelays(['wss://foo', 'wss://bar']); + const disk = readConfiguredRelaysSync() ?? []; + const toRemove = new Set(['wss://foo']); + const next = disk.filter(r => !toRemove.has(r.toLowerCase())); + await writeConfiguredRelays(next); + const final = readConfiguredRelaysSync(); + assert.deepEqual(final, ['wss://bar']); +}); + +test('writes to IGLOO_APPDATA and not global config', async () => { + const file = getRelaysConfigPath(); + assert.ok(file.startsWith(tmpDir)); + await writeConfiguredRelays(['wss://foo']); + const stat = await fs.stat(file); + assert.ok(stat.isFile()); +});