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());
+});