diff --git a/AGENTS.md b/AGENTS.md index d138a40..171d3f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,13 @@ 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 -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. +Use `bun run dev` for hot-reloading during Ink development. Run `bun run build` to compile the distributable CLI via `tsup` into `dist/cli.js`. Smoke-test the bundled build with `bun run start -- --help` or alternate flags. Type safety and lints run through `bun run typecheck`, and `bun test` executes the project's test suite; treat any failure as a release blocker. ## Coding Style & Naming Conventions 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 -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. +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 `bun test` before opening a PR and capture any manual validation steps in the PR description for replayability. ## Commit & Pull Request Guidelines 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. diff --git a/CLAUDE.md b/CLAUDE.md index c5ae591..61c8f64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,120 +12,136 @@ This CLI is built with React (via Ink), TypeScript, and uses the ESM module syst ```bash # Install dependencies -npm install +bun install # Run CLI in development (no build required) -npm run dev [command] [flags] +bun run dev [command] [flags] # Build for distribution -npm run build +bun run build # Run built CLI -npm start -# or -node dist/cli.js +bun start # or: node dist/cli.js # Type checking -npm run typecheck -# or -tsc --noEmit +bun run typecheck -# Test (currently just runs typecheck) -npm test +# Test (runs typecheck + tsx --test) +bun test # Link binary locally for testing -npm link -igloo-cli --help +bun link +igloo --help ``` -## Available Commands +## Command Structure -- `igloo-cli` — Default intro screen with FROSTR-themed welcome -- `igloo-cli setup --threshold 2 --total 3` — Bootstrap checklist for k-of-n setup -- `igloo-cli about` — Product overview and architecture summary -- `igloo-cli status` — Placeholder for health probes (not yet implemented) -- `igloo-cli --help` — Help screen -- `igloo-cli --version` — Version info +**Binary names**: `igloo` and `igloo-cli` (both work after linking) -Flag aliases: `-t` for `--threshold`, `-T` for `--total` +**Top-level commands**: +- `igloo` — Animated welcome screen +- `igloo setup --threshold 2 --total 3` — Bootstrap checklist for k-of-n setup +- `igloo about` — FROSTR architecture overview +- `igloo status --share ` — Peer diagnostics (shortcut for `share status`) +- `igloo signer --share ` — Run a signer (shortcut for `share signer`) +- `igloo policy --share ` — Policy management (shortcut for `share policy`) +- `igloo relays` — Show/set default relays -## Architecture - -### Entry Point & Routing +**Share namespace** (`igloo share `): +- `add --group --share ` — Import share +- `list` — List saved shares +- `load --share ` — Decrypt and display share +- `status --share ` — Peer diagnostics via Bifrost +- `signer --share ` — Long-lived signer process +- `policy --share ` — Configure send/receive rules -- [src/cli.tsx](src/cli.tsx) — Entry point, parses argv, renders Ink app - - Custom `parseArgv()` handles flags, positional args, `--help`, `--version` - - Renders `` or `` components via Ink's `render()` +**Keyset namespace** (`igloo keyset `): +- `create` — Generate and persist encrypted shares -- [src/App.tsx](src/App.tsx) — Command router - - Maps commands to React components - - Normalizes numeric flags (threshold/total) with validation +**Keys namespace** (`igloo keys `): +- `convert --from --value ` — Convert between npub/nsec/hex formats +- `npub ` / `nsec ` — Direct conversion from bech32 format +- `hex-public ` / `hex-private ` — Direct conversion from hex format -### Components +## Architecture -All components live in [src/components/](src/components/) and use Ink's React-like API for terminal UI: +### Entry Point & Routing -- `Intro.tsx` — Default welcome screen -- `Setup.tsx` — Step-by-step bootstrap checklist for share distribution -- `About.tsx` — FROSTR ecosystem overview -- `Help.tsx` — Terminal help/usage screen +- `src/cli.tsx` — Entry point with custom `parseArgv()` for flags and positional args, renders Ink app +- `src/App.tsx` — Command router, maps commands to React components via switch statement + +### Component Organization + +```text +src/components/ +├── Intro.tsx, Setup.tsx, About.tsx, Help.tsx # Top-level screens +├── ui/Prompt.tsx # Reusable prompt component +├── share/ # Share namespace commands +│ ├── ShareSigner.tsx # Long-lived signer with Bifrost node +│ ├── ShareStatus.tsx # Peer diagnostics +│ ├── ShareLoad.tsx, ShareList.tsx, ShareAdd.tsx +│ ├── SharePolicy.tsx # Policy configuration +│ ├── ShareHelp.tsx # Share namespace help +│ └── ShareNamespaceFrame.tsx # Namespace UI wrapper +├── keyset/ # Keyset creation and management +│ ├── KeysetCreate.tsx # Interactive keyset generation +│ ├── KeysetLoad.tsx, KeysetList.tsx # Load/list keysets +│ ├── KeysetSigner.tsx, KeysetStatus.tsx # Signer and status views +│ ├── KeysetHelp.tsx # Keyset namespace help +│ ├── ShareSaver.tsx # Encrypts and persists shares +│ └── useShareEchoListener.ts # Echo event hook +├── keys/ # Key conversion utilities +│ ├── KeyConvert.tsx # Key format conversion +│ └── KeyHelp.tsx # Keys namespace help +└── relays/ # Relay configuration + └── Relays.tsx +``` -### Build Configuration +### Core Library (`src/keyset/`) -- [tsconfig.json](tsconfig.json) — TypeScript config targeting ES2020, NodeNext modules, strict mode -- [tsup.config.ts](tsup.config.ts) — Build tool config - - Entry: `src/cli.tsx` - - Output: `dist/cli.js` with shebang (`#!/usr/bin/env node`) - - Format: ESM only - - Target: Node 18+ +Non-UI utilities for share management: +- `storage.ts` — Read/write share files to disk +- `crypto.ts` — Share encryption/decryption +- `paths.ts` — Platform-specific storage paths (e.g., `~/Library/Application Support/igloo-cli/shares`) +- `policy.ts` — Per-share policy management +- `relays.ts` — Default relay configuration +- `echoRelays.ts` — Echo-specific relay configuration +- `echo.ts` — Echo event utilities for share transfer +- `awaitShareEchoCompat.ts` — Compatibility layer for echo events +- `naming.ts` — Share naming utilities +- `types.ts` — TypeScript types for shares, keysets, policies +- `index.ts` — Barrel export -### FROSTR Ecosystem Context +### Polyfills (`src/polyfills/`) -FROSTR splits a nostr secret key (nsec) into multiple shares using Shamir Secret Sharing. A threshold (k) of total shares (n) is required to sign messages. This CLI helps users bootstrap and manage their signing setups. +- `websocket.ts` — WebSocket global for Node (required by nostr-tools) +- `nostr.ts` — Normalizes Nostr subscribe filters for relay compatibility -**Related Projects**: -- **@frostr/bifrost** — Reference client implementation (node coordination, signing) -- **@frostr/igloo-core** — TypeScript library for keyset management, node creation, peer management -- **Igloo Desktop** — Desktop key management app -- **Frost2x** — Browser extension (NIP-07 signer) -- **Igloo Server** — Server-based signer with ephemeral relay +### Key Dependencies -The CLI provides guidance for setting up these components together. Users typically: -1. Generate an nsec in Igloo Desktop -2. Split into k-of-n shares -3. Distribute shares across signers (Desktop, Frost2x, cold storage) -4. Configure shared relay URLs -5. Use the setup to sign nostr events transparently +- `@frostr/bifrost` — Reference client for node coordination and signing +- `@frostr/igloo-core` — Keyset generation, peer management +- `ink` — React-based terminal UI +- `nostr-tools` — Nostr protocol primitives ## Key Patterns -- **Ink Components**: Use ``, ``, etc. from `ink` for terminal UI (not HTML) -- **Argument Parsing**: Handled manually in cli.tsx, supports `--flag value`, `--flag=value`, `-f value` -- **Command Routing**: Switch statement in App.tsx based on normalized command string -- **File Extensions**: All imports must use `.js` extensions (TypeScript ESM requirement) even though source files are `.tsx` +- **Ink Components**: Use ``, `` from `ink` (not HTML elements) +- **File Extensions**: All imports use `.js` extensions (TypeScript ESM requirement) +- **Flag Parsing**: Manual in `cli.tsx`, supports `--flag value`, `--flag=value`, `-f value` +- **Flag Aliases**: `-t` → `--threshold`, `-T` → `--total`, `-E` → `--debug-echo`, `-h` → `--help`, `-v` → `--version` +- **Numeric Flags**: Validated via `parseNumber()` in App.tsx -## Common Tasks +## Adding a New Command -### Adding a New Command +1. Create component in appropriate `src/components/` subdirectory +2. Import in `src/App.tsx` +3. Add case to router function (`App()` switch or namespace-specific renderer like `renderShare()`) +4. Run `bun run typecheck` to verify +5. Test with `bun run dev ` -1. Create component in [src/components/](src/components/) -2. Import in [src/App.tsx](src/App.tsx) -3. Add case to switch statement in `App()` function -4. Update command examples in `Intro.tsx` if applicable -5. Run `npm run typecheck` to verify -6. Test with `npm run dev ` +## Environment Variables -### Adding Command Flags - -1. Parse in [src/cli.tsx](src/cli.tsx) `parseArgv()` if custom logic needed -2. Extract in [src/App.tsx](src/App.tsx) from `flags` prop -3. Pass as props to component -4. Add validation/defaults as needed (see `parseNumber()` example) - -### Building for Distribution - -```bash -npm run build -# Output: dist/cli.js with shebang, ESM format -# Test: node dist/cli.js [command] -``` +- `IGLOO_DEBUG_ECHO=1` — Enable verbose echo diagnostics (or use `--debug-echo` flag) +- `IGLOO_TEST_RELAY=wss://...` — Pin a specific relay for testing +- `IGLOO_DISABLE_RAW_MODE=1` — Disable Ink raw mode (for CI/tests) diff --git a/llm/context/testing/test-architecture.md b/llm/context/testing/test-architecture.md new file mode 100644 index 0000000..21292a3 --- /dev/null +++ b/llm/context/testing/test-architecture.md @@ -0,0 +1,309 @@ +# Test Architecture Overview + +## Test Framework + +igloo-cli uses **Node.js native test runner** (`node:test`) with `node:assert/strict` for assertions. Tests are executed via `tsx --test` for TypeScript support without precompilation. + +```bash +npm test # Full test suite (typecheck + build + test) +npm run test:bun # Alternative Bun runtime +npx tsx --test tests/specific.test.ts # Run single test file +``` + +--- + +## Test File Organization + +``` +tests/ +├── helpers/ +│ ├── runCli.ts # CLI process spawner for integration tests +│ └── tmp.ts # Temp directory utilities (makeTmp, writePasswordFile) +├── awaitShareEchoCompat.test.ts # Echo payload validation +├── cli.basic.test.ts # Command routing, help, version +├── cli.keys.convert.test.ts # Key format conversion +├── cli.parsing.test.ts # Argument parsing (parseArgv, toBool) +├── cli.relays.command.test.ts # Relay CLI commands +├── cli.share.flow.test.ts # Share add/list/load workflow +├── crypto.test.ts # Share encryption/decryption +├── echoRelays.test.ts # Echo relay computation +├── naming.test.ts # Keyset naming utilities +├── paths.test.ts # Platform-specific paths +├── policy.test.ts # Share policy management +├── relays.test.ts # Relay persistence +└── storage.test.ts # Share file I/O +``` + +--- + +## Test Categories + +### 1. Unit Tests (Pure Functions) + +Test isolated functions with no external dependencies or side effects. + +**Files:** +- `crypto.test.ts` - Cryptographic primitives +- `policy.test.ts` - Policy normalization and manipulation +- `cli.parsing.test.ts` - Argument parsing +- `naming.test.ts` - String slugification and ID building +- `paths.test.ts` - Path construction +- `echoRelays.test.ts` - Relay URL normalization + +**Pattern:** +```typescript +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {someFunction} from '../src/module.js'; + +test('someFunction does something', () => { + const result = someFunction(input); + assert.equal(result, expectedOutput); +}); +``` + +### 2. File I/O Tests + +Test functions that read/write to the filesystem using isolated temp directories. + +**Files:** +- `storage.test.ts` - Share record persistence +- `relays.test.ts` - Relay config persistence +- `naming.test.ts` - `keysetNameExists()` function + +**Pattern:** +```typescript +import {test, beforeEach, afterEach} from 'node:test'; +import fs from 'node:fs/promises'; +import {makeTmp} from './helpers/tmp.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await makeTmp('igloo-tests-'); + process.env.IGLOO_APPDATA = tmpDir; // Redirect storage +}); + +afterEach(async () => { + delete process.env.IGLOO_APPDATA; + await fs.rm(tmpDir, {recursive: true, force: true}); +}); + +test('file operation test', async () => { + // Test uses tmpDir, isolated from real app data +}); +``` + +### 3. Integration Tests (CLI Process) + +Spawn actual CLI process and verify stdout/stderr/exit codes. + +**Files:** +- `cli.basic.test.ts` - Command routing +- `cli.keys.convert.test.ts` - Key conversion commands +- `cli.relays.command.test.ts` - Relay management commands +- `cli.share.flow.test.ts` - End-to-end share workflow + +**Pattern:** +```typescript +import {runCli} from './helpers/runCli.js'; + +test('command produces expected output', async () => { + const {stdout, exitCode} = await runCli(['command', '--flag', 'value'], { + timeoutMs: 15000, + env: {IGLOO_TEST_AUTOPILOT: '1'}, + successPattern: /Expected output/ + }); + + assert.equal(exitCode, 0); + assert.match(stdout, /Expected output/); +}); +``` + +--- + +## Test Helpers + +### `tests/helpers/tmp.ts` + +```typescript +// Create isolated temp directory +export async function makeTmp(prefix = 'igloo-tests-'): Promise + +// Create password file for automation tests +export async function writePasswordFile(dir: string, password = 'testpassword123'): Promise +``` + +### `tests/helpers/runCli.ts` + +Spawns CLI process with full environment control: + +```typescript +type RunCliOptions = { + cwd?: string; // Working directory + env?: Record; // Environment variables + input?: string; // Stdin content + timeoutMs?: number; // Max execution time (default: 15000) + successPattern?: RegExp; // Kill process when pattern matches +}; + +type RunCliResult = { + exitCode: number; + signal: NodeJS.Signals | null; + timedOut: boolean; + stdout: string; + stderr: string; +}; + +export async function runCli(args: string[], options?: RunCliOptions): Promise +``` + +--- + +## Environment Variables for Testing + +| Variable | Purpose | +|----------|---------| +| `IGLOO_APPDATA` | Override application data directory (isolates file I/O tests) | +| `IGLOO_DISABLE_RAW_MODE` | Disable Ink raw mode for CI/non-TTY environments | +| `IGLOO_SKIP_ECHO` | Skip network echo diagnostics | +| `IGLOO_TEST_AUTOPILOT` | Enable automated/non-interactive mode | +| `IGLOO_AUTOPASSWORD` | Auto-supply password for share decryption | +| `IGLOO_TEST_RELAY` | Pin a specific relay for testing | + +--- + +## Coverage by Module + +### `src/keyset/crypto.ts` (~35 tests) + +| Function | Tests | Notes | +|----------|-------|-------| +| `getIterationsForShareVersion()` | 3 | Version → iteration count mapping | +| `deriveSecret()` | 6 | PBKDF2 key derivation | +| `encryptPayload()` | 4 | AES-GCM encryption | +| `decryptPayload()` | 5 | Decryption + error handling | +| `randomSaltHex()` | 2 | Salt generation | +| `assertShareCredentialFormat()` | 2 | bfshare prefix validation | +| `decryptShareCredential()` | 8 | Multi-format decryption (v0/v1) | +| Constants | 3 | Export verification | +| Integration | 1 | Full roundtrip test | + +### `src/keyset/policy.ts` (~35 tests) + +| Function | Tests | Notes | +|----------|-------|-------| +| `createDefaultPolicy()` | 5 | Default policy creation | +| `ensurePolicy()` | 5 | Policy normalization | +| `setPolicyDefaults()` | 3 | Update default permissions | +| `upsertPeerPolicy()` | 4 | Add/update peer rules | +| `removePeerPolicy()` | 3 | Remove peer rules | +| `updatePolicyTimestamp()` | 1 | Timestamp management | +| `pruneEmptyPeers()` | 2 | Cleanup empty peers | +| `coerceBoolean()` (internal) | 9 | Tested via ensurePolicy | +| Immutability | 1 | Verify functions don't mutate | +| Constants | 2 | DEFAULT_POLICY_DEFAULTS | + +### `src/keyset/storage.ts` (~23 tests) + +| Function | Tests | Notes | +|----------|-------|-------| +| `ensureShareDirectory()` | 4 | Directory creation | +| `readShareFiles()` | 7 | List all shares | +| `saveShareRecord()` | 6 | Persist share to disk | +| `loadShareRecord()` | 5 | Load by id or filepath | +| Integration | 1 | Save → load roundtrip | + +### `src/lib/parseArgv.ts` (~22 tests) + +| Function | Tests | Notes | +|----------|-------|-------| +| `parseArgv()` | 17 | Positionals, flags, aliases | +| `toBool()` | 5 | String → boolean coercion | + +### `src/keyset/naming.ts` (~11 tests) + +| Function | Tests | Notes | +|----------|-------|-------| +| `slugifyKeysetName()` | 5 | Name → slug conversion | +| `buildShareId()` | 2 | Generate share ID | +| `buildShareFilePath()` | 1 | Full path construction | +| `keysetNameExists()` | 3 | Check for existing shares | + +### `src/keyset/paths.ts` (~6 tests) + +| Function | Tests | Notes | +|----------|-------|-------| +| `getAppDataPath()` | 4 | Platform-specific paths, env override | +| `getShareDirectory()` | 2 | Shares subdirectory | + +--- + +## Dependency Coverage + +The upstream `@frostr/igloo-core` library has comprehensive tests covering: +- Key splitting and recovery +- Policy normalization and application +- Credential validation (nsec, hex, bfshare, bfgroup) +- Node lifecycle and peer communication +- Echo signaling protocol + +**igloo-cli does NOT re-test** functionality covered by igloo-core. CLI tests focus on: +- CLI-specific argument parsing +- Local share encryption/storage (not covered upstream) +- File I/O and persistence +- Integration with the CLI + +--- + +## Testing Best Practices + +### 1. Isolation +- Each test gets fresh temp directories +- Environment variables are saved/restored in beforeEach/afterEach +- No shared mutable state between tests + +### 2. Determinism +- Use fixed timestamps and salts for cryptographic tests +- Use `IGLOO_SKIP_ECHO` to avoid network dependencies +- Tests should pass regardless of execution order + +### 3. Cleanup +- Always remove temp directories in `afterEach` +- Restore original environment variables + +### 4. Timeouts +- CLI integration tests use explicit timeouts (default: 15s) +- Long-running crypto tests (PBKDF2) may take several seconds + +### 5. Pattern-Based Success Detection +- Use `successPattern` in runCli to detect async operation completion +- Avoids arbitrary sleep() calls + +--- + +## Running Tests + +```bash +# Full suite (recommended) +npm test + +# Individual test file +npx tsx --test tests/crypto.test.ts + +# Multiple specific files +npx tsx --test tests/policy.test.ts tests/storage.test.ts + +# Watch mode (not built-in, use nodemon or similar) +npx nodemon --exec "npx tsx --test tests/policy.test.ts" +``` + +--- + +## Adding New Tests + +1. Create test file in `tests/` with `.test.ts` extension +2. Import from `node:test` and `node:assert/strict` +3. For file I/O tests, use `IGLOO_APPDATA` pattern with `makeTmp()` +4. For CLI tests, use `runCli()` helper +5. Run `npm test` to verify diff --git a/llm/implementation/test-coverage-implementation.md b/llm/implementation/test-coverage-implementation.md new file mode 100644 index 0000000..c8eefbd --- /dev/null +++ b/llm/implementation/test-coverage-implementation.md @@ -0,0 +1,177 @@ +# 2026-01-05 — Test Coverage Implementation + +## Summary + +Implemented comprehensive test coverage for igloo-cli based on the TEST_COVERAGE_ANALYSIS.md specification. Added **97 new tests** across 6 test files, bringing total test count to **164 tests** (164 passing, 0 failing). + +## Test Files Created + +### 1. `tests/crypto.test.ts` (35 tests) - CRITICAL +**Target:** `src/keyset/crypto.ts` + +Tests local share encryption for disk storage—functionality NOT covered by upstream igloo-core. + +**Coverage:** +- Constants verification (`SHARE_FILE_VERSION`, iteration counts, encoding) +- `getIterationsForShareVersion()` - version → iteration mapping +- `deriveSecret()` - PBKDF2 key derivation with sha256/raw encoding +- `encryptPayload()` / `decryptPayload()` - AES-GCM roundtrip +- `randomSaltHex()` - salt generation +- `assertShareCredentialFormat()` - bfshare prefix validation +- `decryptShareCredential()` - multi-format decryption (v0 legacy, v1 modern) +- Full roundtrip integration test + +**Key test vectors:** +- Legacy v0 format: 32 iterations, raw encoding, 12-byte IV +- Modern v1 format: 600,000 iterations, sha256 encoding, 24-byte IV + +--- + +### 2. `tests/policy.test.ts` (35 tests) - HIGH +**Target:** `src/keyset/policy.ts` + +Tests share policy management—CLI's persistence layer for access control. + +**Coverage:** +- `DEFAULT_POLICY_DEFAULTS` constant +- `createDefaultPolicy()` - default policy creation with timestamps +- `ensurePolicy()` - policy normalization from records +- `setPolicyDefaults()` - update default permissions +- `upsertPeerPolicy()` - add/update/auto-remove peer rules +- `removePeerPolicy()` - delete peer rules +- `updatePolicyTimestamp()` - timestamp management +- `pruneEmptyPeers()` - cleanup optimization +- `coerceBoolean()` (internal) - tested via ensurePolicy with string values +- Immutability verification + +**Key behaviors tested:** +- Peer pubkey normalization via igloo-core +- Auto-removal of peer entries matching defaults (storage optimization) +- Case-insensitive boolean coercion ("true", "1", "yes", "on") + +--- + +### 3. `tests/storage.test.ts` (23 tests) - HIGH +**Target:** `src/keyset/storage.ts` + +Tests share file persistence with isolated temp directories. + +**Coverage:** +- `ensureShareDirectory()` - directory creation, override, idempotency +- `readShareFiles()` - list shares, filter JSON, skip malformed, attach filepath +- `saveShareRecord()` - persist with defaults (version, savedAt), prune policy +- `loadShareRecord()` - load by id or filepath, handle missing files +- Save → load roundtrip integration + +**Test pattern:** +```typescript +beforeEach(async () => { + tmpDir = await makeTmp('igloo-storage-'); + process.env.IGLOO_APPDATA = tmpDir; +}); +``` + +--- + +### 4. `tests/cli.parsing.test.ts` (22 tests) - MEDIUM +**Target:** `src/lib/parseArgv.ts` + +Tests CLI argument parsing extracted to a side-effect-free module. + +**Coverage:** +- `parseArgv()` - positional args, long flags, short flags, inline values +- Flag aliases: `-t` → `--threshold`, `-T` → `--total`, `-E` → `--debug-echo` +- Help/version flags: `--help`, `-h`, `--version`, `-v` +- `toBool()` - string/boolean/undefined → boolean coercion + +**Refactoring note:** Extracted `parseArgv` and `toBool` from `src/cli.tsx` to `src/lib/parseArgv.ts` to enable unit testing without side effects (the original module renders the CLI on import). + +--- + +### 5. `tests/naming.test.ts` (11 tests) - LOW +**Target:** `src/keyset/naming.ts` + +Tests keyset naming utilities. + +**Coverage:** +- `slugifyKeysetName()` - lowercase, spaces → hyphens, special chars, trim +- `buildShareId()` - combine slug + index +- `buildShareFilePath()` - full path with .json extension +- `keysetNameExists()` - check for existing shares (file I/O) + +--- + +### 6. `tests/paths.test.ts` (6 tests) - LOW +**Target:** `src/keyset/paths.ts` + +Tests platform-specific path handling. + +**Coverage:** +- `getAppDataPath()` - IGLOO_APPDATA override, platform detection +- `getShareDirectory()` - appends igloo/shares subdirectory + +**Platform testing approach:** Uses env override for isolation; tests actual platform behavior rather than mocking `os.platform()`. + +--- + +## Key Files Modified + +| File | Change | +|------|--------| +| `src/cli.tsx` | Imports from new parseArgv module | +| `src/lib/parseArgv.ts` | **NEW** - Extracted parsing functions | +| `tests/crypto.test.ts` | **NEW** - Crypto unit tests | +| `tests/policy.test.ts` | **NEW** - Policy unit tests | +| `tests/storage.test.ts` | **NEW** - Storage I/O tests | +| `tests/cli.parsing.test.ts` | **NEW** - Parsing unit tests | +| `tests/naming.test.ts` | **NEW** - Naming unit tests | +| `tests/paths.test.ts` | **NEW** - Paths unit tests | + +--- + +## Test Metrics + +| Metric | Value | +|--------|-------| +| New test files | 6 | +| New tests added | 97 | +| Total tests | 164 | +| Passing tests | 164 | +| Failing tests | 0 | +| Test framework | node:test | +| Assertion library | node:assert/strict | + +--- + +## Coverage Summary + +| Module | File | Tests | Priority | +|--------|------|-------|----------| +| Crypto | src/keyset/crypto.ts | 35 | CRITICAL | +| Policy | src/keyset/policy.ts | 35 | HIGH | +| Storage | src/keyset/storage.ts | 23 | HIGH | +| CLI Parsing | src/lib/parseArgv.ts | 22 | MEDIUM | +| Naming | src/keyset/naming.ts | 11 | LOW | +| Paths | src/keyset/paths.ts | 6 | LOW | + +--- + +## Dependencies + +No new dependencies added. Tests use: +- `node:test` (built-in) +- `node:assert/strict` (built-in) +- `node:fs/promises` (built-in) +- `@noble/ciphers/aes.js` (existing, for legacy IV test) + +--- + +## Future Considerations + +1. **Integration tests for new modules** - The file I/O tests could be expanded with more edge cases (permissions, disk full, etc.) + +2. **Crypto test vectors** - Consider adding known-answer tests with fixed inputs for cross-implementation verification + +3. **Platform coverage** - paths.test.ts currently tests actual platform only; CI could run on multiple platforms + +4. **Performance benchmarks** - PBKDF2 tests take several seconds; could add dedicated perf suite diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 405aeb7..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2652 +0,0 @@ -{ - "name": "@frostr/igloo-cli", - "version": "1.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@frostr/igloo-cli", - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "@frostr/bifrost": "^1.0.7", - "@frostr/igloo-core": "0.2.4", - "@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", - "ws": "^8.18.0" - }, - "bin": { - "igloo": "dist/cli.js", - "igloo-cli": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^24.6.1", - "@types/react": "^19.1.16", - "tsup": "^8.5.0", - "tsx": "^4.20.6", - "typescript": "^5.9.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.2.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@cmdcode/buff": { - "version": "2.2.5", - "license": "CC-BY-1.0", - "dependencies": { - "@noble/hashes": "^1.3.3", - "@scure/base": "^1.1.5" - } - }, - "node_modules/@cmdcode/buff/node_modules/@noble/hashes": { - "version": "1.8.0", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@cmdcode/frost": { - "version": "1.1.3", - "license": "MIT", - "dependencies": { - "@cmdcode/buff": "^2.2.5", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0" - } - }, - "node_modules/@cmdcode/frost/node_modules/@noble/curves": { - "version": "1.9.7", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@cmdcode/frost/node_modules/@noble/hashes": { - "version": "1.8.0", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@cmdcode/nostr-p2p": { - "version": "2.0.11", - "license": "MIT", - "dependencies": { - "@cmdcode/buff": "^2.2.5", - "@noble/ciphers": "^1.0.0", - "@noble/curves": "^1.6.0", - "nostr-tools": "^2.10.4", - "zod": "^3.23.8" - } - }, - "node_modules/@cmdcode/nostr-p2p/node_modules/@noble/ciphers": { - "version": "1.3.0", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@cmdcode/nostr-p2p/node_modules/@noble/curves": { - "version": "1.9.7", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@cmdcode/nostr-p2p/node_modules/@noble/hashes": { - "version": "1.8.0", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@frostr/bifrost": { - "version": "1.0.7", - "license": "MIT", - "dependencies": { - "@cmdcode/buff": "^2.2.5", - "@cmdcode/frost": "^1.1.3", - "@cmdcode/nostr-p2p": "^2.0.11", - "@noble/ciphers": "^1.2.1", - "@noble/curves": "^1.8.1", - "zod": "^3.24.1" - } - }, - "node_modules/@frostr/bifrost/node_modules/@noble/ciphers": { - "version": "1.3.0", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@frostr/bifrost/node_modules/@noble/curves": { - "version": "1.9.7", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@frostr/bifrost/node_modules/@noble/hashes": { - "version": "1.8.0", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@frostr/igloo-core": { - "version": "0.2.4", - "license": "MIT", - "dependencies": { - "zod": "^3.24.2" - }, - "peerDependencies": { - "@frostr/bifrost": "^1.0.6", - "nostr-tools": "^2.10.0" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@noble/ciphers": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "@noble/hashes": "2.0.1" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@scure/base": { - "version": "1.2.6", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32": { - "version": "1.3.1", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.1.0", - "@noble/hashes": "~1.3.1", - "@scure/base": "~1.1.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@noble/curves": { - "version": "1.1.0", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.1" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": { - "version": "1.3.1", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@noble/hashes": { - "version": "1.3.3", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32/node_modules/@scure/base": { - "version": "1.1.9", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39": { - "version": "1.2.1", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.3.0", - "@scure/base": "~1.1.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39/node_modules/@noble/hashes": { - "version": "1.3.3", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39/node_modules/@scure/base": { - "version": "1.1.9", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.9.1", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.2", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-escapes": { - "version": "7.1.1", - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "dev": true, - "license": "MIT" - }, - "node_modules/auto-bind": { - "version": "5.0.1", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/bundle-require": { - "version": "5.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "load-tsconfig": "^0.2.3" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "esbuild": ">=0.18" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-toolkit": { - "version": "1.40.0", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.25.11", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/indent-string": { - "version": "5.0.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ink": { - "version": "6.3.1", - "license": "MIT", - "dependencies": { - "@alcalzone/ansi-tokenize": "^0.2.0", - "ansi-escapes": "^7.0.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.6.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^4.0.0", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.39.10", - "indent-string": "^5.0.0", - "is-in-ci": "^2.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.32.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^7.1.0", - "stack-utils": "^2.0.6", - "string-width": "^7.2.0", - "type-fest": "^4.27.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@types/react": ">=19.0.0", - "react": ">=19.0.0", - "react-devtools-core": "^6.1.2" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-in-ci": { - "version": "2.0.0", - "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/joycon": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "dev": true, - "license": "ISC" - }, - "node_modules/magic-string": { - "version": "0.30.19", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mlly": { - "version": "1.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nostr-tools": { - "version": "2.17.0", - "license": "Unlicense", - "peer": true, - "dependencies": { - "@noble/ciphers": "^0.5.1", - "@noble/curves": "1.2.0", - "@noble/hashes": "1.3.1", - "@scure/base": "1.1.1", - "@scure/bip32": "1.3.1", - "@scure/bip39": "1.2.1", - "nostr-wasm": "0.1.0" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/nostr-tools/node_modules/@noble/ciphers": { - "version": "0.5.3", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/nostr-tools/node_modules/@noble/curves": { - "version": "1.2.0", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.3.2" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": { - "version": "1.3.2", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/nostr-tools/node_modules/@noble/hashes": { - "version": "1.3.1", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/nostr-tools/node_modules/@scure/base": { - "version": "1.1.1", - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "license": "MIT" - }, - "node_modules/nostr-wasm": { - "version": "0.1.0", - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/patch-console": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.0", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-reconciler": { - "version": "0.32.0", - "license": "MIT", - "dependencies": { - "scheduler": "^0.26.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rollup": { - "version": "4.52.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.26.0", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "license": "ISC" - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/source-map": { - "version": "0.8.0-beta.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tr46": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.0", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.25.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "0.8.0-beta.0", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/tsx": { - "version": "4.20.6", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/type-fest": { - "version": "4.41.0", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "devOptional": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.1", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.16.0", - "dev": true, - "license": "MIT" - }, - "node_modules/webidl-conversions": { - "version": "4.0.2", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "7.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/which": { - "version": "2.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/widest-line": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.18.3", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yoga-layout": { - "version": "3.2.1", - "license": "MIT" - }, - "node_modules/zod": { - "version": "3.25.76", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json index 4e05904..d2795e1 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,13 @@ "@noble/hashes": "^2.0.1", "ink": "^6.3.1", "nostr-tools": "^2.17.0", + "qrcode": "^1.5.4", "react": "^19.1.1", "ws": "^8.18.0" }, "devDependencies": { "@types/node": "^24.6.1", + "@types/qrcode": "^1.5.6", "@types/react": "^19.1.16", "tsup": "^8.5.0", "tsx": "^4.20.6", diff --git a/src/cli.tsx b/src/cli.tsx index 72b1012..1a1c85e 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -5,6 +5,8 @@ import {render} from 'ink'; import {PassThrough} from 'node:stream'; import App from './App.js'; import {Help} from './components/Help.js'; +import {parseArgv, toBool} from './lib/parseArgv.js'; +import type {ParsedArgs, Flags} from './lib/parseArgv.js'; import packageJson from '../package.json' with {type: 'json'}; // Swallow benign Nostr pool shutdown rejections from nostr-tools @@ -42,103 +44,7 @@ if (typeof process !== 'undefined' && typeof process.on === 'function') { } catch {} } -type Flags = Record; - -type ParsedArgs = { - command: string; - args: string[]; - flags: Flags; - showHelp: boolean; - showVersion: boolean; -}; - -function parseArgv(argv: string[]): ParsedArgs { - const flags: Flags = {}; - const positionals: string[] = []; - let showHelp = false; - let showVersion = false; - - for (let index = 0; index < argv.length; index += 1) { - const value = argv[index]; - - if (value === '--help' || value === '-h') { - showHelp = true; - continue; - } - - if (value === '--version' || value === '-v') { - showVersion = true; - continue; - } - - if (value.startsWith('--')) { - const [name, inline] = value.slice(2).split('='); - - if (inline !== undefined && inline.length > 0) { - flags[name] = inline; - continue; - } - - const next = argv[index + 1]; - if (next !== undefined && !next.startsWith('-')) { - flags[name] = next; - index += 1; - } else { - flags[name] = true; - } - - continue; - } - - if (value.startsWith('-') && value.length > 1) { - const name = value.slice(1); - const next = argv[index + 1]; - if (next !== undefined && !next.startsWith('-')) { - flags[name] = next; - index += 1; - } else { - flags[name] = true; - } - continue; - } - - positionals.push(value); - } - - if (flags.t !== undefined && flags.threshold === undefined) { - flags.threshold = flags.t; - delete flags.t; - } - - if (flags.T !== undefined && flags.total === undefined) { - flags.total = flags.T; - delete flags.T; - } - - // Short alias: -E → --debug-echo - if (flags.E !== undefined && flags['debug-echo'] === undefined) { - flags['debug-echo'] = flags.E; - delete flags.E; - } - - return { - command: positionals[0] ?? 'intro', - args: positionals.slice(1), - flags, - showHelp, - showVersion - }; -} - -function toBool(value: string | boolean | undefined): boolean { - if (typeof value === 'boolean') return value; - if (typeof value === 'string') { - const v = value.trim().toLowerCase(); - if (['1', 'true', 'yes', 'on'].includes(v)) return true; - if (['0', 'false', 'no', 'off'].includes(v)) return false; - } - return false; -} +// parseArgv, toBool, ParsedArgs, and Flags are now imported from ./lib/parseArgv.js function showHelpScreen(version: string, opts?: any) { const instance = render(, opts); diff --git a/src/components/Help.tsx b/src/components/Help.tsx index d913cff..7075dfb 100644 --- a/src/components/Help.tsx +++ b/src/components/Help.tsx @@ -1,6 +1,18 @@ import React from 'react'; import {Box, Text} from 'ink'; +const FROSTR_ICON = ` + \\ | / + \\ | / + ----(o)---- + / | \\ + / | \\ + | + | + |-- + |-- +`; + type HelpProps = { version: string; }; @@ -9,6 +21,10 @@ export function Help({version}: HelpProps) { return ( + {FROSTR_ICON} + + + IGLOO CLI FROSTR remote signing toolkit version {version} diff --git a/src/components/Intro.tsx b/src/components/Intro.tsx index 129e858..fb181d2 100644 --- a/src/components/Intro.tsx +++ b/src/components/Intro.tsx @@ -1,15 +1,110 @@ -import React from 'react'; +import {useState, useEffect} from 'react'; import {Box, Text} from 'ink'; +// Animation frames for twirling key (z-axis rotation) +const ICON_FRAMES = [ + // Frame 0: front view (0°) + ` \\ | / + \\ | / +----(o)---- + / | \\ + / | \\ + | + | + |-- + |--`, + // Frame 1: compressing (~45°) + ` \\ | / + \\|/ + ---(o)--- + /|\\ + / | \\ + | + | + |- + |-`, + // Frame 2: edge view (90°) + ` | + | + (o) + | + | + | + | + | + |`, + // Frame 3: expanding (~135°) + ` / | \\ + /|\\ + ---(o)--- + \\|/ + \\ | / + | + | + -| + -|`, + // Frame 4: back view (180°) + ` / | \\ + / | \\ +----(o)---- + \\ | / + \\ | / + | + | + --| + --|`, + // Frame 5: compressing (~225°) + ` / | \\ + /|\\ + ---(o)--- + \\|/ + \\ | / + | + | + -| + -|`, + // Frame 6: edge view (270°) + ` | + | + (o) + | + | + | + | + | + |`, + // Frame 7: expanding (~315°) + ` \\ | / + \\|/ + ---(o)--- + /|\\ + / | \\ + | + | + |- + |-`, +]; + type IntroProps = { version: string; commandExamples: string[]; }; export function Intro({version, commandExamples}: IntroProps) { + const [frameIndex, setFrameIndex] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setFrameIndex(prev => (prev + 1) % ICON_FRAMES.length); + }, 200); + + return () => clearInterval(interval); + }, []); + return ( + {ICON_FRAMES[frameIndex]} IGLOO CLI FROSTR remote signing toolkit version {version} diff --git a/src/components/keyset/KeysetCreate.tsx b/src/components/keyset/KeysetCreate.tsx index 9adb483..79c6dc8 100644 --- a/src/components/keyset/KeysetCreate.tsx +++ b/src/components/keyset/KeysetCreate.tsx @@ -130,6 +130,7 @@ export function KeysetCreate({flags}: KeysetCreateProps) { const passwordFilePath = typeof flags['password-file'] === 'string' ? flags['password-file'] : undefined; const outputDirFlag = typeof flags.output === 'string' ? flags.output : undefined; const resolvedOutputDir = outputDirFlag ? path.resolve(process.cwd(), outputDirFlag) : undefined; + const showQR = flags.qr === true || flags['show-qr'] === true; const [automationPassword, setAutomationPassword] = useState(directPassword); const [automationError, setAutomationError] = useState(null); const [automationLoading, setAutomationLoading] = useState(Boolean(passwordFilePath && !directPassword)); @@ -495,6 +496,7 @@ export function KeysetCreate({flags}: KeysetCreateProps) { }} autoPassword={automationPassword} outputDir={resolvedOutputDir} + showQR={showQR} /> diff --git a/src/components/keyset/KeysetLoad.tsx b/src/components/keyset/KeysetLoad.tsx index a3cb940..0c581c5 100644 --- a/src/components/keyset/KeysetLoad.tsx +++ b/src/components/keyset/KeysetLoad.tsx @@ -1,8 +1,9 @@ import React, {useEffect, useMemo, useState} from 'react'; -import {Box, Text} from 'ink'; +import {Box, Text, useInput, useStdin} from 'ink'; import {decodeGroup, decodeShare} from '@frostr/igloo-core'; import {readShareFiles, decryptShareCredential, ShareMetadata} from '../../keyset/index.js'; import {Prompt} from '../ui/Prompt.js'; +import {CredentialDisplay} from '../ui/CredentialDisplay.js'; import {useShareEchoListener} from './useShareEchoListener.js'; type KeysetLoadProps = { @@ -26,6 +27,10 @@ export function KeysetLoad({args, flags}: KeysetLoadProps) { const [autoDecrypting, setAutoDecrypting] = useState(false); const [autoError, setAutoError] = useState(null); + const showQRFlag = flags?.qr === true || flags?.['show-qr'] === true; + const [qrVisible, setQrVisible] = useState(showQRFlag); + const {isRawModeSupported} = useStdin(); + useEffect(() => { void (async () => { try { @@ -139,6 +144,12 @@ export function KeysetLoad({args, flags}: KeysetLoadProps) { skipEcho ? undefined : decryptedShare ); + useInput((input) => { + if (input === 'q' || input === 'Q') { + setQrVisible(v => !v); + } + }, {isActive: isRawModeSupported && phase === 'result'}); + if (state.loading) { return ( @@ -275,12 +286,23 @@ export function KeysetLoad({args, flags}: KeysetLoadProps) { return ( Share decrypted successfully. - Share credential - {result.share} - - Group credential - - {result.group} + + + {isRawModeSupported ? ( + Press Q to {qrVisible ? 'hide' : 'show'} QR codes + ) : null} {shareIndex !== undefined ? ( Share details @@ -311,6 +333,23 @@ export function KeysetLoad({args, flags}: KeysetLoadProps) { Echo confirmed by the receiving device. ) : null} + {isRawModeSupported ? ( + + { + try { + if (typeof process !== 'undefined' && typeof process.exit === 'function') { + process.exit(0); + } + } catch {} + return undefined; + }} + /> + + ) : null} ); } diff --git a/src/components/keyset/ShareSaver.tsx b/src/components/keyset/ShareSaver.tsx index 70d7e25..e2757e7 100644 --- a/src/components/keyset/ShareSaver.tsx +++ b/src/components/keyset/ShareSaver.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useMemo, useState} from 'react'; -import {Box, Text} from 'ink'; +import {Box, Text, useInput} from 'ink'; import {decodeShare} from '@frostr/igloo-core'; import { deriveSecret, @@ -15,6 +15,7 @@ import { createDefaultPolicy } from '../../keyset/index.js'; import {Prompt} from '../ui/Prompt.js'; +import {CredentialDisplay} from '../ui/CredentialDisplay.js'; import {useShareEchoListener} from './useShareEchoListener.js'; type ShareSaverProps = { @@ -27,6 +28,7 @@ type ShareSaverProps = { }) => void; autoPassword?: string; outputDir?: string; + showQR?: boolean; }; type ShareState = { @@ -42,7 +44,8 @@ export function ShareSaver({ shareCredentials, onComplete, autoPassword, - outputDir + outputDir, + showQR = false }: ShareSaverProps) { const [currentIndex, setCurrentIndex] = useState(0); const [phase, setPhase] = useState('password'); @@ -53,6 +56,7 @@ export function ShareSaver({ const [notified, setNotified] = useState(false); const [autoState, setAutoState] = useState<'idle' | 'running' | 'done' | 'error'>('idle'); const [autoError, setAutoError] = useState(null); + const [qrVisible, setQrVisible] = useState(showQR); const shares = useMemo(() => { return shareCredentials.map((credential, idx) => { @@ -85,25 +89,47 @@ export function ShareSaver({ share?.credential ); + useInput((input) => { + if (input === 'q' || input === 'Q') { + setQrVisible(v => !v); + } + }, {isActive: shouldPrompt}); + + const qrHint = ( + Press Q to {qrVisible ? 'hide' : 'show'} QR codes + ); + const shareCredentialBlock = ( - - Share credential - {share?.credential ?? 'unknown'} - + ); const groupCredentialBlock = ( - - Group credential - {groupCredential} - + ); const summaryView = ( All shares processed. - Group credential: - {groupCredential} + + {shouldPrompt ? qrHint : null} {savedPaths.length > 0 ? ( Saved files @@ -368,6 +394,7 @@ export function ShareSaver({ Encrypting share {share.index}… {shareCredentialBlock} {groupCredentialBlock} + {shouldPrompt ? qrHint : null} {renderEchoStatus()} ); @@ -380,6 +407,7 @@ export function ShareSaver({ {feedback ? {feedback} : null} {shareCredentialBlock} {groupCredentialBlock} + {shouldPrompt ? qrHint : null} {renderEchoStatus()} Share {share.index} of {shareCredentials.length} {shareCredentialBlock} {groupCredentialBlock} + {shouldPrompt ? qrHint : null} {renderEchoStatus()} Set a password to encrypt this share. Leave blank to skip saving and handle it manually. diff --git a/src/components/ui/CredentialDisplay.tsx b/src/components/ui/CredentialDisplay.tsx new file mode 100644 index 0000000..419b502 --- /dev/null +++ b/src/components/ui/CredentialDisplay.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import {Box, Text} from 'ink'; +import {QRCodeDisplay} from './QRCodeDisplay.js'; + +type CredentialDisplayProps = { + label: string; + credential: string; + labelColor?: string; + textColor?: string; + showQR?: boolean; +}; + +export function CredentialDisplay({ + label, + credential, + labelColor = 'cyanBright', + textColor = 'white', + showQR = false +}: CredentialDisplayProps) { + return ( + + {label} + {credential} + {showQR ? ( + + + + ) : null} + + ); +} diff --git a/src/components/ui/QRCodeDisplay.tsx b/src/components/ui/QRCodeDisplay.tsx new file mode 100644 index 0000000..1b84069 --- /dev/null +++ b/src/components/ui/QRCodeDisplay.tsx @@ -0,0 +1,78 @@ +import {useEffect, useState} from 'react'; +import {Box, Text} from 'ink'; +import QRCode from 'qrcode'; + +type QRCodeDisplayProps = { + value: string; + label?: string; + labelColor?: string; + small?: boolean; +}; + +export function QRCodeDisplay({ + value, + label, + labelColor = 'cyan', + small = true +}: QRCodeDisplayProps) { + const [qrString, setQrString] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!value) { + setQrString(null); + setError(null); + return; + } + + let canceled = false; + + void (async () => { + try { + const result = await QRCode.toString(value, { + type: 'terminal', + small, + errorCorrectionLevel: 'M' + }); + if (!canceled) { + setQrString(result); + setError(null); + } + } catch (err: any) { + if (!canceled) { + setError(err?.message ?? 'Failed to generate QR code'); + setQrString(null); + } + } + })(); + + return () => { + canceled = true; + }; + }, [value, small]); + + if (error) { + return ( + + {label ? {label} : null} + QR error: {error} + + ); + } + + if (!qrString) { + return ( + + {label ? {label} : null} + Generating QR code... + + ); + } + + return ( + + {label ? {label} : null} + {qrString} + + ); +} diff --git a/src/lib/parseArgv.ts b/src/lib/parseArgv.ts new file mode 100644 index 0000000..a34e8c9 --- /dev/null +++ b/src/lib/parseArgv.ts @@ -0,0 +1,101 @@ +export type Flags = Record; + +export type ParsedArgs = { + command: string; + args: string[]; + flags: Flags; + showHelp: boolean; + showVersion: boolean; +}; + +export function parseArgv(argv: string[]): ParsedArgs { + const flags: Flags = {}; + const positionals: string[] = []; + let showHelp = false; + let showVersion = false; + + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + + if (value === '--help' || value === '-h') { + showHelp = true; + continue; + } + + if (value === '--version' || value === '-v') { + showVersion = true; + continue; + } + + if (value.startsWith('--')) { + const flagValue = value.slice(2); + const equalsIndex = flagValue.indexOf('='); + const name = equalsIndex === -1 ? flagValue : flagValue.substring(0, equalsIndex); + + // If = is present, use the RHS (even if empty string) + if (equalsIndex !== -1) { + flags[name] = flagValue.substring(equalsIndex + 1); + continue; + } + + // No =, so peek at next arg for value or treat as boolean + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith('-')) { + flags[name] = next; + index += 1; + } else { + flags[name] = true; + } + + continue; + } + + if (value.startsWith('-') && value.length > 1) { + const name = value.slice(1); + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith('-')) { + flags[name] = next; + index += 1; + } else { + flags[name] = true; + } + continue; + } + + positionals.push(value); + } + + if (flags.t !== undefined && flags.threshold === undefined) { + flags.threshold = flags.t; + delete flags.t; + } + + if (flags.T !== undefined && flags.total === undefined) { + flags.total = flags.T; + delete flags.T; + } + + // Short alias: -E → --debug-echo + if (flags.E !== undefined && flags['debug-echo'] === undefined) { + flags['debug-echo'] = flags.E; + delete flags.E; + } + + return { + command: positionals[0] ?? 'intro', + args: positionals.slice(1), + flags, + showHelp, + showVersion + }; +} + +export function toBool(value: string | boolean | undefined): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const v = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(v)) return true; + if (['0', 'false', 'no', 'off'].includes(v)) return false; + } + return false; +} diff --git a/tests/cli.basic.test.ts b/tests/cli.basic.test.ts index 0c63561..84d4974 100644 --- a/tests/cli.basic.test.ts +++ b/tests/cli.basic.test.ts @@ -19,9 +19,12 @@ afterEach(async () => { }); test('default intro screen renders', async () => { - const {stdout, exitCode, timedOut} = await runCli([]); - assert.equal(exitCode, 0); - assert.equal(timedOut, false); + // Intro has an animated icon that runs forever, so we use successPattern + // to detect when content renders and terminate gracefully + const {stdout, timedOut} = await runCli([], { + successPattern: /Core commands/ + }); + assert.equal(timedOut, false, 'Process should not time out'); assert.match(stdout, /IGLOO CLI/); assert.match(stdout, /Core commands/); }); diff --git a/tests/cli.parsing.test.ts b/tests/cli.parsing.test.ts new file mode 100644 index 0000000..0cc3672 --- /dev/null +++ b/tests/cli.parsing.test.ts @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseArgv, toBool} from '../src/lib/parseArgv.js'; + +// ============================================================================= +// parseArgv - Basic Positional Parsing +// ============================================================================= + +test('parseArgv empty array returns intro command', () => { + const result = parseArgv([]); + assert.equal(result.command, 'intro'); + assert.deepEqual(result.args, []); + assert.deepEqual(result.flags, {}); + assert.equal(result.showHelp, false); + assert.equal(result.showVersion, false); +}); + +test('parseArgv single positional as command', () => { + const result = parseArgv(['setup']); + assert.equal(result.command, 'setup'); + assert.deepEqual(result.args, []); +}); + +test('parseArgv multiple positionals', () => { + const result = parseArgv(['share', 'add', 'extra']); + assert.equal(result.command, 'share'); + assert.deepEqual(result.args, ['add', 'extra']); +}); + +test('parseArgv --help sets showHelp', () => { + const result = parseArgv(['--help']); + assert.equal(result.showHelp, true); + assert.equal(result.showVersion, false); +}); + +test('parseArgv --version sets showVersion', () => { + const result = parseArgv(['--version']); + assert.equal(result.showVersion, true); + assert.equal(result.showHelp, false); +}); + +// ============================================================================= +// parseArgv - Flag Parsing +// ============================================================================= + +test('parseArgv --flag value', () => { + const result = parseArgv(['--share', 'abc123']); + assert.equal(result.flags.share, 'abc123'); +}); + +test('parseArgv --flag=value inline format', () => { + const result = parseArgv(['--share=abc123']); + assert.equal(result.flags.share, 'abc123'); +}); + +test('parseArgv --flag= with empty value yields empty string', () => { + const result = parseArgv(['--name=']); + assert.equal(result.flags.name, ''); +}); + +test('parseArgv --flag= does not consume next arg', () => { + const result = parseArgv(['--name=', 'command']); + assert.equal(result.flags.name, ''); + assert.equal(result.command, 'command'); +}); + +test('parseArgv -f value short flag', () => { + const result = parseArgv(['-s', 'abc123']); + assert.equal(result.flags.s, 'abc123'); +}); + +test('parseArgv boolean flag (no value)', () => { + const result = parseArgv(['--verbose']); + assert.equal(result.flags.verbose, true); +}); + +test('parseArgv -h sets showHelp', () => { + const result = parseArgv(['-h']); + assert.equal(result.showHelp, true); +}); + +test('parseArgv -v sets showVersion', () => { + const result = parseArgv(['-v']); + assert.equal(result.showVersion, true); +}); + +// ============================================================================= +// parseArgv - Flag Aliases +// ============================================================================= + +test('parseArgv -t alias for --threshold', () => { + const result = parseArgv(['-t', '2']); + assert.equal(result.flags.threshold, '2'); + assert.equal(result.flags.t, undefined); // Original short flag should be deleted +}); + +test('parseArgv -T alias for --total', () => { + const result = parseArgv(['-T', '3']); + assert.equal(result.flags.total, '3'); + assert.equal(result.flags.T, undefined); // Original short flag should be deleted +}); + +test('parseArgv -E alias for --debug-echo', () => { + const result = parseArgv(['-E']); + assert.equal(result.flags['debug-echo'], true); + assert.equal(result.flags.E, undefined); // Original short flag should be deleted +}); + +// ============================================================================= +// parseArgv - Mixed Positionals and Flags +// ============================================================================= + +test('parseArgv mixed positionals and flags', () => { + const result = parseArgv([ + 'keyset', + 'create', + '--threshold', '2', + '-T', '3', + '--name=MyKeyset', + '--verbose' + ]); + + assert.equal(result.command, 'keyset'); + assert.deepEqual(result.args, ['create']); + assert.equal(result.flags.threshold, '2'); + assert.equal(result.flags.total, '3'); + assert.equal(result.flags.name, 'MyKeyset'); + assert.equal(result.flags.verbose, true); +}); + +test('parseArgv flag followed by flag (boolean detection)', () => { + // When a flag is followed by another flag, the first should be boolean + const result = parseArgv(['--verbose', '--debug']); + assert.equal(result.flags.verbose, true); + assert.equal(result.flags.debug, true); +}); + +test('parseArgv help flag with command', () => { + const result = parseArgv(['share', '--help']); + assert.equal(result.command, 'share'); + assert.equal(result.showHelp, true); +}); + +// ============================================================================= +// toBool +// ============================================================================= + +test('toBool handles string true values', () => { + assert.equal(toBool('true'), true); + assert.equal(toBool('1'), true); + assert.equal(toBool('yes'), true); + assert.equal(toBool('on'), true); + assert.equal(toBool('TRUE'), true); + assert.equal(toBool('Yes'), true); + assert.equal(toBool('ON'), true); +}); + +test('toBool handles string false values', () => { + assert.equal(toBool('false'), false); + assert.equal(toBool('0'), false); + assert.equal(toBool('no'), false); + assert.equal(toBool('off'), false); + assert.equal(toBool('FALSE'), false); + assert.equal(toBool('No'), false); + assert.equal(toBool('OFF'), false); +}); + +test('toBool handles boolean values', () => { + assert.equal(toBool(true), true); + assert.equal(toBool(false), false); +}); + +test('toBool handles undefined', () => { + assert.equal(toBool(undefined), false); +}); + +test('toBool handles invalid strings', () => { + assert.equal(toBool('invalid'), false); + assert.equal(toBool('maybe'), false); + assert.equal(toBool(''), false); +}); diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts new file mode 100644 index 0000000..e953cc3 --- /dev/null +++ b/tests/crypto.test.ts @@ -0,0 +1,471 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {gcm} from '@noble/ciphers/aes.js'; + +import { + SHARE_FILE_VERSION, + SHARE_FILE_PBKDF2_ITERATIONS, + SHARE_FILE_PBKDF2_PREVIOUS_ITERATIONS, + SHARE_FILE_PBKDF2_LEGACY_ITERATIONS, + SHARE_FILE_PASSWORD_ENCODING, + SHARE_FILE_SALT_LENGTH_BYTES, + SHARE_FILE_SALT_PBKDF2_EXPANDED_BYTES, + SHARE_FILE_IV_LENGTH_BYTES, + SHARE_FILE_IV_LEGACY_LENGTH_BYTES, + getIterationsForShareVersion, + deriveSecret, + encryptPayload, + decryptPayload, + randomSaltHex, + assertShareCredentialFormat, + decryptShareCredential +} from '../src/keyset/crypto.js'; + +// ============================================================================= +// Constants +// ============================================================================= + +test('exports expected constants', () => { + assert.equal(typeof SHARE_FILE_VERSION, 'number'); + assert.equal(typeof SHARE_FILE_PBKDF2_ITERATIONS, 'number'); + assert.equal(typeof SHARE_FILE_PBKDF2_PREVIOUS_ITERATIONS, 'number'); + assert.equal(typeof SHARE_FILE_PBKDF2_LEGACY_ITERATIONS, 'number'); + assert.equal(typeof SHARE_FILE_PASSWORD_ENCODING, 'string'); + assert.equal(typeof SHARE_FILE_SALT_LENGTH_BYTES, 'number'); + assert.equal(typeof SHARE_FILE_SALT_PBKDF2_EXPANDED_BYTES, 'number'); + assert.equal(typeof SHARE_FILE_IV_LENGTH_BYTES, 'number'); + assert.equal(typeof SHARE_FILE_IV_LEGACY_LENGTH_BYTES, 'number'); +}); + +test('SHARE_FILE_VERSION is 1', () => { + assert.equal(SHARE_FILE_VERSION, 1); +}); + +test('iteration constants have correct values', () => { + assert.equal(SHARE_FILE_PBKDF2_ITERATIONS, 600_000); + assert.equal(SHARE_FILE_PBKDF2_PREVIOUS_ITERATIONS, 100_000); + assert.equal(SHARE_FILE_PBKDF2_LEGACY_ITERATIONS, 32); + assert.equal(SHARE_FILE_PASSWORD_ENCODING, 'sha256'); + assert.equal(SHARE_FILE_SALT_LENGTH_BYTES, 16); + assert.equal(SHARE_FILE_SALT_PBKDF2_EXPANDED_BYTES, 32); + assert.equal(SHARE_FILE_IV_LENGTH_BYTES, 24); + assert.equal(SHARE_FILE_IV_LEGACY_LENGTH_BYTES, 12); +}); + +// ============================================================================= +// getIterationsForShareVersion +// ============================================================================= + +test('getIterationsForShareVersion returns legacy iterations for undefined version', () => { + assert.equal(getIterationsForShareVersion(undefined), SHARE_FILE_PBKDF2_LEGACY_ITERATIONS); +}); + +test('getIterationsForShareVersion returns legacy iterations for version 0', () => { + assert.equal(getIterationsForShareVersion(0), SHARE_FILE_PBKDF2_LEGACY_ITERATIONS); +}); + +test('getIterationsForShareVersion returns current iterations for version 1+', () => { + assert.equal(getIterationsForShareVersion(1), SHARE_FILE_PBKDF2_ITERATIONS); + assert.equal(getIterationsForShareVersion(2), SHARE_FILE_PBKDF2_ITERATIONS); + assert.equal(getIterationsForShareVersion(99), SHARE_FILE_PBKDF2_ITERATIONS); +}); + +// ============================================================================= +// deriveSecret +// ============================================================================= + +test('deriveSecret produces 64-char hex string', () => { + const salt = '0'.repeat(32); // 16 bytes as hex + const result = deriveSecret('password', salt, 32, 'raw'); + assert.equal(result.length, 64); + assert.match(result, /^[0-9a-f]{64}$/); +}); + +test('deriveSecret is deterministic with same inputs', () => { + const salt = 'abcd1234abcd1234abcd1234abcd1234'; + const result1 = deriveSecret('password', salt, 32, 'raw'); + const result2 = deriveSecret('password', salt, 32, 'raw'); + assert.equal(result1, result2); +}); + +test('deriveSecret differs with different passwords', () => { + const salt = 'abcd1234abcd1234abcd1234abcd1234'; + const result1 = deriveSecret('password1', salt, 32, 'raw'); + const result2 = deriveSecret('password2', salt, 32, 'raw'); + assert.notEqual(result1, result2); +}); + +test('deriveSecret differs with different salts', () => { + const salt1 = 'abcd1234abcd1234abcd1234abcd1234'; + const salt2 = '1234abcd1234abcd1234abcd1234abcd'; + const result1 = deriveSecret('password', salt1, 32, 'raw'); + const result2 = deriveSecret('password', salt2, 32, 'raw'); + assert.notEqual(result1, result2); +}); + +test('deriveSecret differs with different iterations', () => { + const salt = 'abcd1234abcd1234abcd1234abcd1234'; + const result1 = deriveSecret('password', salt, 32, 'raw'); + const result2 = deriveSecret('password', salt, 64, 'raw'); + assert.notEqual(result1, result2); +}); + +test('deriveSecret sha256 encoding differs from raw encoding', () => { + const salt = 'abcd1234abcd1234abcd1234abcd1234'; + const resultSha256 = deriveSecret('password', salt, 32, 'sha256'); + const resultRaw = deriveSecret('password', salt, 32, 'raw'); + assert.notEqual(resultSha256, resultRaw); +}); + +// ============================================================================= +// encryptPayload +// ============================================================================= + +test('encryptPayload returns cipherText and iv', () => { + const secretHex = 'a'.repeat(64); // 32 bytes as hex + const result = encryptPayload(secretHex, 'hello world'); + assert.ok('cipherText' in result); + assert.ok('iv' in result); + assert.equal(typeof result.cipherText, 'string'); + assert.equal(typeof result.iv, 'string'); +}); + +test('encryptPayload cipherText is valid base64url', () => { + const secretHex = 'a'.repeat(64); + const result = encryptPayload(secretHex, 'hello world'); + // base64url uses A-Z, a-z, 0-9, -, _ and no padding = + assert.match(result.cipherText, /^[A-Za-z0-9_-]+$/); + // Should be decodable + const decoded = Buffer.from(result.cipherText, 'base64url'); + assert.ok(decoded.length > 0); +}); + +test('encryptPayload is deterministic with explicit IV', () => { + const secretHex = 'a'.repeat(64); + const ivHex = 'b'.repeat(48); // 24 bytes as hex + const result1 = encryptPayload(secretHex, 'hello world', ivHex); + const result2 = encryptPayload(secretHex, 'hello world', ivHex); + assert.equal(result1.cipherText, result2.cipherText); + assert.equal(result1.iv, result2.iv); +}); + +test('encryptPayload produces different output with random IV', () => { + const secretHex = 'a'.repeat(64); + const result1 = encryptPayload(secretHex, 'hello world'); + const result2 = encryptPayload(secretHex, 'hello world'); + // IVs should differ (with overwhelming probability) + assert.notEqual(result1.iv, result2.iv); + // Ciphertexts should differ due to different IVs + assert.notEqual(result1.cipherText, result2.cipherText); +}); + +// ============================================================================= +// decryptPayload +// ============================================================================= + +test('decryptPayload recovers original plaintext', () => { + const secretHex = 'a'.repeat(64); + const plaintext = 'hello world'; + const {cipherText} = encryptPayload(secretHex, plaintext); + const decrypted = decryptPayload(secretHex, cipherText); + assert.equal(decrypted, plaintext); +}); + +test('decryptPayload throws on wrong key', () => { + const secretHex1 = 'a'.repeat(64); + const secretHex2 = 'b'.repeat(64); + const {cipherText} = encryptPayload(secretHex1, 'hello world'); + assert.throws(() => { + decryptPayload(secretHex2, cipherText); + }); +}); + +test('decryptPayload throws on truncated ciphertext', () => { + const secretHex = 'a'.repeat(64); + // Ciphertext that's too short (less than IV length) + const shortCipherText = Buffer.from('short').toString('base64url'); + assert.throws(() => { + decryptPayload(secretHex, shortCipherText); + }, /Ciphertext too short/); +}); + +test('decryptPayload throws on corrupted ciphertext', () => { + const secretHex = 'a'.repeat(64); + const {cipherText} = encryptPayload(secretHex, 'hello world'); + // Corrupt the ciphertext by modifying a character + const corrupted = cipherText.slice(0, -1) + (cipherText.slice(-1) === 'a' ? 'b' : 'a'); + assert.throws(() => { + decryptPayload(secretHex, corrupted); + }); +}); + +test('decryptPayload handles 12-byte legacy IV length', () => { + const secretHex = 'a'.repeat(64); + const plaintext = 'legacy payload'; + const ivHex12 = 'c'.repeat(24); // 12 bytes as hex + + // Manually construct a legacy-style encrypted payload with 12-byte IV + const payloadBytes = new TextEncoder().encode(plaintext); + const secretBytes = Buffer.from(secretHex, 'hex'); + const ivBytes = Buffer.from(ivHex12, 'hex'); + + // Use noble/ciphers for encryption (matching the implementation) + const cipher = gcm(secretBytes, ivBytes); + const encrypted = cipher.encrypt(payloadBytes); + + // Combine IV + ciphertext + const combined = Buffer.concat([ivBytes, Buffer.from(encrypted)]); + const encoded = combined.toString('base64url'); + + // Decrypt with legacy IV length + const decrypted = decryptPayload(secretHex, encoded, 12); + assert.equal(decrypted, plaintext); +}); + +// ============================================================================= +// randomSaltHex +// ============================================================================= + +test('randomSaltHex returns 32-char hex string', () => { + const salt = randomSaltHex(); + assert.equal(salt.length, 32); + assert.match(salt, /^[0-9a-f]{32}$/); +}); + +test('randomSaltHex produces unique values', () => { + const salt1 = randomSaltHex(); + const salt2 = randomSaltHex(); + const salt3 = randomSaltHex(); + assert.notEqual(salt1, salt2); + assert.notEqual(salt2, salt3); + assert.notEqual(salt1, salt3); +}); + +// ============================================================================= +// assertShareCredentialFormat +// ============================================================================= + +test('assertShareCredentialFormat accepts bfshare prefix', () => { + // Should not throw + assertShareCredentialFormat('bfshare1abc123'); + assertShareCredentialFormat('bfshare'); +}); + +test('assertShareCredentialFormat rejects non-bfshare strings', () => { + assert.throws(() => { + assertShareCredentialFormat('nsec1abc123'); + }, /not a valid bfshare/); + + assert.throws(() => { + assertShareCredentialFormat('npub1abc123'); + }, /not a valid bfshare/); + + assert.throws(() => { + assertShareCredentialFormat(''); + }, /not a valid bfshare/); + + assert.throws(() => { + assertShareCredentialFormat('bf_share_typo'); + }, /not a valid bfshare/); +}); + +// ============================================================================= +// decryptShareCredential +// ============================================================================= + +test('decryptShareCredential decrypts v1 format with correct password', () => { + const password = 'test-password'; + const salt = randomSaltHex(); + const shareCredential = 'bfshare1testcredential'; + + // Create v1 encrypted record + const secretHex = deriveSecret(password, salt, SHARE_FILE_PBKDF2_ITERATIONS, 'sha256'); + const {cipherText} = encryptPayload(secretHex, shareCredential); + + const record = { + version: 1, + salt, + share: cipherText, + metadata: { + pbkdf2Iterations: SHARE_FILE_PBKDF2_ITERATIONS, + passwordEncoding: 'sha256' as const + } + }; + + const result = decryptShareCredential(record, password); + assert.equal(result.shareCredential, shareCredential); + assert.equal(result.iterations, SHARE_FILE_PBKDF2_ITERATIONS); + assert.equal(result.encoding, 'sha256'); +}); + +test('decryptShareCredential fails with wrong password', {timeout: 30000}, () => { + // This test is slow because decryptShareCredential tries multiple iteration + // counts (600k, 100k, 32) as fallbacks before giving up - unavoidable for + // wrong password scenarios + const password = 'correct-password'; + const salt = randomSaltHex(); + const shareCredential = 'bfshare1testcredential'; + + // Use legacy format (no version) to minimize iterations tried + const secretHex = deriveSecret(password, salt, SHARE_FILE_PBKDF2_LEGACY_ITERATIONS, 'raw'); + const {cipherText} = encryptPayload(secretHex, shareCredential); + + const record = { + version: undefined, // Legacy - only tries 32 iterations first + salt, + share: cipherText, + metadata: undefined + }; + + assert.throws(() => { + decryptShareCredential(record, 'wrong-password'); + }); +}); + +test('decryptShareCredential decrypts legacy v0 format (32 iterations, 12-byte IV)', () => { + const password = 'legacy-password'; + const salt = randomSaltHex(); + const shareCredential = 'bfshare1legacycred'; + + // Create legacy v0 encrypted record with 32 iterations, raw encoding, 12-byte IV + const secretHex = deriveSecret(password, salt, 32, 'raw'); + + // Manually encrypt with 12-byte IV + const ivHex12 = 'd'.repeat(24); // 12 bytes + const payloadBytes = new TextEncoder().encode(shareCredential); + const secretBytes = Buffer.from(secretHex, 'hex'); + const ivBytes = Buffer.from(ivHex12, 'hex'); + + const cipher = gcm(secretBytes, ivBytes); + const encrypted = cipher.encrypt(payloadBytes); + + const combined = Buffer.concat([ivBytes, Buffer.from(encrypted)]); + const cipherText = combined.toString('base64url'); + + const record = { + version: undefined, // Legacy has no version + salt, + share: cipherText, + metadata: undefined + }; + + const result = decryptShareCredential(record, password); + assert.equal(result.shareCredential, shareCredential); + assert.equal(result.iterations, 32); + assert.equal(result.encoding, 'raw'); + assert.equal(result.ivLength, 12); +}); + +test('decryptShareCredential returns used parameters', () => { + const password = 'test-password'; + const salt = randomSaltHex(); + const shareCredential = 'bfshare1testcredential'; + + const secretHex = deriveSecret(password, salt, SHARE_FILE_PBKDF2_ITERATIONS, 'sha256'); + const {cipherText} = encryptPayload(secretHex, shareCredential); + + const record = { + version: 1, + salt, + share: cipherText, + metadata: {} + }; + + const result = decryptShareCredential(record, password); + + assert.ok('shareCredential' in result); + assert.ok('secretHex' in result); + assert.ok('iterations' in result); + assert.ok('encoding' in result); + assert.ok('saltLength' in result); + assert.ok('ivLength' in result); + + assert.equal(typeof result.secretHex, 'string'); + assert.equal(result.secretHex.length, 64); + assert.equal(typeof result.iterations, 'number'); + assert.ok(result.encoding === 'sha256' || result.encoding === 'raw'); + assert.equal(typeof result.saltLength, 'number'); + assert.equal(typeof result.ivLength, 'number'); +}); + +test('decryptShareCredential handles metadata-specified iterations', () => { + const password = 'test-password'; + const salt = randomSaltHex(); + const shareCredential = 'bfshare1customiter'; + const customIterations = 50_000; + + const secretHex = deriveSecret(password, salt, customIterations, 'sha256'); + const {cipherText} = encryptPayload(secretHex, shareCredential); + + const record = { + version: 1, + salt, + share: cipherText, + metadata: { + pbkdf2Iterations: customIterations + } + }; + + const result = decryptShareCredential(record, password); + assert.equal(result.shareCredential, shareCredential); + assert.equal(result.iterations, customIterations); +}); + +test('decryptShareCredential handles metadata-specified encoding', () => { + const password = 'test-password'; + const salt = randomSaltHex(); + const shareCredential = 'bfshare1rawencoding'; + + // Use raw encoding + const secretHex = deriveSecret(password, salt, SHARE_FILE_PBKDF2_ITERATIONS, 'raw'); + const {cipherText} = encryptPayload(secretHex, shareCredential); + + const record = { + version: 1, + salt, + share: cipherText, + metadata: { + passwordEncoding: 'raw' as const + } + }; + + const result = decryptShareCredential(record, password); + assert.equal(result.shareCredential, shareCredential); + assert.equal(result.encoding, 'raw'); +}); + +// ============================================================================= +// Integration +// ============================================================================= + +test('full roundtrip: create encrypted record and decrypt', () => { + const password = 'integration-test-password'; + const shareCredential = 'bfshare1integrationtest123456789'; + + // Step 1: Generate salt + const salt = randomSaltHex(); + + // Step 2: Derive key and encrypt (simulating save) + const secretHex = deriveSecret(password, salt, SHARE_FILE_PBKDF2_ITERATIONS, 'sha256'); + const {cipherText} = encryptPayload(secretHex, shareCredential); + + // Step 3: Create record as it would be stored + const record = { + version: SHARE_FILE_VERSION, + salt, + share: cipherText, + metadata: { + pbkdf2Iterations: SHARE_FILE_PBKDF2_ITERATIONS, + passwordEncoding: 'sha256' as const + } + }; + + // Step 4: Decrypt (simulating load) + const result = decryptShareCredential(record, password); + + // Verify + assert.equal(result.shareCredential, shareCredential); + assert.equal(result.iterations, SHARE_FILE_PBKDF2_ITERATIONS); + assert.equal(result.encoding, 'sha256'); + assert.equal(result.ivLength, SHARE_FILE_IV_LENGTH_BYTES); +}); diff --git a/tests/naming.test.ts b/tests/naming.test.ts new file mode 100644 index 0000000..ee3ad44 --- /dev/null +++ b/tests/naming.test.ts @@ -0,0 +1,143 @@ +import {test, beforeEach, afterEach} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {makeTmp} from './helpers/tmp.js'; +import { + slugifyKeysetName, + buildShareId, + buildShareFilePath, + keysetNameExists +} from '../src/keyset/naming.js'; +import {getShareDirectory} from '../src/keyset/paths.js'; + +// ============================================================================= +// slugifyKeysetName +// ============================================================================= + +test('slugifyKeysetName lowercases', () => { + assert.equal(slugifyKeysetName('MyKey'), 'mykey'); + assert.equal(slugifyKeysetName('UPPERCASE'), 'uppercase'); + assert.equal(slugifyKeysetName('MixedCase'), 'mixedcase'); +}); + +test('slugifyKeysetName replaces spaces with hyphens', () => { + assert.equal(slugifyKeysetName('my key'), 'my-key'); + assert.equal(slugifyKeysetName('my key'), 'my-key'); // Multiple spaces become single hyphen + assert.equal(slugifyKeysetName('my key set'), 'my-key-set'); +}); + +test('slugifyKeysetName removes special chars', () => { + assert.equal(slugifyKeysetName('key@123!'), 'key-123'); + assert.equal(slugifyKeysetName('test#$%name'), 'test-name'); + assert.equal(slugifyKeysetName('key_with_underscores'), 'key-with-underscores'); +}); + +test('slugifyKeysetName trims leading/trailing hyphens', () => { + assert.equal(slugifyKeysetName('--key--'), 'key'); + assert.equal(slugifyKeysetName('---test---'), 'test'); + assert.equal(slugifyKeysetName('@#$key@#$'), 'key'); +}); + +test('slugifyKeysetName returns "keyset" for empty', () => { + assert.equal(slugifyKeysetName(''), 'keyset'); + assert.equal(slugifyKeysetName(' '), 'keyset'); + assert.equal(slugifyKeysetName('@#$'), 'keyset'); // All special chars = empty result +}); + +// ============================================================================= +// buildShareId +// ============================================================================= + +test('buildShareId combines slug and index', () => { + assert.equal(buildShareId('test', 1), 'test_share_1'); + assert.equal(buildShareId('vault', 2), 'vault_share_2'); + assert.equal(buildShareId('backup', 10), 'backup_share_10'); +}); + +test('buildShareId slugifies name', () => { + assert.equal(buildShareId('My Keyset', 2), 'my-keyset_share_2'); + assert.equal(buildShareId('Test@Key', 1), 'test-key_share_1'); + assert.equal(buildShareId('UPPERCASE NAME', 3), 'uppercase-name_share_3'); +}); + +// ============================================================================= +// buildShareFilePath +// ============================================================================= + +test('buildShareFilePath builds full path with directory and .json', () => { + // Set up temp directory for consistent path testing + const originalAppdata = process.env.IGLOO_APPDATA; + process.env.IGLOO_APPDATA = '/tmp/test-appdata'; + + try { + const result = buildShareFilePath('test', 1); + const expectedDir = getShareDirectory(); + const expectedId = buildShareId('test', 1); + + assert.equal(result, path.join(expectedDir, `${expectedId}.json`)); + assert.ok(result.endsWith('.json')); + assert.ok(result.includes('test_share_1')); + } finally { + // Restore original env + if (originalAppdata !== undefined) { + process.env.IGLOO_APPDATA = originalAppdata; + } else { + delete process.env.IGLOO_APPDATA; + } + } +}); + +// ============================================================================= +// keysetNameExists (requires file I/O with temp directory) +// ============================================================================= + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await makeTmp('igloo-naming-'); + process.env.IGLOO_APPDATA = tmpDir; +}); + +afterEach(async () => { + delete process.env.IGLOO_APPDATA; + await fs.rm(tmpDir, {recursive: true, force: true}); +}); + +test('keysetNameExists returns true when shares exist', async () => { + // Create the shares directory + const shareDir = getShareDirectory(); + await fs.mkdir(shareDir, {recursive: true}); + + // Create a share file that matches the keyset name pattern + const shareFile = path.join(shareDir, 'mykeyset_share_1.json'); + await fs.writeFile(shareFile, '{}', 'utf8'); + + const result = await keysetNameExists('mykeyset'); + assert.equal(result, true); + + // Also test with slugified name + const shareFile2 = path.join(shareDir, 'my-keyset_share_2.json'); + await fs.writeFile(shareFile2, '{}', 'utf8'); + + const result2 = await keysetNameExists('My Keyset'); + assert.equal(result2, true); +}); + +test('keysetNameExists returns false when no shares exist', async () => { + // Create the shares directory but no matching files + const shareDir = getShareDirectory(); + await fs.mkdir(shareDir, {recursive: true}); + + // Create an unrelated file + await fs.writeFile(path.join(shareDir, 'other_share_1.json'), '{}', 'utf8'); + + const result = await keysetNameExists('nonexistent'); + assert.equal(result, false); +}); + +test('keysetNameExists returns false when directory is missing', async () => { + // Don't create the directory - ENOENT should return false + const result = await keysetNameExists('anyname'); + assert.equal(result, false); +}); diff --git a/tests/paths.test.ts b/tests/paths.test.ts new file mode 100644 index 0000000..8fae83f --- /dev/null +++ b/tests/paths.test.ts @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import {getAppDataPath, getShareDirectory} from '../src/keyset/paths.js'; + +// ============================================================================= +// getAppDataPath +// ============================================================================= + +test('getAppDataPath respects IGLOO_APPDATA env override', () => { + const original = process.env.IGLOO_APPDATA; + try { + process.env.IGLOO_APPDATA = '/custom/override/path'; + const result = getAppDataPath(); + assert.equal(result, '/custom/override/path'); + } finally { + if (original !== undefined) { + process.env.IGLOO_APPDATA = original; + } else { + delete process.env.IGLOO_APPDATA; + } + } +}); + +test('getAppDataPath handles empty IGLOO_APPDATA (falls through to platform)', () => { + const original = process.env.IGLOO_APPDATA; + try { + process.env.IGLOO_APPDATA = ''; + const result = getAppDataPath(); + // Should NOT be empty string - should fall through to platform logic + assert.ok(result.length > 0); + assert.notEqual(result, ''); + } finally { + if (original !== undefined) { + process.env.IGLOO_APPDATA = original; + } else { + delete process.env.IGLOO_APPDATA; + } + } +}); + +test('getAppDataPath returns platform-appropriate path', () => { + const original = process.env.IGLOO_APPDATA; + try { + delete process.env.IGLOO_APPDATA; + const result = getAppDataPath(); + const platform = os.platform(); + + if (platform === 'darwin') { + // macOS: should be ~/Library/Application Support + assert.ok( + result.includes('Library/Application Support'), + `Expected macOS path to contain "Library/Application Support", got: ${result}` + ); + } else if (platform === 'win32') { + // Windows: should be APPDATA or AppData/Roaming + assert.ok( + result.includes('AppData') || result.includes('appdata'), + `Expected Windows path to contain "AppData", got: ${result}` + ); + } else { + // Linux/other: should be XDG_CONFIG_HOME or ~/.config + const xdgConfig = process.env.XDG_CONFIG_HOME; + if (xdgConfig) { + assert.equal(result, xdgConfig); + } else { + assert.ok( + result.includes('.config') || result === path.join(os.homedir(), '.config'), + `Expected Linux path to contain ".config", got: ${result}` + ); + } + } + } finally { + if (original !== undefined) { + process.env.IGLOO_APPDATA = original; + } else { + delete process.env.IGLOO_APPDATA; + } + } +}); + +test('getAppDataPath returns absolute path', () => { + const original = process.env.IGLOO_APPDATA; + try { + delete process.env.IGLOO_APPDATA; + const result = getAppDataPath(); + assert.ok(path.isAbsolute(result), `Expected absolute path, got: ${result}`); + } finally { + if (original !== undefined) { + process.env.IGLOO_APPDATA = original; + } else { + delete process.env.IGLOO_APPDATA; + } + } +}); + +// ============================================================================= +// getShareDirectory +// ============================================================================= + +test('getShareDirectory appends igloo/shares to app data path', () => { + const original = process.env.IGLOO_APPDATA; + try { + process.env.IGLOO_APPDATA = '/test/appdata'; + const result = getShareDirectory(); + assert.equal(result, path.join('/test/appdata', 'igloo', 'shares')); + } finally { + if (original !== undefined) { + process.env.IGLOO_APPDATA = original; + } else { + delete process.env.IGLOO_APPDATA; + } + } +}); + +test('getShareDirectory returns absolute path', () => { + const original = process.env.IGLOO_APPDATA; + try { + delete process.env.IGLOO_APPDATA; + const result = getShareDirectory(); + assert.ok(path.isAbsolute(result), `Expected absolute path, got: ${result}`); + assert.ok(result.endsWith(path.join('igloo', 'shares'))); + } finally { + if (original !== undefined) { + process.env.IGLOO_APPDATA = original; + } else { + delete process.env.IGLOO_APPDATA; + } + } +}); diff --git a/tests/policy.test.ts b/tests/policy.test.ts new file mode 100644 index 0000000..f035b94 --- /dev/null +++ b/tests/policy.test.ts @@ -0,0 +1,535 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DEFAULT_POLICY_DEFAULTS, + createDefaultPolicy, + ensurePolicy, + updatePolicyTimestamp, + setPolicyDefaults, + upsertPeerPolicy, + removePeerPolicy, + pruneEmptyPeers +} from '../src/keyset/policy.js'; +import type {ShareFileRecord, SharePolicy, SharePeerPolicy} from '../src/keyset/types.js'; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +function createTestPolicy(overrides: Partial = {}): SharePolicy { + return { + defaults: {allowSend: true, allowReceive: true}, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z', + ...overrides + }; +} + +function createTestRecord(policyOverrides?: Partial): ShareFileRecord { + return { + id: 'test-share', + name: 'Test Share', + share: 'encrypted-share-data', + salt: 'a'.repeat(32), + groupCredential: 'bfgroup1testcredential', + policy: policyOverrides ? createTestPolicy(policyOverrides) : undefined + }; +} + +// ============================================================================= +// DEFAULT_POLICY_DEFAULTS +// ============================================================================= + +test('DEFAULT_POLICY_DEFAULTS has allowSend true', () => { + assert.equal(DEFAULT_POLICY_DEFAULTS.allowSend, true); +}); + +test('DEFAULT_POLICY_DEFAULTS has allowReceive true', () => { + assert.equal(DEFAULT_POLICY_DEFAULTS.allowReceive, true); +}); + +// ============================================================================= +// createDefaultPolicy +// ============================================================================= + +test('createDefaultPolicy returns allowSend true', () => { + const policy = createDefaultPolicy(); + assert.equal(policy.defaults.allowSend, true); +}); + +test('createDefaultPolicy returns allowReceive true', () => { + const policy = createDefaultPolicy(); + assert.equal(policy.defaults.allowReceive, true); +}); + +test('createDefaultPolicy returns empty peers object', () => { + const policy = createDefaultPolicy(); + assert.deepEqual(policy.peers, {}); +}); + +test('createDefaultPolicy uses provided timestamp', () => { + const timestamp = '2024-06-15T12:00:00.000Z'; + const policy = createDefaultPolicy(timestamp); + assert.equal(policy.updatedAt, timestamp); +}); + +test('createDefaultPolicy uses current time when no timestamp provided', () => { + const before = new Date().toISOString(); + const policy = createDefaultPolicy(); + const after = new Date().toISOString(); + + assert.ok(policy.updatedAt >= before); + assert.ok(policy.updatedAt <= after); +}); + +// ============================================================================= +// coerceBoolean (tested via ensurePolicy) +// ============================================================================= + +test('coerceBoolean handles true boolean', () => { + const record = createTestRecord({ + defaults: {allowSend: true, allowReceive: true} + }); + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, true); + assert.equal(typeof result.defaults.allowSend, 'boolean'); +}); + +test('coerceBoolean handles false boolean', () => { + const record = createTestRecord({ + defaults: {allowSend: false, allowReceive: false} + }); + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, false); + assert.equal(result.defaults.allowReceive, false); +}); + +test('coerceBoolean coerces "true" string to true', () => { + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: { + allowSend: 'true' as unknown as boolean, + allowReceive: true + }, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, true); + assert.equal(typeof result.defaults.allowSend, 'boolean'); +}); + +test('coerceBoolean coerces "false" string to false', () => { + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: { + allowSend: 'false' as unknown as boolean, + allowReceive: 'false' as unknown as boolean + }, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, false); + assert.equal(result.defaults.allowReceive, false); +}); + +test('coerceBoolean coerces "1" and "0" strings', () => { + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: { + allowSend: '1' as unknown as boolean, + allowReceive: '0' as unknown as boolean + }, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, true); + assert.equal(result.defaults.allowReceive, false); +}); + +test('coerceBoolean coerces "yes"/"no" strings', () => { + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: { + allowSend: 'yes' as unknown as boolean, + allowReceive: 'no' as unknown as boolean + }, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, true); + assert.equal(result.defaults.allowReceive, false); +}); + +test('coerceBoolean coerces "on"/"off" strings', () => { + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: { + allowSend: 'on' as unknown as boolean, + allowReceive: 'off' as unknown as boolean + }, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, true); + assert.equal(result.defaults.allowReceive, false); +}); + +test('coerceBoolean is case insensitive', () => { + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: { + allowSend: 'TRUE' as unknown as boolean, + allowReceive: 'FALSE' as unknown as boolean + }, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, true); + assert.equal(result.defaults.allowReceive, false); + + // Also test mixed case + const record2: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: { + allowSend: 'Yes' as unknown as boolean, + allowReceive: 'No' as unknown as boolean + }, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result2 = ensurePolicy(record2); + assert.equal(result2.defaults.allowSend, true); + assert.equal(result2.defaults.allowReceive, false); +}); + +test('coerceBoolean returns fallback for invalid values', () => { + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: { + allowSend: 'invalid' as unknown as boolean, + allowReceive: 'maybe' as unknown as boolean + }, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + // Fallback is true for both + assert.equal(result.defaults.allowSend, true); + assert.equal(result.defaults.allowReceive, true); +}); + +// ============================================================================= +// ensurePolicy +// ============================================================================= + +test('ensurePolicy creates default for undefined policy', () => { + const record: ShareFileRecord = { + id: 'test', + name: 'Test', + share: 'encrypted', + salt: 'a'.repeat(32), + groupCredential: 'bfgroup1...', + policy: undefined + }; + const result = ensurePolicy(record); + assert.equal(result.defaults.allowSend, true); + assert.equal(result.defaults.allowReceive, true); + assert.deepEqual(result.peers, {}); +}); + +test('ensurePolicy normalizes peer pubkeys', () => { + // Use a pubkey with 02 prefix which normalizePubkey should strip + const prefixedPubkey = '02abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'; + const expectedNormalized = 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'; + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: {allowSend: true, allowReceive: true}, + peers: { + [prefixedPubkey]: {allowSend: false, allowReceive: true} + }, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + // Key should be normalized (02 prefix stripped) + const peerKeys = Object.keys(result.peers!); + assert.equal(peerKeys.length, 1); + assert.equal(peerKeys[0], expectedNormalized); + // Verify the policy values are preserved + assert.equal(result.peers![expectedNormalized].allowSend, false); + assert.equal(result.peers![expectedNormalized].allowReceive, true); +}); + +test('ensurePolicy preserves valid peers', () => { + const pubkey = 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234'; + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: {allowSend: true, allowReceive: true}, + peers: { + [pubkey]: {allowSend: false, allowReceive: false} + }, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + assert.ok(pubkey in result.peers!); + assert.equal(result.peers![pubkey].allowSend, false); + assert.equal(result.peers![pubkey].allowReceive, false); +}); + +test('ensurePolicy skips empty string pubkey keys', () => { + const record: ShareFileRecord = { + ...createTestRecord(), + policy: { + defaults: {allowSend: true, allowReceive: true}, + peers: { + '': {allowSend: false, allowReceive: false}, + 'validpubkey': {allowSend: false, allowReceive: true} + }, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }; + const result = ensurePolicy(record); + assert.ok(!('' in result.peers!)); + assert.ok('validpubkey' in result.peers!); +}); + +test('ensurePolicy uses savedAt as timestamp fallback', () => { + const savedAt = '2024-03-15T10:30:00.000Z'; + const record: ShareFileRecord = { + id: 'test', + name: 'Test', + share: 'encrypted', + salt: 'a'.repeat(32), + groupCredential: 'bfgroup1...', + savedAt, + policy: undefined + }; + const result = ensurePolicy(record); + assert.equal(result.updatedAt, savedAt); +}); + +// ============================================================================= +// setPolicyDefaults +// ============================================================================= + +test('setPolicyDefaults updates defaults', () => { + const policy = createTestPolicy({ + defaults: {allowSend: true, allowReceive: true} + }); + const result = setPolicyDefaults(policy, {allowSend: false, allowReceive: false}); + assert.equal(result.defaults.allowSend, false); + assert.equal(result.defaults.allowReceive, false); +}); + +test('setPolicyDefaults re-normalizes existing peers', () => { + const pubkey = 'testpeer'; + const policy: SharePolicy = { + defaults: {allowSend: true, allowReceive: true}, + peers: { + [pubkey]: { + allowSend: 'yes' as unknown as boolean, + allowReceive: 'no' as unknown as boolean + } + }, + updatedAt: '2024-01-01T00:00:00.000Z' + }; + const result = setPolicyDefaults(policy, {allowSend: false, allowReceive: false}); + // Peer values should be coerced to proper booleans + assert.equal(result.peers![pubkey].allowSend, true); + assert.equal(result.peers![pubkey].allowReceive, false); +}); + +test('setPolicyDefaults updates timestamp', () => { + const policy = createTestPolicy(); + const newTimestamp = '2024-06-15T12:00:00.000Z'; + const result = setPolicyDefaults(policy, {allowSend: true, allowReceive: true}, newTimestamp); + assert.equal(result.updatedAt, newTimestamp); +}); + +// ============================================================================= +// upsertPeerPolicy +// ============================================================================= + +test('upsertPeerPolicy adds new peer', () => { + const policy = createTestPolicy(); + const pubkey = 'newpeer1234'; + const result = upsertPeerPolicy(policy, pubkey, {allowSend: false, allowReceive: true}); + assert.ok(pubkey in result.peers!); + assert.equal(result.peers![pubkey].allowSend, false); + assert.equal(result.peers![pubkey].allowReceive, true); +}); + +test('upsertPeerPolicy updates existing peer', () => { + const pubkey = 'existingpeer'; + const policy: SharePolicy = { + defaults: {allowSend: true, allowReceive: true}, + peers: { + [pubkey]: {allowSend: false, allowReceive: false} + }, + updatedAt: '2024-01-01T00:00:00.000Z' + }; + const result = upsertPeerPolicy(policy, pubkey, {allowSend: true, allowReceive: false}); + // Since allowSend matches default (true) but allowReceive doesn't (false vs true), + // the peer entry should still exist + assert.ok(pubkey in result.peers!); + assert.equal(result.peers![pubkey].allowReceive, false); +}); + +test('upsertPeerPolicy removes peer when matching defaults', () => { + const pubkey = 'removablepeer'; + const policy: SharePolicy = { + defaults: {allowSend: true, allowReceive: true}, + peers: { + [pubkey]: {allowSend: false, allowReceive: false} + }, + updatedAt: '2024-01-01T00:00:00.000Z' + }; + // Update peer to match defaults - should be removed + const result = upsertPeerPolicy(policy, pubkey, {allowSend: true, allowReceive: true}); + assert.ok(!(pubkey in result.peers!)); +}); + +test('upsertPeerPolicy handles non-standard pubkey formats', () => { + const policy = createTestPolicy(); + const nonStandardPubkey = 'INVALID_NOT_HEX!@#'; + // Use policy that differs from defaults so peer is stored + const result = upsertPeerPolicy(policy, nonStandardPubkey, {allowSend: false, allowReceive: false}); + // normalizePubkey may return the input unchanged for invalid formats + // The peer should be stored under some key + const peerKeys = Object.keys(result.peers!); + assert.equal(peerKeys.length, 1); + // Verify the policy values are correct regardless of key normalization + const storedKey = peerKeys[0]; + assert.equal(result.peers![storedKey].allowSend, false); + assert.equal(result.peers![storedKey].allowReceive, false); +}); + +// ============================================================================= +// removePeerPolicy +// ============================================================================= + +test('removePeerPolicy deletes peer', () => { + const pubkey = 'peertodelete'; + const policy: SharePolicy = { + defaults: {allowSend: true, allowReceive: true}, + peers: { + [pubkey]: {allowSend: false, allowReceive: false} + }, + updatedAt: '2024-01-01T00:00:00.000Z' + }; + const result = removePeerPolicy(policy, pubkey); + assert.ok(!(pubkey in result.peers!)); +}); + +test('removePeerPolicy is no-op for missing peer', () => { + const policy = createTestPolicy(); + // Should not throw + const result = removePeerPolicy(policy, 'nonexistent'); + assert.deepEqual(result.peers, {}); +}); + +test('removePeerPolicy updates timestamp', () => { + const policy = createTestPolicy(); + const newTimestamp = '2024-06-15T12:00:00.000Z'; + const result = removePeerPolicy(policy, 'anypeer', newTimestamp); + assert.equal(result.updatedAt, newTimestamp); +}); + +// ============================================================================= +// updatePolicyTimestamp +// ============================================================================= + +test('updatePolicyTimestamp changes updatedAt', () => { + const policy = createTestPolicy({updatedAt: '2024-01-01T00:00:00.000Z'}); + const newTimestamp = '2024-12-25T00:00:00.000Z'; + const result = updatePolicyTimestamp(policy, newTimestamp); + assert.equal(result.updatedAt, newTimestamp); + // Original should be unchanged + assert.equal(policy.updatedAt, '2024-01-01T00:00:00.000Z'); +}); + +// ============================================================================= +// pruneEmptyPeers +// ============================================================================= + +test('pruneEmptyPeers removes peers key when empty', () => { + const policy: SharePolicy = { + defaults: {allowSend: true, allowReceive: true}, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + }; + const result = pruneEmptyPeers(policy); + assert.ok(!('peers' in result)); +}); + +test('pruneEmptyPeers preserves non-empty peers', () => { + const policy: SharePolicy = { + defaults: {allowSend: true, allowReceive: true}, + peers: { + somepeer: {allowSend: false, allowReceive: true} + }, + updatedAt: '2024-01-01T00:00:00.000Z' + }; + const result = pruneEmptyPeers(policy); + assert.ok('peers' in result); + assert.ok('somepeer' in result.peers!); +}); + +// ============================================================================= +// Immutability +// ============================================================================= + +test('policy functions do not mutate input objects', () => { + const originalPolicy: SharePolicy = { + defaults: {allowSend: true, allowReceive: true}, + peers: { + existingpeer: {allowSend: false, allowReceive: false} + }, + updatedAt: '2024-01-01T00:00:00.000Z' + }; + + // Deep clone for comparison + const originalSnapshot = JSON.stringify(originalPolicy); + + // Test each function + updatePolicyTimestamp(originalPolicy, '2024-12-25T00:00:00.000Z'); + assert.equal(JSON.stringify(originalPolicy), originalSnapshot); + + setPolicyDefaults(originalPolicy, {allowSend: false, allowReceive: false}); + assert.equal(JSON.stringify(originalPolicy), originalSnapshot); + + upsertPeerPolicy(originalPolicy, 'newpeer', {allowSend: false, allowReceive: true}); + assert.equal(JSON.stringify(originalPolicy), originalSnapshot); + + removePeerPolicy(originalPolicy, 'existingpeer'); + assert.equal(JSON.stringify(originalPolicy), originalSnapshot); + + pruneEmptyPeers(originalPolicy); + assert.equal(JSON.stringify(originalPolicy), originalSnapshot); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..489fb27 --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,355 @@ +import {test, beforeEach, afterEach} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {makeTmp} from './helpers/tmp.js'; + +import { + ensureShareDirectory, + readShareFiles, + saveShareRecord, + loadShareRecord +} from '../src/keyset/storage.js'; +import {getShareDirectory} from '../src/keyset/paths.js'; +import {SHARE_FILE_VERSION} from '../src/keyset/crypto.js'; +import type {ShareFileRecord} from '../src/keyset/types.js'; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +function createTestRecord(overrides: Partial = {}): ShareFileRecord { + return { + id: 'test-share-1', + name: 'Test Keyset share 1', + share: 'encrypted-share-data-base64url', + salt: 'a'.repeat(32), + groupCredential: 'bfgroup1testcredential', + ...overrides + }; +} + +// ============================================================================= +// Test Setup / Teardown +// ============================================================================= + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await makeTmp('igloo-storage-'); + process.env.IGLOO_APPDATA = tmpDir; +}); + +afterEach(async () => { + delete process.env.IGLOO_APPDATA; + await fs.rm(tmpDir, {recursive: true, force: true}); +}); + +// ============================================================================= +// ensureShareDirectory +// ============================================================================= + +test('ensureShareDirectory creates directory when missing', async () => { + const dir = await ensureShareDirectory(); + const stat = await fs.stat(dir); + assert.ok(stat.isDirectory()); +}); + +test('ensureShareDirectory respects directory override', async () => { + const customDir = path.join(tmpDir, 'custom-shares'); + const result = await ensureShareDirectory(customDir); + assert.equal(result, customDir); + + const stat = await fs.stat(customDir); + assert.ok(stat.isDirectory()); + + // Default share directory should NOT be created + const defaultDir = getShareDirectory(); + await assert.rejects( + fs.stat(defaultDir), + {code: 'ENOENT'} + ); +}); + +test('ensureShareDirectory is idempotent', async () => { + const dir1 = await ensureShareDirectory(); + const dir2 = await ensureShareDirectory(); + assert.equal(dir1, dir2); + + const stat = await fs.stat(dir1); + assert.ok(stat.isDirectory()); +}); + +test('ensureShareDirectory returns correct path', async () => { + const result = await ensureShareDirectory(); + const expected = getShareDirectory(); + assert.equal(result, expected); +}); + +// ============================================================================= +// readShareFiles +// ============================================================================= + +test('readShareFiles returns empty array when directory missing', async () => { + // Don't create the directory - just read + const result = await readShareFiles(); + assert.deepEqual(result, []); +}); + +test('readShareFiles returns empty array for empty directory', async () => { + await ensureShareDirectory(); + const result = await readShareFiles(); + assert.deepEqual(result, []); +}); + +test('readShareFiles parses valid JSON share files', async () => { + const dir = await ensureShareDirectory(); + const record = createTestRecord({id: 'valid-share'}); + const filepath = path.join(dir, 'valid-share.json'); + await fs.writeFile(filepath, JSON.stringify(record), 'utf8'); + + const result = await readShareFiles(); + assert.equal(result.length, 1); + assert.equal(result[0].id, 'valid-share'); + assert.equal(result[0].name, record.name); + assert.equal(result[0].share, record.share); +}); + +test('readShareFiles ignores non-JSON files', async () => { + const dir = await ensureShareDirectory(); + + // Create a valid JSON share file + const record = createTestRecord({id: 'json-share'}); + await fs.writeFile(path.join(dir, 'json-share.json'), JSON.stringify(record), 'utf8'); + + // Create non-JSON files + await fs.writeFile(path.join(dir, 'readme.txt'), 'This is not JSON', 'utf8'); + await fs.writeFile(path.join(dir, 'backup.bak'), 'backup data', 'utf8'); + await fs.writeFile(path.join(dir, '.hidden'), 'hidden file', 'utf8'); + + const result = await readShareFiles(); + assert.equal(result.length, 1); + assert.equal(result[0].id, 'json-share'); +}); + +test('readShareFiles skips malformed JSON silently', async () => { + const dir = await ensureShareDirectory(); + + // Create a valid share file + const goodRecord = createTestRecord({id: 'good-share'}); + await fs.writeFile(path.join(dir, 'good-share.json'), JSON.stringify(goodRecord), 'utf8'); + + // Create a malformed JSON file + await fs.writeFile(path.join(dir, 'bad-share.json'), '{broken json', 'utf8'); + + // Create another valid share file + const anotherRecord = createTestRecord({id: 'another-share'}); + await fs.writeFile(path.join(dir, 'another-share.json'), JSON.stringify(anotherRecord), 'utf8'); + + const result = await readShareFiles(); + // Should have 2 valid shares, malformed one is silently skipped + assert.equal(result.length, 2); + const ids = result.map(r => r.id).sort(); + assert.deepEqual(ids, ['another-share', 'good-share']); +}); + +test('readShareFiles attaches filepath to each entry', async () => { + const dir = await ensureShareDirectory(); + const record = createTestRecord({id: 'filepath-test'}); + const expectedPath = path.join(dir, 'filepath-test.json'); + await fs.writeFile(expectedPath, JSON.stringify(record), 'utf8'); + + const result = await readShareFiles(); + assert.equal(result.length, 1); + assert.equal(result[0].filepath, expectedPath); +}); + +test('readShareFiles ensures policy on loaded data', async () => { + const dir = await ensureShareDirectory(); + // Create a record WITHOUT a policy field + const record = createTestRecord({id: 'no-policy'}); + delete (record as any).policy; + await fs.writeFile(path.join(dir, 'no-policy.json'), JSON.stringify(record), 'utf8'); + + const result = await readShareFiles(); + assert.equal(result.length, 1); + // Policy should be ensured with defaults + assert.ok(result[0].policy); + assert.equal(result[0].policy.defaults.allowSend, true); + assert.equal(result[0].policy.defaults.allowReceive, true); +}); + +// ============================================================================= +// saveShareRecord +// ============================================================================= + +test('saveShareRecord creates JSON file in share directory', async () => { + const record = createTestRecord({id: 'save-test'}); + const filepath = await saveShareRecord(record); + + assert.ok(filepath.endsWith('save-test.json')); + const stat = await fs.stat(filepath); + assert.ok(stat.isFile()); +}); + +test('saveShareRecord uses record.id as filename', async () => { + const record1 = createTestRecord({id: 'share-alpha'}); + const record2 = createTestRecord({id: 'share-beta'}); + + const filepath1 = await saveShareRecord(record1); + const filepath2 = await saveShareRecord(record2); + + assert.ok(filepath1.endsWith('share-alpha.json')); + assert.ok(filepath2.endsWith('share-beta.json')); +}); + +test('saveShareRecord adds version if missing', async () => { + const record = createTestRecord({id: 'no-version'}); + delete (record as any).version; + + await saveShareRecord(record); + + const dir = getShareDirectory(); + const raw = await fs.readFile(path.join(dir, 'no-version.json'), 'utf8'); + const saved = JSON.parse(raw); + assert.equal(saved.version, SHARE_FILE_VERSION); +}); + +test('saveShareRecord adds savedAt timestamp if missing', async () => { + const record = createTestRecord({id: 'no-timestamp'}); + delete (record as any).savedAt; + + const before = new Date().toISOString(); + await saveShareRecord(record); + const after = new Date().toISOString(); + + const dir = getShareDirectory(); + const raw = await fs.readFile(path.join(dir, 'no-timestamp.json'), 'utf8'); + const saved = JSON.parse(raw); + + assert.ok(saved.savedAt >= before); + assert.ok(saved.savedAt <= after); +}); + +test('saveShareRecord prunes empty peers from policy', async () => { + const record = createTestRecord({ + id: 'empty-peers', + policy: { + defaults: {allowSend: true, allowReceive: true}, + peers: {}, + updatedAt: '2024-01-01T00:00:00.000Z' + } + }); + + await saveShareRecord(record); + + const dir = getShareDirectory(); + const raw = await fs.readFile(path.join(dir, 'empty-peers.json'), 'utf8'); + const saved = JSON.parse(raw); + + // Empty peers should be pruned + assert.ok(!('peers' in saved.policy) || Object.keys(saved.policy.peers).length === 0); +}); + +test('saveShareRecord respects directory override', async () => { + const customDir = path.join(tmpDir, 'custom-save-dir'); + const record = createTestRecord({id: 'custom-dir-share'}); + + const filepath = await saveShareRecord(record, {directory: customDir}); + + assert.ok(filepath.startsWith(customDir)); + assert.ok(filepath.endsWith('custom-dir-share.json')); + + const stat = await fs.stat(filepath); + assert.ok(stat.isFile()); +}); + +// ============================================================================= +// loadShareRecord +// ============================================================================= + +test('loadShareRecord loads by id', async () => { + const record = createTestRecord({id: 'load-by-id'}); + await saveShareRecord(record); + + const loaded = await loadShareRecord({id: 'load-by-id'}); + assert.ok(loaded); + assert.equal(loaded.id, 'load-by-id'); + assert.equal(loaded.name, record.name); + assert.equal(loaded.share, record.share); +}); + +test('loadShareRecord loads by filepath', async () => { + const record = createTestRecord({id: 'load-by-path'}); + const savedPath = await saveShareRecord(record); + + const loaded = await loadShareRecord({filepath: savedPath}); + assert.ok(loaded); + assert.equal(loaded.id, 'load-by-path'); + assert.equal(loaded.filepath, savedPath); +}); + +test('loadShareRecord returns undefined for missing id', async () => { + const result = await loadShareRecord({id: 'nonexistent-id'}); + assert.equal(result, undefined); +}); + +test('loadShareRecord returns undefined for missing filepath', async () => { + const result = await loadShareRecord({filepath: '/nonexistent/path/share.json'}); + assert.equal(result, undefined); +}); + +test('loadShareRecord ensures policy on loaded data', async () => { + // Manually write a file without policy + const dir = await ensureShareDirectory(); + const record = createTestRecord({id: 'no-policy-load'}); + delete (record as any).policy; + const filepath = path.join(dir, 'no-policy-load.json'); + await fs.writeFile(filepath, JSON.stringify(record), 'utf8'); + + const loaded = await loadShareRecord({id: 'no-policy-load'}); + assert.ok(loaded); + assert.ok(loaded.policy); + assert.equal(loaded.policy.defaults.allowSend, true); + assert.equal(loaded.policy.defaults.allowReceive, true); +}); + +// ============================================================================= +// Integration: Roundtrip +// ============================================================================= + +test('roundtrip: save then load returns equivalent data', async () => { + const original = createTestRecord({ + id: 'roundtrip-test', + name: 'Roundtrip Test Share', + share: 'encrypted-data-for-roundtrip', + salt: 'b'.repeat(32), + groupCredential: 'bfgroup1roundtrip', + keysetName: 'TestKeyset', + index: 2, + policy: { + defaults: {allowSend: false, allowReceive: true}, + peers: { + somepeer: {allowSend: true, allowReceive: false} + }, + updatedAt: '2024-06-15T12:00:00.000Z' + } + }); + + const savedPath = await saveShareRecord(original); + const loaded = await loadShareRecord({filepath: savedPath}); + + assert.ok(loaded); + assert.equal(loaded.id, original.id); + assert.equal(loaded.name, original.name); + assert.equal(loaded.share, original.share); + assert.equal(loaded.salt, original.salt); + assert.equal(loaded.groupCredential, original.groupCredential); + assert.equal(loaded.keysetName, original.keysetName); + assert.equal(loaded.index, original.index); + + // Policy should be normalized but values preserved + assert.equal(loaded.policy.defaults.allowSend, false); + assert.equal(loaded.policy.defaults.allowReceive, true); + assert.ok('somepeer' in loaded.policy.peers!); +});