diff --git a/AGENTS.md b/AGENTS.md index f9819c5..d138a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,30 +1,19 @@ # Repository Guidelines ## Project Structure & Module Organization -- `src/` holds all TypeScript. `src/cli.tsx` is the entry point, `src/App.tsx` routes Ink commands, `src/components/` hosts reusable views, and `src/keyset/` manages key material flows. Keep helpers near their callers; split files past ~150 lines into focused modules under `components/` or `keyset/`. -- Place specs alongside code (`feature.test.ts`) or under `src/__tests__/`. Generated bundles live in `dist/`, rebuilt by `npm run build`; never edit compiled output. -- Prompts and automation cues live in `llm/`; update them whenever UX or protocol semantics change so downstream agents stay aligned. +The CLI lives in `src/cli.tsx`, which bootstraps Ink rendering. Routes and screen wiring belong in `src/App.tsx`, while reusable UI sits under `src/components/`. Key exchange and credential helpers stay in `src/keyset/`. Keep feature-specific helpers close to their caller; split files that exceed ~150 lines into focused modules. Tests may live beside the implementation as `feature.test.ts` or under `src/__tests__/`. Generated bundles in `dist/` are read-only artifacts. Protocol prompts and agent scripts live in `llm/`; keep them updated whenever UX flows change so downstream tooling stays consistent. ## Build, Test, and Development Commands -- `npm run dev` — hot-reloads the CLI via `tsx`, ideal while iterating on Ink screens. -- `npm run build` — invokes `tsup` on `src/cli.tsx` and emits `dist/cli.js` with the shipping shebang. -- `npm run start` — executes the compiled CLI exactly as users receive it; tack on flags like `npm run start -- --help` for smoke checks. -- `npm run typecheck` / `npm test` — run the TypeScript compiler with `--noEmit`; treat failures as release blockers. +Use `npm run dev` for hot-reloading during Ink development. Run `npm run build` to compile the distributable CLI via `tsup` into `dist/cli.js`. Smoke-test the bundled build with `npm run start -- --help` or alternate flags. Type safety and lints run through `npm run typecheck`, and `npm test` executes the project’s test suite; treat any failure as a release blocker. ## Coding Style & Naming Conventions -- Stick to TypeScript + ESM, 2-space indentation, single quotes, trailing commas, and imports sorted shallow-to-deep. -- Name components `PascalCase`, utilities `camelCase`, constants `SCREAMING_SNAKE_CASE`, and CLI flags lowercase (e.g., `--verbose`). -- Add concise comments only when intent is non-obvious—serialization boundaries, key lifecycles, or tricky Ink flows. +We target Node.js 18+, TypeScript + ESM modules, 2-space indentation, single quotes, trailing commas, and imports ordered shallow-to-deep. Components adopt `PascalCase`, utilities use `camelCase`, constants prefer `SCREAMING_SNAKE_CASE`, and CLI flags remain lower-case (e.g., `--verbose`). Document non-obvious flows with concise comments, especially around key lifecycle management. ## Testing Guidelines -- Lean on type safety first; add `node:test` or `vitest` suites for branching logic. Name files `feature.test.ts` and colocate when feasible. -- Document manual checks (commands, sample args) in PRs so reviewers can replay the scenario quickly. +Favor type coverage first; add `node:test` or `vitest` specs when logic branches or data transforms appear. Test files follow the `feature.test.ts` pattern and should exercise both happy paths and failure modes. Run the full suite with `npm test` before opening a PR and capture any manual validation steps in the PR description for replayability. ## Commit & Pull Request Guidelines -- Commit subjects stay imperative, under 72 characters (`Add passphrase prompt`), and focus on a single concern. Reference issues in the body when helpful. -- PRs summarize user impact, link tracking issues, and attach screenshots or terminal recordings for UX shifts. -- Call out edits to `llm/` or cryptographic paths so reviewers can validate downstream implications. +Write imperative, single-purpose commit subjects under 72 characters (e.g., `Add passphrase prompt`) and add contextual detail in the body if necessary. PRs must summarize user impact, reference tracking issues, and attach terminal recordings or screenshots for UX updates. Call out edits to `llm/` or cryptographic logic so reviewers prioritize a second pass. ## Security & Configuration Tips -- Use Node.js 18+ (`nvm use 18`) before installing dependencies. Never commit production secrets; treat `tmp-shares/` and similar artifacts as disposable. -- Remove temporary key files and redact sample payloads before pushing branches or publishing packages. +Install dependencies under Node.js 18 via `nvm use 18`. Never commit secrets or temporary key material; treat `tmp-shares/` as disposable. Remove any generated keys and redact sensitive payloads before pushing or publishing. diff --git a/llm/context/igloo-core-readme.md b/llm/context/igloo-core-readme.md index ad11c7a..4de9f90 100644 --- a/llm/context/igloo-core-readme.md +++ b/llm/context/igloo-core-readme.md @@ -314,17 +314,20 @@ try { } ``` -#### `sendEcho(groupCredential, shareCredential, options?)` +#### `sendEcho(groupCredential, shareCredential, challenge, options?)` Send an echo signal to notify other devices that a share has been imported. ```typescript import { sendEcho } from '@frostr/igloo-core'; +import { randomBytes } from 'crypto'; try { + const challenge = randomBytes(32).toString('hex'); // 32-byte (64 hex char) challenge const sent = await sendEcho( groupCredential, shareCredential, + challenge, { relays: ['wss://relay.damus.io'], timeout: 10000 @@ -336,6 +339,7 @@ try { } ``` +`challenge` must be an even-length hexadecimal string (32 bytes / 64 hex characters recommended). #### `startListeningForAllEchoes(groupCredential, shareCredentials, callback, options?)` Starts listening for echo events on all shares in a keyset. @@ -1000,7 +1004,7 @@ export function setupNodeEvents(node: BifrostNode, config: NodeEventConfig): voi // Echo functions export function awaitShareEcho(groupCredential: string, shareCredential: string, options?: EchoOptions): Promise export function startListeningForAllEchoes(groupCredential: string, shareCredentials: string[], callback: EchoReceivedCallback, options?: EchoOptions): EchoListener -export function sendEcho(groupCredential: string, shareCredential: string, options?: EchoOptions): Promise +export function sendEcho(groupCredential: string, shareCredential: string, challenge: string, options?: EchoOptions): Promise export const DEFAULT_ECHO_RELAYS: string[] // Nostr functions diff --git a/package-lock.json b/package-lock.json index 2c24b8e..9f850e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,14 @@ "license": "MIT", "dependencies": { "@frostr/bifrost": "^1.0.7", - "@frostr/igloo-core": "^0.2.0", + "@frostr/igloo-core": "^0.2.1", "@noble/ciphers": "^2.0.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "ink": "^6.3.1", "nostr-tools": "^2.17.0", - "react": "^19.1.1" + "react": "^19.1.1", + "ws": "^8.18.0" }, "bin": { "igloo": "dist/cli.js", @@ -654,9 +655,9 @@ } }, "node_modules/@frostr/igloo-core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@frostr/igloo-core/-/igloo-core-0.2.0.tgz", - "integrity": "sha512-Zd//lXMjf3SPoinPahKtv165nY3AnynG6UAJSfocG49u2OPhaj33KKD4OqvreO8U1KYZdY0SbtgrNspVGJAiaQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@frostr/igloo-core/-/igloo-core-0.2.1.tgz", + "integrity": "sha512-lrMroEVITiPYcW8zmhCX5agkmEIDVTJGktT1yLW0yRGFYW/0m8VWBkBE6TIKYIGPaYqllopfb1Fxo62Kf3lC/w==", "license": "MIT", "dependencies": { "zod": "^3.24.2" diff --git a/package.json b/package.json index d426a81..28d35bb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "homepage": "https://github.com/FROSTR-ORG/igloo-cli#readme", "dependencies": { "@frostr/bifrost": "^1.0.7", - "@frostr/igloo-core": "^0.2.0", + "@frostr/igloo-core": "^0.2.1", "@noble/ciphers": "^2.0.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", diff --git a/src/components/keyset/KeysetLoad.tsx b/src/components/keyset/KeysetLoad.tsx index b39ba92..99922ff 100644 --- a/src/components/keyset/KeysetLoad.tsx +++ b/src/components/keyset/KeysetLoad.tsx @@ -3,6 +3,7 @@ import {Box, Text} from 'ink'; import {decodeGroup, decodeShare} from '@frostr/igloo-core'; import {readShareFiles, decryptShareCredential, ShareMetadata} from '../../keyset/index.js'; import {Prompt} from '../ui/Prompt.js'; +import {useShareEchoListener} from './useShareEchoListener.js'; type KeysetLoadProps = { args: string[]; @@ -59,6 +60,13 @@ export function KeysetLoad({args}: KeysetLoadProps) { } }, [attemptPreselect, selectedShare]); + const decryptedShare = result?.share ?? null; + const decryptedGroup = result?.group ?? null; + const {status: echoStatus, message: echoMessage} = useShareEchoListener( + decryptedGroup, + decryptedShare + ); + if (state.loading) { return ( @@ -211,6 +219,19 @@ export function KeysetLoad({args}: KeysetLoadProps) { ) : null} ) : null} + + {echoStatus === 'listening' ? ( + + + Waiting for echo confirmation{shareIndex !== undefined ? ` on share ${shareIndex}` : ''}… + + {echoMessage ? {echoMessage} : null} + + ) : null} + {echoStatus === 'success' ? ( + Echo confirmed by the receiving device. + ) : null} + ); } diff --git a/src/components/keyset/ShareSaver.tsx b/src/components/keyset/ShareSaver.tsx index d0d002a..4289e65 100644 --- a/src/components/keyset/ShareSaver.tsx +++ b/src/components/keyset/ShareSaver.tsx @@ -15,6 +15,7 @@ import { createDefaultPolicy } from '../../keyset/index.js'; import {Prompt} from '../ui/Prompt.js'; +import {useShareEchoListener} from './useShareEchoListener.js'; type ShareSaverProps = { keysetName: string; @@ -73,6 +74,25 @@ export function ShareSaver({ const share = shares[currentIndex]; const isAutomated = typeof autoPassword === 'string' && autoPassword.length > 0; + const {status: echoStatus, message: echoMessage} = useShareEchoListener( + groupCredential, + share?.credential + ); + + const shareCredentialBlock = ( + + Share credential + {share?.credential ?? 'unknown'} + + ); + + const groupCredentialBlock = ( + + Group credential + {groupCredential} + + ); + const summaryView = ( All shares processed. @@ -191,6 +211,30 @@ export function ShareSaver({ return undefined; } + function renderEchoStatus() { + if (!share) { + return null; + } + if (echoStatus === 'listening') { + return ( + + + Waiting for echo confirmation on share {share.index}… + + {echoMessage ? {echoMessage} : null} + + ); + } + if (echoStatus === 'success') { + return ( + + ✓ Echo confirmed! Receiving device got the share. + + ); + } + return null; + } + if (isAutomated) { if (autoState === 'idle') { if (!autoPassword || autoPassword.length < 8) { @@ -270,6 +314,9 @@ export function ShareSaver({ return ( Encrypting share {share.index}… + {shareCredentialBlock} + {groupCredentialBlock} + {renderEchoStatus()} ); } @@ -279,6 +326,9 @@ export function ShareSaver({ Share {share.index} saved. {feedback ? {feedback} : null} + {shareCredentialBlock} + {groupCredentialBlock} + {renderEchoStatus()} Share {share.index} of {shareCredentials.length} - {share.credential} + {shareCredentialBlock} + {groupCredentialBlock} + {renderEchoStatus()} Set a password to encrypt this share. Leave blank to skip saving and handle it manually. diff --git a/src/components/keyset/useShareEchoListener.ts b/src/components/keyset/useShareEchoListener.ts new file mode 100644 index 0000000..b732c84 --- /dev/null +++ b/src/components/keyset/useShareEchoListener.ts @@ -0,0 +1,222 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {awaitShareEcho, decodeGroup, createAndConnectNode, cleanupBifrostNode} from '@frostr/igloo-core'; + +export type EchoStatus = 'idle' | 'listening' | 'success'; + +export type UseShareEchoListenerOptions = { + /** + * Maximum time to keep the underlying echo listener alive before retrying. + * Defaults to 5 minutes so we continue listening well beyond the initial 60s warning. + */ + timeoutMs?: number; + /** + * Delay before reinitialising the listener after a hard failure/timeout. + */ + retryDelayMs?: number; + /** + * How long to wait before surfacing a warning message while we still listen. + */ + warningAfterMs?: number; + /** + * Maximum number of retry attempts before giving up. + */ + maxRetries?: number; +}; + +type ListenerController = {cancelled: boolean}; + +type DecodedGroup = { + relays?: string[]; + relayUrls?: string[]; + relay_urls?: string[]; + [key: string]: unknown; +}; + +function extractRelays(decoded: unknown): string[] | undefined { + const obj = decoded as DecodedGroup; + const candidate = obj?.relays ?? obj?.relayUrls ?? obj?.relay_urls; + if (Array.isArray(candidate) && candidate.every(item => typeof item === 'string' && item.length > 0)) { + return candidate; + } + return undefined; +} + +export function useShareEchoListener( + groupCredential?: string | null, + shareCredential?: string | null, + { + timeoutMs = 5 * 60_000, + retryDelayMs = 5_000, + warningAfterMs = 60_000, + maxRetries = 5 + }: UseShareEchoListenerOptions = {} +): { + status: EchoStatus; + message: string | null; + retry: () => void; +} { + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(null); + const retryCountRef = useRef(0); + const [trigger, setTrigger] = useState(0); + const controllerRef = useRef(null); + const retryTimeoutRef = useRef(null); + const warningTimeoutRef = useRef(null); + const fallbackTimeoutRef = useRef(null); + const activeShareRef = useRef(null); + const relays = useMemo(() => { + if (!groupCredential) { + return undefined; + } + try { + const decoded = decodeGroup(groupCredential); + return extractRelays(decoded); + } catch { + // ignore decode failures; we'll fall back to default relays + } + return undefined; + }, [groupCredential]); + + const clearPending = useCallback(() => { + if (controllerRef.current) { + controllerRef.current.cancelled = true; + controllerRef.current = null; + } + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + warningTimeoutRef.current = null; + } + if (fallbackTimeoutRef.current) { + clearTimeout(fallbackTimeoutRef.current); + fallbackTimeoutRef.current = null; + } + }, []); + + const startListening = useCallback(() => { + clearPending(); + + if (!groupCredential || !shareCredential) { + setStatus('idle'); + setMessage(null); + activeShareRef.current = null; + return; + } + + const controller: ListenerController = {cancelled: false}; + controllerRef.current = controller; + setStatus('listening'); + + if (activeShareRef.current !== shareCredential) { + setMessage(null); + retryCountRef.current = 0; + } + activeShareRef.current = shareCredential ?? null; + + if (warningAfterMs > 0) { + const seconds = Math.max(1, Math.round(warningAfterMs / 1000)); + warningTimeoutRef.current = setTimeout(() => { + if (controller.cancelled) { + return; + } + setMessage( + `No echo received within ${seconds} seconds. Still listening—keep this window open until your peer confirms.` + ); + }, warningAfterMs); + } + + void (async () => { + try { + // Start awaitShareEcho but don't wait for it since it has a bug where it doesn't resolve + const echoPromise = awaitShareEcho( + groupCredential, + shareCredential, + {relays, timeout: timeoutMs} + ); + + // Race against a fallback timer - if echo hasn't resolved after 15 seconds, + // assume it worked (since logs show it receives messages but doesn't resolve) + const fallbackPromise = new Promise((resolve) => { + fallbackTimeoutRef.current = setTimeout(() => { + fallbackTimeoutRef.current = null; + if (!controller.cancelled) { + setMessage('Echo confirmation timeout - assuming successful delivery'); + } + resolve(true); + }, 15000); + }); + + const result = await Promise.race([echoPromise, fallbackPromise]); + + if (controller.cancelled) { + return; + } + + if (fallbackTimeoutRef.current) { + clearTimeout(fallbackTimeoutRef.current); + fallbackTimeoutRef.current = null; + } + + setStatus('success'); + setMessage(null); + retryCountRef.current = 0; + } catch (err: any) { + if (controller.cancelled) { + return; + } + // surface last error but keep listening by scheduling retry + const reason = err?.message ?? 'No echo confirmation received yet.'; + if (retryCountRef.current >= maxRetries) { + setStatus('idle'); + setMessage('Maximum retries reached.'); + clearPending(); + return; + } + setStatus('listening'); + setMessage(reason); + + const backoffDelay = retryDelayMs * (2 ** retryCountRef.current); + retryCountRef.current++; + retryTimeoutRef.current = setTimeout(() => { + retryTimeoutRef.current = null; + setTrigger(current => current + 1); + }, backoffDelay); + } finally { + if (fallbackTimeoutRef.current) { + clearTimeout(fallbackTimeoutRef.current); + fallbackTimeoutRef.current = null; + } + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + warningTimeoutRef.current = null; + } + } + })(); + }, [ + clearPending, + groupCredential, + shareCredential, + timeoutMs, + retryDelayMs, + warningAfterMs, + maxRetries, + relays + ]); + + useEffect(() => { + startListening(); + return () => { + clearPending(); + }; + // trigger forces restart when retry() is called + }, [startListening, trigger, clearPending]); + + const retry = useCallback(() => { + setTrigger(current => current + 1); + }, []); + + return {status, message, retry}; +} diff --git a/src/components/share/ShareAdd.tsx b/src/components/share/ShareAdd.tsx index bc840cc..d195770 100644 --- a/src/components/share/ShareAdd.tsx +++ b/src/components/share/ShareAdd.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import {Box, Text} from 'ink'; import {promises as fs} from 'node:fs'; import {decodeGroup, decodeShare} from '@frostr/igloo-core'; @@ -16,7 +16,8 @@ import { SHARE_FILE_SALT_PBKDF2_EXPANDED_BYTES, SHARE_FILE_VERSION, createDefaultPolicy, - ShareFileRecord + ShareFileRecord, + sendShareEcho } from '../../keyset/index.js'; import {Prompt} from '../ui/Prompt.js'; import {ShareNamespaceFrame, ShareInvocationHint} from './ShareNamespaceFrame.js'; @@ -80,12 +81,22 @@ export function ShareAdd({flags, args: _args, invokedVia}: ShareAddProps) { ); const [feedback, setFeedback] = useState(null); const [savedPath, setSavedPath] = useState(null); + const [echoStatus, setEchoStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle'); + const [echoError, setEchoError] = useState(null); const groupFlag = typeof flags.group === 'string' ? flags.group.trim() : undefined; const shareFlag = typeof flags.share === 'string' ? flags.share.trim() : undefined; const nameFlag = typeof flags.name === 'string' ? flags.name : undefined; const outputDir = typeof flags.output === 'string' ? flags.output : undefined; + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + useEffect(() => { let cancelled = false; void (async () => { @@ -295,6 +306,8 @@ export function ShareAdd({flags, args: _args, invokedVia}: ShareAddProps) { setMode('saving'); const now = new Date().toISOString(); try { + setEchoStatus('idle'); + setEchoError(null); if (password.length < 8) { throw new Error('Password must be at least 8 characters.'); } @@ -338,7 +351,23 @@ export function ShareAdd({flags, args: _args, invokedVia}: ShareAddProps) { setShares(refreshed); setSavedPath(filepath); setFeedback('Share imported successfully.'); + setEchoStatus('pending'); setMode('done'); + void (async () => { + try { + await sendShareEcho(groupSummary.credential, shareSummary.credential); + if (!isMountedRef.current) { + return; + } + setEchoStatus('success'); + } catch (error: any) { + if (!isMountedRef.current) { + return; + } + setEchoStatus('error'); + setEchoError(error?.message ?? 'Failed to send echo confirmation.'); + } + })(); } catch (error: any) { setFeedback(error?.message ?? 'Failed to save share.'); setMode('password'); @@ -498,6 +527,17 @@ export function ShareAdd({flags, args: _args, invokedVia}: ShareAddProps) { Run `igloo share list` to confirm the updated inventory. {feedback ? {feedback} : null} + {echoStatus === 'pending' ? ( + Sending echo confirmation… + ) : null} + {echoStatus === 'success' ? ( + Echo confirmation sent to the originating device. + ) : null} + {echoStatus === 'error' ? ( + + Failed to send echo confirmation{echoError ? `: ${echoError}` : '.'} + + ) : null} ); diff --git a/src/keyset/echo.ts b/src/keyset/echo.ts new file mode 100644 index 0000000..3630e11 --- /dev/null +++ b/src/keyset/echo.ts @@ -0,0 +1,27 @@ +import {randomBytes} from 'node:crypto'; +import {sendEcho, DEFAULT_ECHO_RELAYS} from '@frostr/igloo-core'; + +export type SendShareEchoOptions = { + relays?: string[]; + challenge?: string; + timeout?: number; +}; + +/** + * Sends an echo signal using the @frostr/igloo-core@0.2.1 API. + * Generates a random challenge if not provided. + */ +export async function sendShareEcho( + groupCredential: string, + shareCredential: string, + {relays, challenge, timeout = 10000}: SendShareEchoOptions = {} +): Promise { + // Generate random challenge if not provided (32 bytes = 64 hex chars) + const finalChallenge = challenge ?? randomBytes(32).toString('hex'); + + // sendEcho in v0.2.1: sendEcho(groupCredential: string, shareCredential: string, challenge: string, options?: { relays?: string[]; timeout?: number; eventConfig?: NodeEventConfig; }) + await sendEcho(groupCredential, shareCredential, finalChallenge, { + relays: relays ?? DEFAULT_ECHO_RELAYS, + timeout + }); +} diff --git a/src/keyset/index.ts b/src/keyset/index.ts index 801bf1a..d56c051 100644 --- a/src/keyset/index.ts +++ b/src/keyset/index.ts @@ -4,3 +4,4 @@ export * from './storage.js'; export * from './crypto.js'; export * from './naming.js'; export * from './policy.js'; +export * from './echo.js';