From dce30d669a9e9c92859fb364f4cab71dcc08a346 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:15:17 -0400 Subject: [PATCH] feat: rebuild interactive mode as Python's legacy TUI menus The list-and-arrow-keys interface I wrote was not the thing to build. What `seedpass` opens is a numbered menu tree, and navigating it is typing a number and pressing Enter, with a blank line to go back -- so an interface driven by arrow keys and single-letter bindings was unnavigable to anyone who already knows SeedPass, however well it worked on its own terms. This follows src/main.py item for item: the same eight main-menu entries in the same order, the same nine entry types under Add Entry, the same eighteen settings under the same numbers, the Profiles and Nostr submenus, the entry action and edit menus with Python's letter keys. Numbering is part of the interface, not decoration -- 7 then 1 has to still be Settings then Profiles -- so items this build cannot perform stay in place and say why when selected, rather than vanishing and shifting everything below them. Line-based means no raw mode, no alternate screen, and no terminal to restore, which removes a whole class of failure the previous version had to defend against. Raw mode is now entered only around a password prompt, where echo must be off. The agent-blind rule survives the change: listings and details show metadata with stored secrets as has_* flags, and a secret appears only through Show Secret or Copy. Secret Mode -- a setting Python already has -- routes both to the clipboard instead of the screen, so it means the same thing in both implementations. The key/value field, hidden custom fields, and seed phrases are read without echo. Three settings needed new profile operations: changePassword re-wraps only parent_seed.enc, since the index is keyed by the seed rather than the password and a half-applied change would leave the two disagreeing; writeParentSeed and setProfileName back the profile menu. Verified through a pty against a real vault -- every menu, adding a password entry, editing a label, revealing a secret -- and the derived values match the CLI. 28 menu tests drive the tree through a scripted UI, including that listings never contain a stored secret and that a non-numeric length is refused rather than stored as NaN. 128 CLI tests, 220 core tests, 36/36 cross-impl checks. test/nostr.test.ts is flaky under parallel load (it uses a live relay); it failed once during this work and passed on rerun and three times in isolation. Co-Authored-By: Claude Fable 5 --- docs/typescript_migration_guide.md | 35 +- js/packages/cli/src/appDir.ts | 54 + js/packages/cli/src/capabilities.ts | 7 +- js/packages/cli/src/program.ts | 3 +- js/packages/cli/src/tui/app.ts | 943 +-------------- js/packages/cli/src/tui/console.ts | 160 +++ js/packages/cli/src/tui/menus.ts | 1467 +++++++++++++++++++++++ js/packages/cli/src/tui/terminal.ts | 274 ----- js/packages/cli/test/tui.test.ts | 91 -- js/packages/cli/test/tuiMenus.test.ts | 391 ++++++ js/packages/cli/test/tuiScreens.test.ts | 331 ----- 11 files changed, 2159 insertions(+), 1597 deletions(-) create mode 100644 js/packages/cli/src/tui/console.ts create mode 100644 js/packages/cli/src/tui/menus.ts delete mode 100644 js/packages/cli/src/tui/terminal.ts delete mode 100644 js/packages/cli/test/tui.test.ts create mode 100644 js/packages/cli/test/tuiMenus.test.ts delete mode 100644 js/packages/cli/test/tuiScreens.test.ts diff --git a/docs/typescript_migration_guide.md b/docs/typescript_migration_guide.md index 6ad4a3e9..32eab888 100644 --- a/docs/typescript_migration_guide.md +++ b/docs/typescript_migration_guide.md @@ -97,11 +97,12 @@ so this only applies if you jump backwards several releases. generation is not reproducible, so the TypeScript port refuses rather than deriving a different key. Keep the Python implementation for RSA entries. - The `semantic` (vector search) and `api` (FastAPI server) command groups. -- Most of Python's TUI. `seedpass-js` with no subcommand opens an interactive - mode, as `seedpass` does, but it covers the daily path only: browse, - search, copy, reveal, add a password/TOTP/key-value entry, archive, and - switch profile. Python's v2 and v3 TUIs have screens this does not — the - command palette, the inspector, the bulk operations. +- Python's v2 and v3 TUIs. Interactive mode follows the **legacy (v1)** menu + tree instead, which is the one `seedpass --legacy-tui` opens. +- Two legacy-TUI items are stubs that say so when selected: Semantic Index + (not part of this build) and QR code display (no QR encoder here; it offers + the underlying value instead). Script checksum verification covers the + Python source tree and is replaced by the bundle's `.sha256`. If you rely on any of those, run both: they operate on the same profile. @@ -112,16 +113,24 @@ seedpass-js # opens the vault; asks for your master password ``` It unlocks the same way the CLI does: `SEEDPASS_MNEMONIC` if set, then the -session agent, then a password prompt. Press `?` for the keys. +session agent, then a password prompt. -Two things behave deliberately: +The menus mirror Python's legacy TUI item for item, in the same order and +under the same numbers, so the sequences you already know still work — `7` +then `1` is still Settings then Profiles. A blank line goes back; a blank +line at the main menu exits. -- Nothing secret is drawn until you ask. The list and detail views show - metadata; `c` copies to the clipboard without displaying anything, and `r` - is the one explicit reveal, on screen only until the next keypress. -- The whole session runs on the terminal's alternate screen, so quitting - takes it with it. A revealed secret cannot be recovered by scrolling back, - and it never reaches a terminal log that captures the main buffer. +``` + 1. Add Entry 5. Modify an Existing Entry + 2. Retrieve Entry 6. 2FA Codes + 3. Search Entries 7. Settings + 4. List Entries 8. List Archived +``` + +Entry details list metadata only, with stored secrets shown as `has_*` +flags. `S` shows a secret and `C` copies it — the two actions whose purpose +is to produce one. Turning on **Secret Mode** (Settings 15) sends both to the +clipboard instead of the screen, as it does in Python. Piped or redirected, the bare command prints the help instead — there is no terminal to drive. diff --git a/js/packages/cli/src/appDir.ts b/js/packages/cli/src/appDir.ts index 7e7007b8..b93fd2da 100644 --- a/js/packages/cli/src/appDir.ts +++ b/js/packages/cli/src/appDir.ts @@ -259,4 +259,58 @@ export class AppDir { } throw new Error(`could not decrypt parent seed (wrong password?): ${String(lastError)}`); } + + /** + * Re-encrypt a profile's parent seed under a new master password. + * + * Only `parent_seed.enc` is touched: the vault index is keyed by the seed, + * not the password, so nothing else needs rewriting — and nothing else + * *may* be, since a half-applied change would leave a profile whose seed + * and index disagree. + */ + async changePassword( + fingerprint: string, + oldPassword: string, + newPassword: string, + iterations = DEFAULT_PBKDF2_ITERATIONS, + ): Promise { + if (!newPassword) throw new Error("a master password is required"); + // Proves the old password before anything is written. + const mnemonic = await this.decryptParentSeed(fingerprint, oldPassword); + await this.writeParentSeed(fingerprint, mnemonic, newPassword, iterations); + } + + /** Write parent_seed.enc for `fingerprint`, in Python's kdf/ct wrapper. */ + async writeParentSeed( + fingerprint: string, + mnemonic: string, + password: string, + iterations = DEFAULT_PBKDF2_ITERATIONS, + ): Promise { + const salt = seedKdfSalt(fingerprint); + const seedKey = base64url.decode(deriveKeyFromPassword(password, salt, iterations)); + const ct = await encryptV3(seedKey, utf8(mnemonic)); + const kdf: KdfConfig = { + name: "pbkdf2", + version: 1, + params: { iterations }, + salt_b64: Buffer.from(salt).toString("base64"), + }; + await atomicWrite( + join(this.profileDir(fingerprint), PARENT_SEED_FILENAME), + utf8(JSON.stringify({ kdf, ct: Buffer.from(ct).toString("base64") })), + ); + } + + /** Set or clear a profile's display name. */ + async setProfileName(fingerprint: string, name: string): Promise { + assertValidFingerprint(fingerprint); + await this.mutateFingerprints((data) => { + if (!data.fingerprints.includes(fingerprint)) { + throw new Error(`no profile ${fingerprint}`); + } + if (name) data.names[fingerprint] = name; + else delete data.names[fingerprint]; + }); + } } diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index e0eba5a2..2469dd79 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -17,8 +17,8 @@ export function capabilities(): Record { }, interactive: { command: "seedpass-js (no subcommand, on a terminal)", - covers: ["browse", "search", "copy", "reveal", "add", "archive", "switch profile"], - secrets: "metadata only until 'c' (copy) or 'r' (reveal); alternate screen, no scrollback", + model: "numbered menus mirroring Python's legacy (v1) TUI; blank input goes back", + secrets: "metadata only; 'S' shows and 'C' copies, and Secret Mode routes both to the clipboard", non_tty: "prints help", }, agent_surface: { @@ -76,7 +76,8 @@ export function capabilities(): Record { "index0/atlas", "approval gates and high-risk partitions", "semantic (vector search) and api (FastAPI server) command groups", - "most of Python's TUI (interactive mode covers the daily path only)", + "Python's v2/v3 TUIs (interactive mode follows the legacy v1 menus)", + "QR code display in the TUI (no QR encoder in this build)", ], }; } diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index edd86f58..4830b3c7 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -155,7 +155,6 @@ import { loadConfig, saveConfig, mutateConfig } from "./configFile.js"; import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js"; import { AuditLog } from "./audit.js"; import { runTui } from "./tui/app.js"; -import { Terminal } from "./tui/terminal.js"; export interface ProgramIo { out(line: string): void; @@ -401,7 +400,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { // remains the sensible answer. program.action(async () => { const opts = program.opts() as GlobalOpts; - if (!Terminal.isInteractive()) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { program.outputHelp(); process.exitCode = 1; return; diff --git a/js/packages/cli/src/tui/app.ts b/js/packages/cli/src/tui/app.ts index 4e5d91e7..0f6689da 100644 --- a/js/packages/cli/src/tui/app.ts +++ b/js/packages/cli/src/tui/app.ts @@ -1,87 +1,37 @@ /** - * The interactive mode: `seedpass-js` with no subcommand. + * Interactive mode: `seedpass-js` with no subcommand. * - * Parity note: Python's `seedpass` launches a TUI when invoked bare, and this - * exists because the port did not — a new user typing the bare command got a - * help dump and no way in. + * Python's `seedpass` launches its TUI when invoked bare; this is the port of + * that experience, following the legacy (v1) menu tree in `src/main.py` — + * numbered menus, blank line to go back, the same items in the same order. * - * The agent-blind rule from plan section 9.3 holds here exactly as it does in - * the CLI: the list and detail views render metadata only, `c` copies to the - * clipboard without ever displaying the value, and `r` is the one explicit - * plaintext egress — it requires a keypress, says what it is about to do, and - * the value is erased from the screen when dismissed. Combined with the - * alternate screen buffer (see terminal.ts) a revealed secret cannot be - * recovered from scrollback after quitting. + * The agent-blind rule from plan section 9.3 still applies: listings show + * metadata with stored secrets reduced to `has_*` flags, and a secret appears + * only through an action whose purpose is to produce it. Secret Mode (a + * setting Python also has) routes those to the clipboard instead. */ import process from "node:process"; import { join } from "node:path"; -import { - archiveEntry, - restoreEntry, - addPasswordEntry, - addTotpDeterministic, - addKeyValueEntry, - assertValidMnemonic, - isValidMnemonic, - type Entry, -} from "@seedpass/core"; +import { assertValidMnemonic } from "@seedpass/core"; import { AppDir, resolveAppDir, INDEX_FILENAME } from "../appDir.js"; -import { openVault, saveVaultHoldingLock, withVaultLock, type OpenedVault } from "../vaultFile.js"; -import { entryMetadata, refFor } from "../refs.js"; -import { materializeSecret } from "../secrets.js"; -import { clipboardSink } from "../sinks.js"; +import { openVault } from "../vaultFile.js"; +import { loadConfig } from "../configFile.js"; import { AgentClient, agentSocketPath } from "../agent.js"; -import { Terminal, ansi, visibleLength, type Key } from "./terminal.js"; - -type Screen = "list" | "detail" | "reveal" | "add" | "help" | "profiles"; - -interface Row { - id: string; - label: string; - kind: string; - archived: boolean; - meta: Record; -} - -const ADD_KINDS = ["password", "totp", "key-value"] as const; -type AddKind = (typeof ADD_KINDS)[number]; - -interface AddState { - kind: AddKind; - /** Which field is focused; -1 means the kind picker. */ - field: number; - values: string[]; -} - -const ADD_FIELDS: Record = { - password: ["label", "length", "username"], - totp: ["label"], - "key-value": ["label", "key", "value"], -}; +import { ConsoleUi, fail, warn, type Ui } from "./console.js"; +import { mainMenu, type Session } from "./menus.js"; export interface TuiOptions { appDir?: string; fingerprint?: string; } -/** - * What the app needs from a terminal. Narrowed to an interface so tests can - * drive the real screens through a fake, rather than a pty the test runner - * cannot allocate — the redaction rules below are worth asserting directly. - */ -export interface TerminalLike { - readonly columns: number; - readonly rows: number; - start(): void; - restore(): void; - onKey(handler: (key: Key) => void): void; - onResize(handler: () => void): void; - draw(lines: string[]): void; +function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); } -export async function runTui(opts: TuiOptions, terminal?: TerminalLike): Promise { - if (!terminal && !Terminal.isInteractive()) { +export async function runTui(opts: TuiOptions, injectedUi?: Ui): Promise { + if (!injectedUi && !isInteractive()) { process.stderr.write( "seedpass-js: interactive mode needs a terminal on both stdin and stdout.\n" + "Run a subcommand instead — 'seedpass-js --help' lists them.\n", @@ -89,9 +39,11 @@ export async function runTui(opts: TuiOptions, terminal?: TerminalLike): Promise return 1; } + const ui = injectedUi ?? new ConsoleUi(); const app = new AppDir(resolveAppDir(opts.appDir)); - const fps = await app.readFingerprints(); - if (fps.fingerprints.length === 0) { + const registry = await app.readFingerprints(); + + if (registry.fingerprints.length === 0) { process.stderr.write( "No vault yet. Create one with:\n\n" + " export SEEDPASS_PASSWORD='a master password'\n" + @@ -100,836 +52,61 @@ export async function runTui(opts: TuiOptions, terminal?: TerminalLike): Promise return 1; } - const tui = new Tui(app, opts, terminal ?? new Terminal()); - return tui.run(); -} - -class Tui { - private screen: Screen = "list"; - private vault: OpenedVault | null = null; - private fingerprint = ""; - private profileNames: Record = {}; - private profiles: string[] = []; - private rows: Row[] = []; - private filtered: Row[] = []; - private cursor = 0; - private scroll = 0; - private query = ""; - private searching = false; - private status = ""; - private statusKind: "info" | "error" | "ok" = "info"; - private revealed: { label: string; value: string; descriptor: string } | null = null; - private add: AddState | null = null; - private showArchived = false; - private profileCursor = 0; - private done = false; - private exitCode = 0; - private resolveDone: (() => void) | null = null; - - constructor( - private readonly app: AppDir, - private readonly opts: TuiOptions, - private readonly term: TerminalLike, - ) {} - - async run(): Promise { - // Unlock before touching the terminal: the password prompt is a plain - // line-mode read, and failures should print normally rather than inside - // an alternate screen that is about to be torn down. - try { - await this.unlock(); - } catch (e) { - process.stderr.write(`seedpass-js: ${(e as Error).message}\n`); - return 1; - } - - this.term.start(); - this.term.onResize(() => this.render()); - this.term.onKey((key) => { - void this.handleKey(key).catch((e: unknown) => { - this.setStatus((e as Error).message, "error"); - this.render(); - }); - }); - - this.render(); - await new Promise((resolve) => { - this.resolveDone = resolve; - }); - this.term.restore(); - return this.exitCode; - } - - private quit(code = 0): void { - this.done = true; - this.exitCode = code; - this.resolveDone?.(); - } - - // ---------------------------------------------------------------- unlock - - private async unlock(): Promise { - const fps = await this.app.readFingerprints(); - this.profiles = fps.fingerprints; - this.profileNames = fps.names; - this.fingerprint = this.opts.fingerprint ?? fps.last_used ?? fps.fingerprints[0]!; - - const mnemonic = await this.resolveMnemonic(this.fingerprint); - await this.loadVault(this.fingerprint, mnemonic); - } + const fingerprint = + opts.fingerprint ?? registry.last_used ?? registry.fingerprints[0]!; /** - * Same resolution order as the CLI: environment, then the session agent, - * then ask. Asking is last because the other two mean the user already - * decided how this session is unlocked. + * Resolve a profile's seed: environment, then the session agent, then ask. + * The same order the CLI uses, so a session unlocked with `vault unlock` + * does not ask again. */ - private async resolveMnemonic(fingerprint: string): Promise { + const relock = async (fp: string): Promise => { const env = process.env["SEEDPASS_MNEMONIC"]; if (env) { assertValidMnemonic(env, "SEEDPASS_MNEMONIC"); return env; } try { - const held = await new AgentClient(agentSocketPath(this.app.root)).ownerMnemonic(fingerprint); + const held = await new AgentClient(agentSocketPath(app.root)).ownerMnemonic(fp); if (held) return held; } catch { - // no agent, or it holds nothing for this profile — fall through to ask + // no agent running, or it holds nothing for this profile } - - const name = this.profileNames[fingerprint]; - const prompt = new HiddenPrompt(); - try { - for (let attempt = 0; attempt < 3; attempt++) { - const password = await prompt.ask( - `Master password for ${name ? `${name} (${fingerprint})` : fingerprint}: `, - ); - if (password === null) throw new Error("cancelled"); - try { - return await this.app.decryptParentSeed(fingerprint, password); - } catch { - process.stderr.write("Wrong password.\n"); - } - } - throw new Error("too many failed password attempts"); - } finally { - prompt.close(); - } - } - - private async loadVault(fingerprint: string, mnemonic: string): Promise { - const path = join(this.app.profileDir(fingerprint), INDEX_FILENAME); - this.vault = await openVault(path, mnemonic); - this.fingerprint = fingerprint; - this.refreshRows(); - } - - private refreshRows(): void { - const index = this.vault!.index; - this.rows = Object.entries(index.entries) - .map(([id, entry]) => { - const meta = entryMetadata(id, entry as Entry); - return { - id, - label: String((entry as Entry).label ?? ""), - kind: String((entry as Entry).kind ?? (entry as Entry).type ?? "unknown"), - archived: Boolean((entry as Entry).archived), - meta, - }; - }) - .sort((a, b) => Number(a.id) - Number(b.id)); - this.applyFilter(); - } - - private applyFilter(): void { - const q = this.query.trim().toLowerCase(); - this.filtered = this.rows.filter((r) => { - if (r.archived && !this.showArchived) return false; - if (!q) return true; - const tags = Array.isArray(r.meta["tags"]) ? (r.meta["tags"] as string[]).join(" ") : ""; - return `${r.label} ${r.kind} ${tags} ${String(r.meta["username"] ?? "")}` - .toLowerCase() - .includes(q); - }); - if (this.cursor >= this.filtered.length) this.cursor = Math.max(0, this.filtered.length - 1); - } - - private current(): Row | null { - return this.filtered[this.cursor] ?? null; - } - - private setStatus(text: string, kind: "info" | "error" | "ok" = "info"): void { - this.status = text; - this.statusKind = kind; - } - - // ------------------------------------------------------------------ keys - - private async handleKey(key: Key): Promise { - if (this.done) return; - if (key.name === "ctrl-c") { - this.quit(130); - return; - } - - switch (this.screen) { - case "list": - await this.keyList(key); - break; - case "detail": - await this.keyDetail(key); - break; - case "reveal": - this.keyReveal(key); - break; - case "add": - await this.keyAdd(key); - break; - case "help": - this.screen = "list"; - break; - case "profiles": - await this.keyProfiles(key); - break; - } - if (!this.done) this.render(); - } - - private async keyList(key: Key): Promise { - if (this.searching) { - switch (key.name) { - case "enter": - case "escape": - this.searching = false; - if (key.name === "escape") { - this.query = ""; - this.applyFilter(); - } - return; - case "backspace": - this.query = this.query.slice(0, -1); - this.applyFilter(); - return; - case "char": - this.query += key.ch; - this.applyFilter(); - return; - default: - return; + const names = (await app.readFingerprints()).names; + const who = names[fp] ? `${names[fp]} (${fp})` : fp; + for (let attempt = 0; attempt < 3; attempt++) { + const password = await ui.askHidden(`Master password for ${who}: `); + if (!password) throw new Error("cancelled"); + try { + return await app.decryptParentSeed(fp, password); + } catch { + warn(ui, "Wrong password."); } } + throw new Error("too many failed password attempts"); + }; - switch (key.name) { - case "up": - this.move(-1); - return; - case "down": - this.move(1); - return; - case "pageup": - this.move(-this.pageSize()); - return; - case "pagedown": - this.move(this.pageSize()); - return; - case "home": - this.cursor = 0; - return; - case "end": - this.cursor = Math.max(0, this.filtered.length - 1); - return; - case "enter": - if (this.current()) this.screen = "detail"; - return; - case "escape": - if (this.query) { - this.query = ""; - this.applyFilter(); - } - return; - case "char": - break; - default: - return; - } - - switch (key.ch) { - case "/": - this.searching = true; - this.setStatus(""); - return; - case "q": - this.quit(0); - return; - case "?": - this.screen = "help"; - return; - case "c": - await this.copyCurrent(); - return; - case "r": - this.revealCurrent(); - return; - case "a": - this.add = { kind: "password", field: -1, values: ["", "", ""] }; - this.screen = "add"; - return; - case "d": - await this.toggleArchive(); - return; - case "A": - this.showArchived = !this.showArchived; - this.applyFilter(); - this.setStatus(this.showArchived ? "showing archived" : "hiding archived"); - return; - case "p": - this.screen = "profiles"; - this.profileCursor = Math.max(0, this.profiles.indexOf(this.fingerprint)); - return; - case "g": - await this.reload(); - return; - default: - return; - } + let session: Session; + try { + const mnemonic = await relock(fingerprint); + session = { + ui, + app, + fingerprint, + name: registry.names[fingerprint] ?? null, + vault: await openVault(join(app.profileDir(fingerprint), INDEX_FILENAME), mnemonic), + config: await loadConfig(app.profileDir(fingerprint), mnemonic), + relock, + }; + } catch (e) { + process.stderr.write(`seedpass-js: ${(e as Error).message}\n`); + return 1; } - private async keyDetail(key: Key): Promise { - if (key.name === "escape" || (key.name === "char" && (key.ch === "q" || key.ch === "h"))) { - this.screen = "list"; - return; - } - if (key.name === "char") { - switch (key.ch) { - case "c": - await this.copyCurrent(); - return; - case "r": - this.revealCurrent(); - return; - case "d": - await this.toggleArchive(); - return; - case "?": - this.screen = "help"; - return; - default: - return; - } - } - if (key.name === "up") this.move(-1); - if (key.name === "down") this.move(1); - } - - private keyReveal(_key: Key): void { - // Any key dismisses. The value is dropped here and the frame is redrawn - // without it, so it exists on screen only while being looked at. - this.revealed = null; - this.screen = "detail"; - this.setStatus("hidden"); - } - - private async keyProfiles(key: Key): Promise { - switch (key.name) { - case "up": - this.profileCursor = Math.max(0, this.profileCursor - 1); - return; - case "down": - this.profileCursor = Math.min(this.profiles.length - 1, this.profileCursor + 1); - return; - case "escape": - this.screen = "list"; - return; - case "enter": { - const fp = this.profiles[this.profileCursor]; - if (!fp || fp === this.fingerprint) { - this.screen = "list"; - return; - } - await this.switchProfile(fp); - return; - } - case "char": - if (key.ch === "q") this.screen = "list"; - return; - default: - return; - } - } - - private async keyAdd(key: Key): Promise { - const state = this.add!; - const fields = ADD_FIELDS[state.kind]; - - if (key.name === "escape") { - this.add = null; - this.screen = "list"; - this.setStatus("cancelled"); - return; - } - - // Kind picker. - if (state.field === -1) { - if (key.name === "left" || key.name === "up") { - const i = ADD_KINDS.indexOf(state.kind); - state.kind = ADD_KINDS[(i - 1 + ADD_KINDS.length) % ADD_KINDS.length]!; - state.values = ADD_FIELDS[state.kind].map(() => ""); - return; - } - if (key.name === "right" || key.name === "down" || key.name === "tab") { - const i = ADD_KINDS.indexOf(state.kind); - state.kind = ADD_KINDS[(i + 1) % ADD_KINDS.length]!; - state.values = ADD_FIELDS[state.kind].map(() => ""); - return; - } - if (key.name === "enter") { - state.field = 0; - return; - } - return; - } - - switch (key.name) { - case "enter": - if (state.field < fields.length - 1) { - state.field += 1; - return; - } - await this.commitAdd(); - return; - case "tab": - state.field = (state.field + 1) % fields.length; - return; - case "up": - state.field = Math.max(0, state.field - 1); - return; - case "down": - state.field = Math.min(fields.length - 1, state.field + 1); - return; - case "backspace": - state.values[state.field] = state.values[state.field]!.slice(0, -1); - return; - case "char": - state.values[state.field] += key.ch; - return; - default: - return; - } - } - - private move(delta: number): void { - if (this.filtered.length === 0) return; - this.cursor = Math.min(this.filtered.length - 1, Math.max(0, this.cursor + delta)); - } - - private pageSize(): number { - return Math.max(1, this.term.rows - 6); - } - - // ---------------------------------------------------------------- actions - - private async copyCurrent(): Promise { - const row = this.current(); - if (!row) return; - const secret = materializeSecret( - this.vault!.index, - row.id, - this.vault!.index.entries[row.id] as Entry, - this.vault!.mnemonic, - ); - const result = await clipboardSink(secret.value); - // Deliberately reports the descriptor, never the value. - this.setStatus(`copied ${secret.descriptor} (${result.detail})`, "ok"); - } - - private revealCurrent(): void { - const row = this.current(); - if (!row) return; - const secret = materializeSecret( - this.vault!.index, - row.id, - this.vault!.index.entries[row.id] as Entry, - this.vault!.mnemonic, - ); - this.revealed = { label: row.label, value: secret.value, descriptor: secret.descriptor }; - this.screen = "reveal"; - } - - private async toggleArchive(): Promise { - const row = this.current(); - if (!row) return; - const wasArchived = row.archived; - await this.mutate((vault) => { - if (wasArchived) restoreEntry(vault.index, row.id); - else archiveEntry(vault.index, row.id); - }); - this.setStatus(`${wasArchived ? "unarchived" : "archived"} ${row.label}`, "ok"); - } - - private async commitAdd(): Promise { - const state = this.add!; - const fields = ADD_FIELDS[state.kind]; - const value = (name: string): string => state.values[fields.indexOf(name)]?.trim() ?? ""; - - const label = value("label"); - if (!label) { - this.setStatus("label is required", "error"); - return; - } - - let newId = ""; - await this.mutate((vault) => { - switch (state.kind) { - case "password": { - const raw = value("length"); - const length = raw ? Number(raw) : 20; - if (!Number.isInteger(length) || length < 8 || length > 128) { - throw new Error("length must be a whole number between 8 and 128"); - } - const username = value("username"); - newId = addPasswordEntry(vault.index, label, length, { - ...(username && { username }), - }); - return; - } - case "totp": - newId = addTotpDeterministic(vault.index, label, vault.mnemonic); - return; - case "key-value": { - const key = value("key"); - if (!key) throw new Error("key is required"); - newId = addKeyValueEntry(vault.index, label, key, value("value")); - return; - } - } - }); - - this.add = null; - this.screen = "list"; - // Land the cursor on what was just created. - const at = this.filtered.findIndex((r) => r.id === newId); - if (at >= 0) this.cursor = at; - this.setStatus(`added ${refFor(newId)} ${label}`, "ok"); - } - - /** - * Apply a change under the vault lock and re-read afterwards. - * - * The whole read-modify-write cycle is inside the lock, matching the CLI: - * the TUI holds an in-memory index for the length of a session, so writing - * it back without re-reading would clobber anything a concurrent - * `seedpass-js` or a `nostr restore` wrote in the meantime. - */ - private async mutate(fn: (vault: OpenedVault) => void): Promise { - const path = this.vault!.path; - const mnemonic = this.vault!.mnemonic; - await withVaultLock(path, async () => { - const fresh = await openVault(path, mnemonic); - fn(fresh); - await saveVaultHoldingLock(fresh); - this.vault = fresh; - }); - this.refreshRows(); - } - - private async reload(): Promise { - this.vault = await openVault(this.vault!.path, this.vault!.mnemonic); - this.refreshRows(); - this.setStatus("reloaded", "ok"); - } - - private async switchProfile(fingerprint: string): Promise { - // A different profile is a different seed; the current one must not be - // reused to open it. - this.term.restore(); - try { - const mnemonic = await this.resolveMnemonic(fingerprint); - await this.loadVault(fingerprint, mnemonic); - await this.app.switchProfile(fingerprint); - this.cursor = 0; - this.query = ""; - this.applyFilter(); - this.setStatus(`switched to ${this.profileNames[fingerprint] ?? fingerprint}`, "ok"); - } finally { - this.term.start(); - } - this.screen = "list"; - } - - // ---------------------------------------------------------------- render - - private render(): void { - if (this.done) return; - const width = this.term.columns; - const rows = this.term.rows; - const lines: string[] = []; - - const name = this.profileNames[this.fingerprint]; - const title = ` SeedPass ${ansi.dim}${name ? `${name} · ` : ""}${this.fingerprint}${ansi.reset}`; - lines.push(`${ansi.reverse}${pad(title, width)}${ansi.reset}`); - - switch (this.screen) { - case "list": - case "detail": - this.renderList(lines, width, rows); - break; - case "reveal": - this.renderReveal(lines, width); - break; - case "add": - this.renderAdd(lines, width); - break; - case "help": - this.renderHelp(lines); - break; - case "profiles": - this.renderProfiles(lines); - break; - } - - while (lines.length < rows - 1) lines.push(""); - lines.push(this.renderStatus(width)); - this.term.draw(lines.slice(0, rows)); - } - - private renderList(lines: string[], width: number, rows: number): void { - const detail = this.screen === "detail"; - const listHeight = Math.max(3, (detail ? Math.floor((rows - 4) / 2) : rows - 4)); - - // Keep the cursor inside the window. - if (this.cursor < this.scroll) this.scroll = this.cursor; - if (this.cursor >= this.scroll + listHeight) this.scroll = this.cursor - listHeight + 1; - if (this.scroll > Math.max(0, this.filtered.length - listHeight)) { - this.scroll = Math.max(0, this.filtered.length - listHeight); - } - - const header = this.searching - ? `${ansi.cyan}/${this.query}${ansi.reset}${ansi.dim}▌${ansi.reset}` - : this.query - ? `${ansi.dim}filter:${ansi.reset} ${this.query} ${ansi.dim}(esc clears)${ansi.reset}` - : `${ansi.dim}${this.filtered.length} entr${this.filtered.length === 1 ? "y" : "ies"}${ - this.showArchived ? " (incl. archived)" : "" - }${ansi.reset}`; - lines.push(` ${header}`); - - if (this.filtered.length === 0) { - lines.push(""); - lines.push( - ` ${ansi.dim}${this.rows.length === 0 ? "No entries yet — press 'a' to add one." : "Nothing matches."}${ansi.reset}`, - ); - } - - for (let i = this.scroll; i < Math.min(this.filtered.length, this.scroll + listHeight); i++) { - const row = this.filtered[i]!; - const selected = i === this.cursor; - const marker = selected ? `${ansi.cyan}❯${ansi.reset}` : " "; - const kind = pad(row.kind, 10); - const label = row.archived ? `${ansi.dim}${row.label}${ansi.reset}` : row.label; - const secretFlag = hasSecret(row.meta) ? `${ansi.dim}•${ansi.reset}` : " "; - const line = ` ${marker} ${ansi.grey}${pad(refFor(row.id), 14)}${ansi.reset}${secretFlag} ${ansi.dim}${kind}${ansi.reset} ${label}`; - lines.push(selected ? highlight(line, width) : line); - } - - if (detail) { - lines.push(`${ansi.dim}${"─".repeat(width)}${ansi.reset}`); - this.renderDetail(lines); - } - } - - private renderDetail(lines: string[]): void { - const row = this.current(); - if (!row) return; - // Straight from entryMetadata, which is the same redaction the CLI and - // the agent's read scope use — secret fields arrive as has_* flags. - for (const [k, v] of Object.entries(row.meta)) { - if (v === "" || v === null || (Array.isArray(v) && v.length === 0)) continue; - lines.push(` ${ansi.dim}${pad(k, 16)}${ansi.reset} ${formatValue(v)}`); - } - } - - private renderReveal(lines: string[], width: number): void { - const r = this.revealed!; - lines.push(""); - // materializeSecret's descriptor already names the entry ("password for - // github.com"), so appending the label again read as a stutter. - lines.push(` ${ansi.yellow}${r.descriptor}${ansi.reset}`); - lines.push(""); - lines.push(` ${ansi.bold}${r.value}${ansi.reset}`); - lines.push(""); - lines.push( - ` ${ansi.dim}On screen only until you press a key. Nothing is written to${ansi.reset}`, - ); - lines.push(` ${ansi.dim}scrollback — this view disappears with the session.${ansi.reset}`); - void width; - } - - private renderAdd(lines: string[], width: number): void { - const state = this.add!; - const fields = ADD_FIELDS[state.kind]; - lines.push(""); - const picker = ADD_KINDS.map((k) => - k === state.kind ? `${ansi.reverse} ${k} ${ansi.reset}` : ` ${ansi.dim}${k}${ansi.reset} `, - ).join(" "); - lines.push(` ${state.field === -1 ? `${ansi.cyan}❯${ansi.reset}` : " "} ${picker}`); - lines.push(""); - fields.forEach((field, i) => { - const focused = state.field === i; - const marker = focused ? `${ansi.cyan}❯${ansi.reset}` : " "; - const raw = state.values[i] ?? ""; - // The key-value "value" is a secret being imported, so it is masked - // like any other secret: the rule here is that nothing secret reaches - // the screen without an explicit reveal, and a field being typed is no - // exception. Check it afterwards with 'r'. - const secretField = state.kind === "key-value" && field === "value"; - const shown = secretField ? "•".repeat(raw.length) : raw; - const hint = - field === "length" && !raw - ? `${ansi.dim}20${ansi.reset}` - : secretField - ? `${ansi.dim}${raw ? "hidden" : "(optional, hidden as you type)"}${ansi.reset}` - : ""; - lines.push( - ` ${marker} ${ansi.dim}${pad(field, 10)}${ansi.reset} ${shown}${focused ? `${ansi.cyan}▌${ansi.reset}` : ""} ${hint}`, - ); - }); - lines.push(""); - if (state.kind === "password") { - lines.push( - ` ${ansi.dim}The password is derived from your seed — it is never shown here${ansi.reset}`, - ); - lines.push(` ${ansi.dim}and never stored. Use 'c' to copy it afterwards.${ansi.reset}`); - } - if (state.kind === "totp") { - lines.push(` ${ansi.dim}A deterministic TOTP secret is derived at the next free index.${ansi.reset}`); - } - void width; - } - - private renderHelp(lines: string[]): void { - const keys: [string, string][] = [ - ["↑ ↓ / pgup pgdn", "move"], - ["enter", "open entry"], - ["/", "search (esc clears)"], - ["c", "copy secret to clipboard (never displayed)"], - ["r", "reveal secret on screen until a key is pressed"], - ["a", "add an entry"], - ["d", "archive / unarchive"], - ["A", "show or hide archived entries"], - ["p", "switch profile"], - ["g", "reload the vault from disk"], - ["?", "this help"], - ["q", "quit"], - ]; - lines.push(""); - for (const [k, what] of keys) lines.push(` ${ansi.cyan}${pad(k, 18)}${ansi.reset} ${what}`); - lines.push(""); - lines.push(` ${ansi.dim}Entries show metadata only. 'c' and 'r' are the only ways${ansi.reset}`); - lines.push(` ${ansi.dim}a secret leaves the vault, and both need a keypress.${ansi.reset}`); - lines.push(""); - lines.push(` ${ansi.dim}press any key${ansi.reset}`); - } - - private renderProfiles(lines: string[]): void { - lines.push(""); - this.profiles.forEach((fp, i) => { - const marker = i === this.profileCursor ? `${ansi.cyan}❯${ansi.reset}` : " "; - const active = fp === this.fingerprint ? `${ansi.green}●${ansi.reset}` : " "; - lines.push(` ${marker} ${active} ${pad(fp, 20)} ${ansi.dim}${this.profileNames[fp] ?? ""}${ansi.reset}`); - }); - lines.push(""); - lines.push(` ${ansi.dim}enter to switch (asks for that profile's password), esc to cancel${ansi.reset}`); - } - - private renderStatus(width: number): string { - const hints = - this.screen === "add" - ? "enter next · esc cancel" - : this.screen === "reveal" - ? "any key hides" - : "/ search · c copy · r reveal · a add · ? help · q quit"; - const colour = - this.statusKind === "error" ? ansi.red : this.statusKind === "ok" ? ansi.green : ansi.dim; - const left = this.status ? `${colour}${this.status}${ansi.reset}` : `${ansi.dim}${hints}${ansi.reset}`; - return ` ${truncate(left, width - 2)}`; + try { + return await mainMenu(session); + } catch (e) { + fail(ui, `Unexpected error: ${(e as Error).message}`); + return 1; } } - -// ------------------------------------------------------------------ helpers - -function hasSecret(meta: Record): boolean { - return Object.entries(meta).some(([k, v]) => k.startsWith("has_") && v === true); -} - -function formatValue(v: unknown): string { - if (Array.isArray(v)) return v.join(", "); - if (typeof v === "boolean") return v ? "yes" : "no"; - return String(v); -} - -function pad(s: string, width: number): string { - const len = visibleLength(s); - return len >= width ? s : s + " ".repeat(width - len); -} - -function truncate(s: string, width: number): string { - return visibleLength(s) <= width ? s : s.slice(0, Math.max(0, width - 1)) + "…"; -} - -function highlight(line: string, width: number): string { - return `${ansi.reverse}${pad(truncate(line, width), width)}${ansi.reset}`; -} - -/** - * Reads passwords with echo off. - * - * Raw mode is entered once and held for the whole sequence rather than per - * prompt. Toggling it around each read re-enables echo between attempts, so - * anything typed in that window — a retried password after a typo, most - * likely — was echoed to the screen in clear text. - * - * Written against the tty directly rather than readline, whose line editing - * echoes what it receives. - */ -class HiddenPrompt { - private readonly wasRaw: boolean; - private closed = false; - - constructor() { - this.wasRaw = process.stdin.isRaw ?? false; - process.stdin.setRawMode?.(true); - process.stdin.resume(); - process.stdin.setEncoding("utf8"); - } - - ask(prompt: string): Promise { - return new Promise((resolve) => { - process.stderr.write(prompt); - let value = ""; - const finish = (result: string | null): void => { - process.stdin.removeListener("data", onData); - process.stderr.write("\n"); - resolve(result); - }; - const onData = (chunk: string): void => { - for (const ch of chunk) { - if (ch === "\r" || ch === "\n") return finish(value); - if (ch === "\x03") return finish(null); - if (ch === "\x7f" || ch === "\b") { - value = value.slice(0, -1); - continue; - } - if (ch >= " ") value += ch; - } - }; - process.stdin.on("data", onData); - }); - } - - close(): void { - if (this.closed) return; - this.closed = true; - process.stdin.setRawMode?.(this.wasRaw); - process.stdin.pause(); - } -} - -export { isValidMnemonic }; diff --git a/js/packages/cli/src/tui/console.ts b/js/packages/cli/src/tui/console.ts new file mode 100644 index 00000000..8a1c7b8a --- /dev/null +++ b/js/packages/cli/src/tui/console.ts @@ -0,0 +1,160 @@ +/** + * Console I/O for the interactive menus. + * + * Python's legacy TUI is line-based: clear the screen, print a header and a + * numbered menu, read a line, act, repeat. This mirrors that model rather + * than the full-screen raw-mode approach, because the navigation *is* the + * interface — typing a number and pressing Enter, blank to go back. + * + * Everything the menus do goes through the `Ui` interface so the whole tree + * can be driven by a scripted test without a pty. + */ + +import process from "node:process"; +import { createInterface } from "node:readline"; + +export const ansi = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", +} as const; + +export interface Ui { + /** Print a line. */ + say(line?: string): void; + /** Read a line. Returns "" when the user just presses Enter. */ + ask(prompt: string): Promise; + /** Read a line without echoing it. */ + askHidden(prompt: string): Promise; + /** Clear the screen (no-op when not a terminal). */ + clear(): void; +} + +/** A menu option: the key the user types, and its label. */ +export interface MenuOption { + key: string; + label: string; +} + +export class ConsoleUi implements Ui { + constructor( + private readonly out: NodeJS.WritableStream = process.stdout, + private readonly input: NodeJS.ReadableStream & { isTTY?: boolean } = process.stdin, + ) {} + + say(line = ""): void { + this.out.write(line + "\n"); + } + + clear(): void { + if ((this.out as unknown as { isTTY?: boolean }).isTTY) { + // Clear and reset the cursor, then clear scrollback: menus redraw + // constantly, and without the third sequence a session leaves hundreds + // of stale screens behind it. + this.out.write("\x1b[2J\x1b[H\x1b[3J"); + } + } + + async ask(prompt: string): Promise { + const rl = createInterface({ input: this.input, output: this.out, terminal: true }); + try { + return (await new Promise((resolve) => rl.question(prompt, resolve))).trim(); + } finally { + rl.close(); + } + } + + /** + * Read without echo. + * + * Not readline: its line editing echoes what it receives, and a master + * password must not reach the screen or a terminal log. Raw mode is entered + * and left around this single read; the menus are otherwise line-mode. + */ + askHidden(prompt: string): Promise { + const stdin = this.input as NodeJS.ReadableStream & { + isTTY?: boolean; + setRawMode?: (v: boolean) => void; + isRaw?: boolean; + }; + if (!stdin.isTTY) return this.ask(prompt); + + return new Promise((resolve) => { + const wasRaw = stdin.isRaw ?? false; + this.out.write(prompt); + stdin.setRawMode?.(true); + stdin.resume(); + (stdin as NodeJS.ReadableStream & { setEncoding(e: string): void }).setEncoding("utf8"); + + let value = ""; + const finish = (result: string): void => { + stdin.removeListener("data", onData); + stdin.setRawMode?.(wasRaw); + stdin.pause(); + this.out.write("\n"); + resolve(result); + }; + const onData = (chunk: string): void => { + for (const ch of chunk) { + if (ch === "\r" || ch === "\n") return finish(value); + // Ctrl-C inside a password prompt should abort the program, not + // silently return an empty password that then "fails to unlock". + if (ch === "\x03") { + finish(""); + process.exit(130); + return; + } + if (ch === "\x7f" || ch === "\b") { + value = value.slice(0, -1); + continue; + } + if (ch >= " ") value += ch; + } + }; + stdin.on("data", onData); + }); + } +} + +/** Header shown at the top of every screen: profile, then breadcrumb. */ +export function header(ui: Ui, fingerprint: string, name: string | null, title: string): void { + ui.clear(); + const who = name ? `${name} (${fingerprint})` : fingerprint; + ui.say(`${ansi.dim}SeedPass — ${who}${ansi.reset}`); + ui.say(`${ansi.bold}${title}${ansi.reset}`); +} + +export function menu(ui: Ui, options: MenuOption[]): void { + ui.say(); + for (const { key, label } of options) { + ui.say(` ${ansi.cyan}${key}.${ansi.reset} ${label}`); + } + ui.say(); +} + +export async function pause(ui: Ui): Promise { + await ui.ask(`${ansi.dim}Press Enter to continue.${ansi.reset} `); +} + +export async function confirm(ui: Ui, question: string): Promise { + const answer = (await ui.ask(`${question} (y/N): `)).toLowerCase(); + return answer === "y" || answer === "yes"; +} + +export function ok(ui: Ui, message: string): void { + ui.say(`${ansi.green}${message}${ansi.reset}`); +} + +export function warn(ui: Ui, message: string): void { + ui.say(`${ansi.yellow}${message}${ansi.reset}`); +} + +export function fail(ui: Ui, message: string): void { + ui.say(`${ansi.red}${message}${ansi.reset}`); +} diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts new file mode 100644 index 00000000..b5f0d537 --- /dev/null +++ b/js/packages/cli/src/tui/menus.ts @@ -0,0 +1,1467 @@ +/** + * The interactive menus, matching Python's legacy (v1) TUI. + * + * Structure and numbering follow `src/main.py` deliberately: the same items + * in the same order under the same names, so muscle memory carries over. + * Where this build cannot do something Python can (semantic index, QR + * rendering, PGP RSA) the item is still listed and says so, rather than + * disappearing and leaving the numbering shifted. + * + * The agent-blind rule (plan section 9.3) still holds: secrets are shown only + * by the actions whose whole purpose is to show them, and Secret Mode routes + * those to the clipboard instead. + */ + +import process from "node:process"; +import { join, basename } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { + addPasswordEntry, + addTotpDeterministic, + addTotpImported, + addSshKeyEntry, + addSeedEntry, + addNostrKeyEntry, + addPgpKeyEntry, + addKeyValueEntry, + addManagedAccountEntry, + addDocumentEntry, + modifyEntry, + archiveEntry, + restoreEntry, + deriveNostrKeys, + deriveTotpSecret, + Bip85, + generateMnemonic, + exportBackup, + importBackup, + totpCodeAt, + type Entry, + type VaultIndex, +} from "@seedpass/core"; +import { AppDir, INDEX_FILENAME } from "../appDir.js"; +import { openVault, saveVaultHoldingLock, withVaultLock, type OpenedVault } from "../vaultFile.js"; +import { entryMetadata, refFor } from "../refs.js"; +import { materializeSecret } from "../secrets.js"; +import { clipboardSink } from "../sinks.js"; +import { loadConfig, mutateConfig, DEFAULT_RELAYS } from "../configFile.js"; +import { + ansi, + confirm, + fail, + header, + menu, + ok, + pause, + warn, + type Ui, +} from "./console.js"; + +const ENTRY_TYPES = [ + "password", + "totp", + "ssh", + "seed", + "pgp", + "nostr", + "key_value", + "managed_account", + "document", +] as const; + +/** Shared state for one interactive session. */ +export interface Session { + ui: Ui; + app: AppDir; + fingerprint: string; + name: string | null; + vault: OpenedVault; + config: Record; + /** Re-prompt for the master password; used by Lock Vault and profile switch. */ + relock: (fingerprint: string) => Promise; +} + +// ---------------------------------------------------------------- utilities + +function title(s: Session, breadcrumb: string): void { + header(s.ui, s.fingerprint, s.name, breadcrumb); +} + +function kindOf(entry: Entry): string { + const e = entry as unknown as Record; + return String(e["kind"] ?? e["type"] ?? "unknown"); +} + +/** + * otpauth:// URI for a TOTP entry, for "Export 2FA codes". + * + * Imported entries carry their secret; deterministic ones derive it from the + * seed at the entry's index — the same secret the code is computed from. + */ +function totpUri(entry: Entry, mnemonic: string): string { + const e = entry as unknown as Record; + const secret = + typeof e["secret"] === "string" && e["secret"] + ? (e["secret"] as string) + : deriveTotpSecret(mnemonic, Number(e["index"] ?? 0)); + const label = encodeURIComponent(String(e["label"] ?? "")); + const period = Number(e["period"] ?? 30); + const digits = Number(e["digits"] ?? 6); + return `otpauth://totp/${label}?secret=${secret}&issuer=SeedPass&period=${period}&digits=${digits}`; +} + +function prettyKind(kind: string): string { + return kind + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +function entriesOf(index: VaultIndex): Array<{ id: string; entry: Entry }> { + return Object.entries(index.entries) + .map(([id, entry]) => ({ id, entry: entry as Entry })) + .sort((a, b) => Number(a.id) - Number(b.id)); +} + +function isArchived(entry: Entry): boolean { + return Boolean(entry.archived ?? (entry as Record)["blacklisted"]); +} + +/** Apply a change under the vault lock, re-reading first. */ +async function mutate(s: Session, fn: (vault: OpenedVault) => void): Promise { + const { path, mnemonic } = s.vault; + await withVaultLock(path, async () => { + const fresh = await openVault(path, mnemonic); + fn(fresh); + await saveVaultHoldingLock(fresh); + s.vault = fresh; + }); +} + +async function reload(s: Session): Promise { + s.vault = await openVault(s.vault.path, s.vault.mnemonic); +} + +function secretModeOn(s: Session): boolean { + return Boolean(s.config["secret_mode_enabled"]); +} + +/** + * Show a secret, honouring Secret Mode. + * + * Python's Secret Mode copies to the clipboard instead of printing; this + * keeps that behaviour so the setting means the same thing in both. + */ +async function reveal(s: Session, value: string, descriptor: string): Promise { + if (secretModeOn(s)) { + try { + const result = await clipboardSink(value); + ok(s.ui, `${descriptor} copied to clipboard (${result.detail}).`); + } catch (e) { + fail(s.ui, `Clipboard unavailable: ${(e as Error).message}`); + warn(s.ui, "Secret Mode is on, so it was not printed. Turn it off to display it."); + } + return; + } + s.ui.say(`${ansi.dim}${descriptor}:${ansi.reset}`); + s.ui.say(`${ansi.bold}${value}${ansi.reset}`); +} + +async function askIndex(s: Session, prompt: string): Promise { + const raw = await s.ui.ask(prompt); + if (!raw) return null; + const id = raw.startsWith("sp://entry/") ? raw.slice("sp://entry/".length) : raw; + if (!/^\d+$/.test(id)) { + fail(s.ui, "Invalid index."); + await pause(s.ui); + return null; + } + if (!s.vault.index.entries[id]) { + fail(s.ui, `No entry ${id}.`); + await pause(s.ui); + return null; + } + return id; +} + +async function askInt( + s: Session, + prompt: string, + opts: { min?: number; max?: number; default?: number } = {}, +): Promise { + const raw = await s.ui.ask(prompt); + if (!raw) return opts.default ?? null; + const value = Number(raw); + // A NaN reaching the vault once made an index unreadable on every later + // open; refuse it at the prompt. + if (!Number.isInteger(value)) { + fail(s.ui, "Please enter a whole number."); + return null; + } + if (opts.min !== undefined && value < opts.min) { + fail(s.ui, `Must be at least ${opts.min}.`); + return null; + } + if (opts.max !== undefined && value > opts.max) { + fail(s.ui, `Must be at most ${opts.max}.`); + return null; + } + return value; +} + +// ------------------------------------------------------------- main menu + +export async function mainMenu(s: Session): Promise { + for (;;) { + title(s, "Main Menu"); + menu(s.ui, [ + { key: "1", label: "Add Entry" }, + { key: "2", label: "Retrieve Entry" }, + { key: "3", label: "Search Entries" }, + { key: "4", label: "List Entries" }, + { key: "5", label: "Modify an Existing Entry" }, + { key: "6", label: "2FA Codes" }, + { key: "7", label: "Settings" }, + { key: "8", label: "List Archived" }, + ]); + const choice = await s.ui.ask("Enter your choice (1-8) or press Enter to exit: "); + if (!choice) return 0; + try { + switch (choice) { + case "1": + await addEntryMenu(s); + break; + case "2": + await retrieveEntry(s); + break; + case "3": + await searchEntries(s); + break; + case "4": + await listEntriesMenu(s); + break; + case "5": + await modifyEntryFlow(s); + break; + case "6": + await totpCodes(s); + break; + case "7": + await settingsMenu(s); + break; + case "8": + await listArchived(s); + break; + default: + fail(s.ui, "Invalid choice. Please select a valid option."); + await pause(s.ui); + } + } catch (e) { + fail(s.ui, `Action failed: ${(e as Error).message}`); + await pause(s.ui); + } + } +} + +// -------------------------------------------------------------- add entry + +async function addEntryMenu(s: Session): Promise { + for (;;) { + title(s, "Main Menu > Add Entry"); + menu(s.ui, [ + { key: "1", label: "Password" }, + { key: "2", label: "2FA (TOTP)" }, + { key: "3", label: "SSH Key" }, + { key: "4", label: "Seed Phrase" }, + { key: "5", label: "Nostr Key Pair" }, + { key: "6", label: "PGP Key" }, + { key: "7", label: "Key/Value" }, + { key: "8", label: "Managed Account" }, + { key: "9", label: "Document" }, + ]); + const choice = await s.ui.ask("Select entry type or press Enter to go back: "); + if (!choice) return; + const handlers: Record Promise> = { + "1": () => addPassword(s), + "2": () => addTotp(s), + "3": () => addSimple(s, "SSH Key", (index, label) => addSshKeyEntry(index, label)), + "4": () => addSeed(s), + "5": () => addSimple(s, "Nostr Key Pair", (index, label) => addNostrKeyEntry(index, label)), + "6": () => addPgp(s), + "7": () => addKeyValue(s), + "8": () => + addSimple(s, "Managed Account", (index, label) => + addManagedAccountEntry(index, label, s.vault.mnemonic), + ), + "9": () => addDocument(s), + }; + const handler = handlers[choice]; + if (!handler) { + fail(s.ui, "Invalid choice."); + await pause(s.ui); + continue; + } + if (await handler()) return; + } +} + +async function commonFields( + s: Session, +): Promise<{ notes: string; tags: string[] }> { + const notes = await s.ui.ask("Notes (optional): "); + const tagsRaw = await s.ui.ask("Tags, comma separated (optional): "); + const tags = tagsRaw + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + return { notes, tags }; +} + +async function announce(s: Session, id: string, what: string): Promise { + ok(s.ui, `[+] ${what} added at index ${id} (${refFor(id)}).`); + await pause(s.ui); +} + +async function addPassword(s: Session): Promise { + const label = await s.ui.ask("Label: "); + if (!label) return false; + const length = await askInt(s, "Length (default 20): ", { min: 8, max: 128, default: 20 }); + if (length === null) { + await pause(s.ui); + return false; + } + const username = await s.ui.ask("Username (optional): "); + const url = await s.ui.ask("URL (optional): "); + const { notes, tags } = await commonFields(s); + let id = ""; + await mutate(s, (v) => { + id = addPasswordEntry(v.index, label, length, { username, url, notes, tags }); + }); + // Derived, not stored: show it once here the way Python does. + const entry = s.vault.index.entries[id] as Entry; + const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); + await reveal(s, secret.value, `Password for ${label}`); + await announce(s, id, "Password"); + return true; +} + +async function addTotp(s: Session): Promise { + const label = await s.ui.ask("Label: "); + if (!label) return false; + const imported = await s.ui.ask("Import an existing secret? (y/N): "); + let id = ""; + if (imported.toLowerCase().startsWith("y")) { + const secret = await s.ui.askHidden("Base32 secret: "); + if (!secret) return false; + const period = await askInt(s, "Period seconds (default 30): ", { min: 1, default: 30 }); + const digits = await askInt(s, "Digits (default 6): ", { min: 4, max: 10, default: 6 }); + await mutate(s, (v) => { + id = addTotpImported(v.index, label, secret, { + ...(period !== null && { period }), + ...(digits !== null && { digits }), + }); + }); + } else { + await mutate(s, (v) => { + id = addTotpDeterministic(v.index, label, v.mnemonic); + }); + } + const entry = s.vault.index.entries[id] as Entry; + try { + await reveal(s, totpUri(entry, s.vault.mnemonic), `otpauth URI for ${label}`); + } catch { + // Not fatal: the entry exists either way. + } + await announce(s, id, "2FA entry"); + return true; +} + +async function addSimple( + s: Session, + what: string, + add: (index: VaultIndex, label: string) => string, +): Promise { + const label = await s.ui.ask("Label: "); + if (!label) return false; + let id = ""; + await mutate(s, (v) => { + id = add(v.index, label); + }); + await announce(s, id, what); + return true; +} + +async function addSeed(s: Session): Promise { + const label = await s.ui.ask("Label: "); + if (!label) return false; + const words = await askInt(s, "Word count 12 or 24 (default 24): ", { default: 24 }); + if (words !== 12 && words !== 24) { + fail(s.ui, "Word count must be 12 or 24."); + await pause(s.ui); + return false; + } + let id = ""; + await mutate(s, (v) => { + id = addSeedEntry(v.index, label, { wordCount: words }); + }); + await announce(s, id, "Seed Phrase"); + return true; +} + +async function addPgp(s: Session): Promise { + const label = await s.ui.ask("Label: "); + if (!label) return false; + const userId = await s.ui.ask("User ID (e.g. name ): "); + let id = ""; + await mutate(s, (v) => { + id = addPgpKeyEntry(v.index, label, { ...(userId && { userId }) }); + }); + await announce(s, id, "PGP Key"); + return true; +} + +async function addKeyValue(s: Session): Promise { + const label = await s.ui.ask("Label: "); + if (!label) return false; + const key = await s.ui.ask("Key: "); + if (!key) { + fail(s.ui, "Key is required."); + await pause(s.ui); + return false; + } + // Hidden: this is a stored secret, and it should not sit in the scrollback + // of the terminal it was typed into. + const value = await s.ui.askHidden("Value (hidden): "); + let id = ""; + await mutate(s, (v) => { + id = addKeyValueEntry(v.index, label, key, value); + }); + await announce(s, id, "Key/Value"); + return true; +} + +async function addDocument(s: Session): Promise { + const label = await s.ui.ask("Label: "); + if (!label) return false; + const path = await s.ui.ask("Path to file (blank to type the content): "); + let content: string; + let fileType = ""; + if (path) { + try { + content = await readFile(path, "utf8"); + } catch (e) { + fail(s.ui, `Could not read ${path}: ${(e as Error).message}`); + await pause(s.ui); + return false; + } + const dot = basename(path).lastIndexOf("."); + if (dot > 0) fileType = basename(path).slice(dot + 1); + } else { + content = await s.ui.ask("Content: "); + fileType = await s.ui.ask("File type (optional): "); + } + let id = ""; + await mutate(s, (v) => { + id = addDocumentEntry(v.index, label, content, { ...(fileType && { fileType }) }); + }); + await announce(s, id, "Document"); + return true; +} + +// --------------------------------------------------------- retrieve/search + +async function retrieveEntry(s: Session): Promise { + title(s, "Main Menu > Retrieve Entry"); + const query = await s.ui.ask("Enter index, sp:// reference, or label: "); + if (!query) return; + const id = await resolveQuery(s, query); + if (id === null) return; + await entryDetails(s, id); +} + +async function resolveQuery(s: Session, query: string): Promise { + const bare = query.startsWith("sp://entry/") ? query.slice("sp://entry/".length) : query; + if (/^\d+$/.test(bare) && s.vault.index.entries[bare]) return bare; + const matches = entriesOf(s.vault.index).filter(({ entry }) => + String(entry.label ?? "").toLowerCase().includes(query.toLowerCase()), + ); + if (matches.length === 0) { + warn(s.ui, "No matching entries found."); + await pause(s.ui); + return null; + } + if (matches.length === 1) return matches[0]!.id; + s.ui.say(); + for (const { id, entry } of matches) { + s.ui.say(` ${ansi.cyan}${id}.${ansi.reset} ${prettyKind(kindOf(entry))} - ${entry.label}`); + } + s.ui.say(); + return askIndex(s, "Enter index or press Enter to go back: "); +} + +async function searchEntries(s: Session): Promise { + title(s, "Main Menu > Search Entries"); + const query = (await s.ui.ask("Search: ")).toLowerCase(); + if (!query) return; + const matches = entriesOf(s.vault.index).filter(({ entry }) => { + const tags = Array.isArray(entry.tags) ? (entry.tags as string[]).join(" ") : ""; + return `${entry.label ?? ""} ${kindOf(entry)} ${tags} ${entry.username ?? ""} ${entry.notes ?? ""}` + .toLowerCase() + .includes(query); + }); + if (matches.length === 0) { + warn(s.ui, "No matching entries found."); + await pause(s.ui); + return; + } + s.ui.say(`\n${ansi.green}[+] Matches:${ansi.reset}\n`); + for (const { id, entry } of matches) { + const archived = isArchived(entry) ? `${ansi.dim} (archived)${ansi.reset}` : ""; + s.ui.say( + ` ${ansi.cyan}${id}.${ansi.reset} ${prettyKind(kindOf(entry))} - ${entry.label}${archived}`, + ); + } + s.ui.say(); + const id = await askIndex(s, "Enter index to view details or press Enter to go back: "); + if (id !== null) await entryDetails(s, id); +} + +// ------------------------------------------------------------ list entries + +async function listEntriesMenu(s: Session): Promise { + for (;;) { + title(s, "Main Menu > List Entries"); + const options = [{ key: "1", label: "All" }]; + const optionMap: Record = {}; + ENTRY_TYPES.forEach((t, i) => { + const key = String(i + 2); + options.push({ key, label: prettyKind(t) }); + optionMap[key] = t; + }); + menu(s.ui, options); + const choice = await s.ui.ask("Select entry type or press Enter to go back: "); + if (!choice) return; + let filter: string | null; + if (choice === "1") filter = null; + else if (optionMap[choice]) filter = optionMap[choice]!; + else { + fail(s.ui, "Invalid choice."); + await pause(s.ui); + continue; + } + await listAndPick(s, filter, false, "Main Menu > List Entries"); + } +} + +async function listArchived(s: Session): Promise { + await listAndPick(s, null, true, "Main Menu > List Archived"); +} + +async function listAndPick( + s: Session, + filter: string | null, + archivedOnly: boolean, + breadcrumb: string, +): Promise { + for (;;) { + await reload(s); + title(s, breadcrumb); + const rows = entriesOf(s.vault.index).filter(({ entry }) => { + if (archivedOnly !== isArchived(entry)) return false; + return filter === null || kindOf(entry) === filter; + }); + if (rows.length === 0) { + const what = filter ? `${prettyKind(filter)} ` : ""; + warn(s.ui, archivedOnly ? "No archived entries found." : `No active ${what}entries found.`); + await pause(s.ui); + return; + } + s.ui.say(`\n${ansi.green}[+] Entries:${ansi.reset}\n`); + for (const { id, entry } of rows) { + const shown = filter === null ? `${prettyKind(kindOf(entry))} - ${entry.label}` : String(entry.label); + s.ui.say(` ${ansi.cyan}${id}.${ansi.reset} ${shown}`); + } + s.ui.say(); + const id = await askIndex(s, "Enter index to view details or press Enter to go back: "); + if (id === null) return; + await entryDetails(s, id); + } +} + +// ---------------------------------------------------------- entry details + +async function entryDetails(s: Session, id: string): Promise { + for (;;) { + await reload(s); + const entry = s.vault.index.entries[id] as Entry | undefined; + if (!entry) return; + const kind = kindOf(entry); + title(s, `Main Menu > Entry ${id}`); + + // Metadata first, redacted exactly as the CLI and the agent's read scope + // redact it — stored secrets appear as has_* flags, never values. + s.ui.say(); + for (const [k, v] of Object.entries(entryMetadata(id, entry))) { + if (v === "" || v === null || (Array.isArray(v) && v.length === 0)) continue; + s.ui.say(` ${ansi.dim}${k.padEnd(16)}${ansi.reset} ${Array.isArray(v) ? v.join(", ") : String(v)}`); + } + + const options = [ + { key: "S", label: "Show secret" }, + { key: "C", label: "Copy secret to clipboard" }, + { key: isArchived(entry) ? "U" : "A", label: isArchived(entry) ? "Unarchive" : "Archive" }, + { key: "N", label: "Add Note" }, + { key: "F", label: "Add Custom Field" }, + { key: "H", label: "Add Hidden Field" }, + { key: "E", label: "Edit" }, + { key: "T", label: "Edit Tags" }, + ]; + if (kind === "seed" || kind === "managed_account" || kind === "nostr") { + options.push({ key: "Q", label: "Show QR codes" }); + } + if (kind === "document") options.push({ key: "X", label: "Export Document to File" }); + menu(s.ui, options); + + const choice = (await s.ui.ask("Select an action or press Enter to return: ")).toLowerCase(); + if (!choice) return; + switch (choice) { + case "s": + case "c": { + const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); + if (choice === "c") { + try { + const r = await clipboardSink(secret.value); + ok(s.ui, `Copied ${secret.descriptor} (${r.detail}).`); + } catch (e) { + fail(s.ui, `Clipboard unavailable: ${(e as Error).message}`); + } + } else { + await reveal(s, secret.value, secret.descriptor); + } + await pause(s.ui); + break; + } + case "a": + case "u": + await mutate(s, (v) => { + if (isArchived(entry)) restoreEntry(v.index, id); + else archiveEntry(v.index, id); + }); + ok(s.ui, isArchived(entry) ? "Unarchived." : "Archived."); + await pause(s.ui); + break; + case "n": { + const notes = await s.ui.ask("Note: "); + await mutate(s, (v) => modifyEntry(v.index, id, { notes })); + ok(s.ui, "Note saved."); + await pause(s.ui); + break; + } + case "f": + case "h": + await addCustomField(s, id, entry, choice === "h"); + break; + case "e": + await editEntryMenu(s, id); + break; + case "t": { + const raw = await s.ui.ask("Tags, comma separated: "); + const tags = raw.split(",").map((t) => t.trim()).filter(Boolean); + await mutate(s, (v) => modifyEntry(v.index, id, { tags })); + ok(s.ui, "Tags saved."); + await pause(s.ui); + break; + } + case "q": + await showQr(s, id, entry); + break; + case "x": + await exportDocument(s, id, entry); + break; + default: + fail(s.ui, "Invalid choice."); + await pause(s.ui); + } + } +} + +async function addCustomField( + s: Session, + id: string, + entry: Entry, + hidden: boolean, +): Promise { + const label = await s.ui.ask("Field label: "); + if (!label) return; + const value = hidden + ? await s.ui.askHidden("Field value (hidden): ") + : await s.ui.ask("Field value: "); + const existing = Array.isArray(entry.custom_fields) + ? (entry.custom_fields as Record[]) + : []; + const custom_fields = [...existing, { label, value, is_hidden: hidden }]; + await mutate(s, (v) => modifyEntry(v.index, id, { custom_fields })); + ok(s.ui, `${hidden ? "Hidden field" : "Custom field"} added.`); + await pause(s.ui); +} + +async function editEntryMenu(s: Session, id: string): Promise { + for (;;) { + await reload(s); + const entry = s.vault.index.entries[id] as Entry | undefined; + if (!entry) return; + const kind = kindOf(entry); + title(s, `Main Menu > Entry ${id} > Edit`); + const options = [{ key: "L", label: "Edit Label" }]; + if (kind === "key_value") { + options.push({ key: "K", label: "Edit Key" }, { key: "V", label: "Edit Value" }); + } else if (kind === "document") { + options.push({ key: "C", label: "Edit Content" }, { key: "F", label: "Edit File Type" }); + } + if (kind === "password") { + options.push({ key: "U", label: "Edit Username" }, { key: "R", label: "Edit URL" }); + } else if (kind === "totp") { + options.push({ key: "P", label: "Edit Period" }, { key: "D", label: "Edit Digits" }); + } + menu(s.ui, options); + const choice = (await s.ui.ask("Select option or press Enter to go back: ")).toLowerCase(); + if (!choice) return; + + const setField = async (changes: Record): Promise => { + await mutate(s, (v) => modifyEntry(v.index, id, changes)); + ok(s.ui, "Saved."); + await pause(s.ui); + }; + + switch (choice) { + case "l": + await setField({ label: await s.ui.ask("New label: ") }); + break; + case "k": + await setField({ key: await s.ui.ask("New key: ") }); + break; + case "v": + await setField({ value: await s.ui.askHidden("New value (hidden): ") }); + break; + case "c": + await setField({ content: await s.ui.ask("New content: ") }); + break; + case "f": + await setField({ file_type: await s.ui.ask("New file type: ") }); + break; + case "u": + await setField({ username: await s.ui.ask("New username: ") }); + break; + case "r": + await setField({ url: await s.ui.ask("New URL: ") }); + break; + case "p": { + const period = await askInt(s, "New period (seconds): ", { min: 1 }); + if (period !== null) await setField({ period }); + else await pause(s.ui); + break; + } + case "d": { + const digits = await askInt(s, "New digits: ", { min: 4, max: 10 }); + if (digits !== null) await setField({ digits }); + else await pause(s.ui); + break; + } + default: + fail(s.ui, "Invalid choice."); + await pause(s.ui); + } + } +} + +async function showQr(s: Session, id: string, entry: Entry): Promise { + // Python renders a QR block in the terminal. This build does not have a QR + // encoder, and inventing one for a display convenience is not worth a + // dependency in a process that holds unlocked seeds — say so plainly and + // offer the underlying value instead. + warn(s.ui, "QR rendering is not available in this build."); + const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); + if (await confirm(s.ui, "Show the value it would encode instead?")) { + await reveal(s, secret.value, secret.descriptor); + } + await pause(s.ui); +} + +async function exportDocument(s: Session, id: string, entry: Entry): Promise { + const out = await s.ui.ask("Output path: "); + if (!out) return; + const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic); + try { + await writeFile(out, secret.value, { encoding: "utf8", mode: 0o600 }); + ok(s.ui, `Document exported to: ${out}`); + } catch (e) { + fail(s.ui, `Export failed: ${(e as Error).message}`); + } + await pause(s.ui); +} + +async function modifyEntryFlow(s: Session): Promise { + title(s, "Main Menu > Modify an Existing Entry"); + const query = await s.ui.ask("Enter index, sp:// reference, or label: "); + if (!query) return; + const id = await resolveQuery(s, query); + if (id === null) return; + await editEntryMenu(s, id); +} + +// -------------------------------------------------------------- 2FA codes + +async function totpCodes(s: Session): Promise { + title(s, "Main Menu > 2FA Codes"); + const rows = entriesOf(s.vault.index).filter( + ({ entry }) => kindOf(entry) === "totp" && !isArchived(entry), + ); + if (rows.length === 0) { + warn(s.ui, "No 2FA entries found."); + await pause(s.ui); + return; + } + rows.sort((a, b) => + String(a.entry.label ?? "").toLowerCase().localeCompare(String(b.entry.label ?? "").toLowerCase()), + ); + const now = Math.floor(Date.now() / 1000); + s.ui.say(); + for (const { id, entry } of rows) { + const period = Number(entry.period ?? 30); + const remaining = period - (now % period); + const secret = materializeSecret(s.vault.index, id, entry, s.vault.mnemonic, { + timestamp: now, + }); + const code = secretModeOn(s) ? "".padEnd(6, "•") : secret.value; + s.ui.say( + ` ${ansi.cyan}${String(entry.label)}${ansi.reset} ${ansi.bold}${code}${ansi.reset} ` + + `${ansi.dim}${remaining}s left${ansi.reset}`, + ); + } + s.ui.say(); + if (secretModeOn(s)) warn(s.ui, "Secret Mode is on, so codes are hidden."); + await pause(s.ui); +} + +// --------------------------------------------------------------- settings + +async function settingsMenu(s: Session): Promise { + for (;;) { + title(s, "Main Menu > Settings"); + menu(s.ui, [ + { key: "1", label: "Profiles" }, + { key: "2", label: "Nostr" }, + { key: "3", label: "Change password" }, + { key: "4", label: "Verify Script Checksum" }, + { key: "5", label: "Generate Script Checksum" }, + { key: "6", label: "Backup Parent Seed" }, + { key: "7", label: "Export database" }, + { key: "8", label: "Import database" }, + { key: "9", label: "Export 2FA codes" }, + { key: "10", label: "Set additional backup location" }, + { key: "11", label: "KDF strength & benchmark" }, + { key: "12", label: "Set inactivity timeout" }, + { key: "13", label: "Lock Vault" }, + { key: "14", label: "Stats" }, + { key: "15", label: "Toggle Secret Mode" }, + { key: "16", label: "Toggle Offline Mode" }, + { key: "17", label: "Toggle Quick Unlock" }, + { key: "18", label: "Semantic Index" }, + ]); + const choice = await s.ui.ask("Select an option or press Enter to go back: "); + if (!choice) return; + try { + switch (choice) { + case "1": + await profilesMenu(s); + break; + case "2": + await nostrMenu(s); + break; + case "3": + await changePassword(s); + break; + case "4": + case "5": + warn( + s.ui, + "Script checksums cover the Python source tree. This build ships a " + + "single signed bundle; verify it with the .sha256 beside it.", + ); + await pause(s.ui); + break; + case "6": + await backupParentSeed(s); + break; + case "7": + await exportDatabase(s); + break; + case "8": + await importDatabase(s); + break; + case "9": + await exportTotpCodes(s); + break; + case "10": + await setBackupLocation(s); + break; + case "11": + await kdfSettings(s); + break; + case "12": + await setInactivityTimeout(s); + break; + case "13": + await lockVault(s); + break; + case "14": + await showStats(s); + break; + case "15": + await toggleConfig(s, "secret_mode_enabled", "Secret Mode"); + break; + case "16": + await toggleConfig(s, "offline_mode", "Offline Mode"); + break; + case "17": + await toggleConfig(s, "quick_unlock_enabled", "Quick Unlock"); + break; + case "18": + warn(s.ui, "The semantic index is not part of this build."); + await pause(s.ui); + break; + default: + fail(s.ui, "Invalid choice."); + await pause(s.ui); + } + } catch (e) { + fail(s.ui, `Action failed: ${(e as Error).message}`); + await pause(s.ui); + } + } +} + +async function saveSetting(s: Session, key: string, value: unknown): Promise { + await mutateConfig(s.app.profileDir(s.fingerprint), s.vault.mnemonic, (cfg) => { + cfg[key] = value; + }); + s.config[key] = value; +} + +async function toggleConfig(s: Session, key: string, label: string): Promise { + const next = !s.config[key]; + await saveSetting(s, key, next); + ok(s.ui, `${label} is now ${next ? "ON" : "OFF"}.`); + await pause(s.ui); +} + +// --------------------------------------------------------------- profiles + +async function profilesMenu(s: Session): Promise { + for (;;) { + title(s, "Main Menu > Settings > Profiles"); + menu(s.ui, [ + { key: "1", label: "Switch Seed Profile" }, + { key: "2", label: "Add a New Seed Profile" }, + { key: "3", label: "Remove an Existing Seed Profile" }, + { key: "4", label: "List All Seed Profiles" }, + { key: "5", label: "Set Seed Profile Name" }, + ]); + const choice = await s.ui.ask("Select an option or press Enter to go back: "); + if (!choice) return; + try { + switch (choice) { + case "1": + if (await switchProfile(s)) return; + break; + case "2": + await addProfile(s); + break; + case "3": + await removeProfile(s); + break; + case "4": + await listProfiles(s); + break; + case "5": + await setProfileName(s); + break; + default: + fail(s.ui, "Invalid choice."); + await pause(s.ui); + } + } catch (e) { + fail(s.ui, `Action failed: ${(e as Error).message}`); + await pause(s.ui); + } + } +} + +async function pickProfile(s: Session, prompt: string): Promise { + const data = await s.app.readFingerprints(); + if (data.fingerprints.length === 0) { + warn(s.ui, "No profiles."); + await pause(s.ui); + return null; + } + s.ui.say(); + data.fingerprints.forEach((fp, i) => { + const current = fp === s.fingerprint ? `${ansi.green} (current)${ansi.reset}` : ""; + s.ui.say(` ${ansi.cyan}${i + 1}.${ansi.reset} ${fp} ${data.names[fp] ?? ""}${current}`); + }); + s.ui.say(); + const raw = await s.ui.ask(prompt); + if (!raw) return null; + const n = Number(raw); + if (!Number.isInteger(n) || n < 1 || n > data.fingerprints.length) { + fail(s.ui, "Invalid selection."); + await pause(s.ui); + return null; + } + return data.fingerprints[n - 1]!; +} + +async function switchProfile(s: Session): Promise { + title(s, "Main Menu > Settings > Profiles > Switch"); + const fp = await pickProfile(s, "Select a profile or press Enter to go back: "); + if (fp === null || fp === s.fingerprint) return false; + // A different profile is a different seed: unlock it on its own terms + // rather than reusing the seed already in hand. + const mnemonic = await s.relock(fp); + s.vault = await openVault(join(s.app.profileDir(fp), INDEX_FILENAME), mnemonic); + s.fingerprint = fp; + s.name = (await s.app.readFingerprints()).names[fp] ?? null; + s.config = await loadConfig(s.app.profileDir(fp), mnemonic); + await s.app.switchProfile(fp); + ok(s.ui, `Switched to ${s.name ?? fp}.`); + await pause(s.ui); + return true; +} + +async function addProfile(s: Session): Promise { + title(s, "Main Menu > Settings > Profiles > Add"); + menu(s.ui, [ + { key: "1", label: "Generate a new seed" }, + { key: "2", label: "Enter an existing seed phrase" }, + ]); + const how = await s.ui.ask("Select an option or press Enter to go back: "); + if (!how) return; + + let mnemonic: string; + let generated = false; + if (how === "1") { + const words = await askInt(s, "Word count 12 or 24 (default 24): ", { default: 24 }); + if (words !== 12 && words !== 24) { + fail(s.ui, "Word count must be 12 or 24."); + await pause(s.ui); + return; + } + mnemonic = generateMnemonic(words); + generated = true; + } else if (how === "2") { + mnemonic = await s.ui.askHidden("Seed phrase (hidden): "); + if (!mnemonic) return; + } else { + fail(s.ui, "Invalid choice."); + await pause(s.ui); + return; + } + + const name = await s.ui.ask("Profile name (optional): "); + const password = await s.ui.askHidden("Master password for the new profile: "); + const again = await s.ui.askHidden("Confirm password: "); + if (password !== again) { + fail(s.ui, "Passwords do not match. Nothing was created."); + await pause(s.ui); + return; + } + + if (generated) { + // Show it before creating anything: a generated seed that is never seen + // is a vault nobody can recover. + s.ui.say(); + warn(s.ui, "Write this seed phrase down and store it offline."); + warn(s.ui, "It is the ONLY way to recover this profile."); + s.ui.say(); + s.ui.say(`${ansi.bold}${mnemonic}${ansi.reset}`); + s.ui.say(); + await pause(s.ui); + if (!(await confirm(s.ui, "Have you written it down?"))) { + warn(s.ui, "Cancelled. Nothing was created."); + await pause(s.ui); + return; + } + } + + const fp = await s.app.createProfile(mnemonic, password, name || undefined); + ok(s.ui, `Profile ${fp} created.`); + await pause(s.ui); +} + +async function removeProfile(s: Session): Promise { + title(s, "Main Menu > Settings > Profiles > Remove"); + const fp = await pickProfile(s, "Select a profile to REMOVE or press Enter to go back: "); + if (fp === null) return; + warn(s.ui, `This deletes the profile directory for ${fp}.`); + warn(s.ui, "Entries derived from the seed can be recreated; imported secrets cannot."); + const typed = await s.ui.ask(`Type the fingerprint to confirm: `); + if (typed !== fp) { + warn(s.ui, "Fingerprint did not match. Nothing was removed."); + await pause(s.ui); + return; + } + await s.app.removeProfile(fp); + ok(s.ui, `Removed ${fp}.`); + if (fp === s.fingerprint) { + warn(s.ui, "That was the active profile. Restart seedpass-js to pick another."); + await pause(s.ui); + process.exit(0); + } + await pause(s.ui); +} + +async function listProfiles(s: Session): Promise { + title(s, "Main Menu > Settings > Profiles > List"); + const data = await s.app.readFingerprints(); + s.ui.say(); + for (const fp of data.fingerprints) { + const current = fp === s.fingerprint ? `${ansi.green} (current)${ansi.reset}` : ""; + s.ui.say(` ${fp} ${data.names[fp] ?? ""}${current}`); + } + s.ui.say(); + await pause(s.ui); +} + +async function setProfileName(s: Session): Promise { + title(s, "Main Menu > Settings > Profiles > Set Name"); + const fp = await pickProfile(s, "Select a profile or press Enter to go back: "); + if (fp === null) return; + const name = await s.ui.ask("New name (blank to clear): "); + await s.app.setProfileName(fp, name); + if (fp === s.fingerprint) s.name = name || null; + ok(s.ui, "Name saved."); + await pause(s.ui); +} + +// ------------------------------------------------------------------ nostr + +async function nostrMenu(s: Session): Promise { + for (;;) { + title(s, "Main Menu > Settings > Nostr"); + menu(s.ui, [ + { key: "1", label: "Backup to Nostr" }, + { key: "2", label: "Restore from Nostr" }, + { key: "3", label: "View current relays" }, + { key: "4", label: "Add a relay URL" }, + { key: "5", label: "Remove a relay by number" }, + { key: "6", label: "Reset to default relays" }, + { key: "7", label: "Display Nostr Public Key" }, + { key: "8", label: "Reset Nostr sync state" }, + { key: "9", label: "Start fresh Nostr namespace (new key index)" }, + ]); + const choice = await s.ui.ask("Select an option or press Enter to go back: "); + if (!choice) return; + try { + switch (choice) { + case "1": + case "2": + warn( + s.ui, + `Run it from the command line: ${ansi.cyan}seedpass-js nostr ` + + `${choice === "1" ? "sync" : "restore"}${ansi.reset}`, + ); + warn(s.ui, "Relay traffic prints progress that does not belong inside a menu."); + await pause(s.ui); + break; + case "3": + await viewRelays(s); + break; + case "4": + await addRelay(s); + break; + case "5": + await removeRelay(s); + break; + case "6": + await saveSetting(s, "relays", [...DEFAULT_RELAYS]); + ok(s.ui, "Relays reset to defaults."); + await pause(s.ui); + break; + case "7": + await displayNpub(s); + break; + case "8": + await saveSetting(s, "last_sync_ts", 0); + ok(s.ui, "Nostr sync state reset; the next sync republishes everything."); + await pause(s.ui); + break; + case "9": + await startFreshNamespace(s); + break; + default: + fail(s.ui, "Invalid choice."); + await pause(s.ui); + } + } catch (e) { + fail(s.ui, `Action failed: ${(e as Error).message}`); + await pause(s.ui); + } + } +} + +function relaysOf(s: Session): string[] { + const relays = s.config["relays"]; + return Array.isArray(relays) ? (relays as string[]) : [...DEFAULT_RELAYS]; +} + +async function viewRelays(s: Session): Promise { + s.ui.say(); + relaysOf(s).forEach((r, i) => s.ui.say(` ${ansi.cyan}${i + 1}.${ansi.reset} ${r}`)); + s.ui.say(); + await pause(s.ui); +} + +async function addRelay(s: Session): Promise { + const url = await s.ui.ask("Relay URL (wss://...): "); + if (!url) return; + if (!/^wss?:\/\//i.test(url)) { + fail(s.ui, "A relay URL must start with ws:// or wss://."); + await pause(s.ui); + return; + } + const relays = relaysOf(s); + if (relays.includes(url)) { + warn(s.ui, "That relay is already configured."); + await pause(s.ui); + return; + } + await saveSetting(s, "relays", [...relays, url]); + ok(s.ui, "Relay added."); + await pause(s.ui); +} + +async function removeRelay(s: Session): Promise { + const relays = relaysOf(s); + s.ui.say(); + relays.forEach((r, i) => s.ui.say(` ${ansi.cyan}${i + 1}.${ansi.reset} ${r}`)); + s.ui.say(); + const n = await askInt(s, "Remove which number? ", { min: 1, max: relays.length }); + if (n === null) return; + if (relays.length === 1 && !(await confirm(s.ui, "That is the last relay. Remove it anyway?"))) { + return; + } + await saveSetting(s, "relays", relays.filter((_, i) => i !== n - 1)); + ok(s.ui, "Relay removed."); + await pause(s.ui); +} + +async function displayNpub(s: Session): Promise { + const keys = deriveNostrKeys(Bip85.fromMnemonic(s.vault.mnemonic)); + s.ui.say(); + s.ui.say(` ${ansi.dim}npub${ansi.reset} ${keys.npub}`); + s.ui.say(` ${ansi.dim}hex ${ansi.reset} ${keys.publicKeyHex}`); + s.ui.say(); + await pause(s.ui); +} + +async function startFreshNamespace(s: Session): Promise { + warn(s.ui, "This moves sync to a new Nostr key index."); + warn(s.ui, "Snapshots under the old index stay on the relays but are no longer used."); + if (!(await confirm(s.ui, "Continue?"))) return; + const current = Number(s.config["nostr_key_index"] ?? 0); + await saveSetting(s, "nostr_key_index", current + 1); + await saveSetting(s, "last_sync_ts", 0); + ok(s.ui, `Now using Nostr key index ${current + 1}. Run 'seedpass-js nostr sync' to publish.`); + await pause(s.ui); +} + +// ------------------------------------------------------- settings actions + +async function changePassword(s: Session): Promise { + title(s, "Main Menu > Settings > Change password"); + const oldPw = await s.ui.askHidden("Current password: "); + if (!oldPw) return; + const newPw = await s.ui.askHidden("New password: "); + if (!newPw) return; + const again = await s.ui.askHidden("Confirm new password: "); + if (newPw !== again) { + fail(s.ui, "Passwords do not match. Nothing was changed."); + await pause(s.ui); + return; + } + try { + await s.app.changePassword(s.fingerprint, oldPw, newPw); + ok(s.ui, "Password changed."); + } catch { + // Indistinguishable from any other failure on purpose: the only useful + // signal here is "that password was wrong". + fail(s.ui, "Incorrect password."); + } + await pause(s.ui); +} + +async function backupParentSeed(s: Session): Promise { + title(s, "Main Menu > Settings > Backup Parent Seed"); + warn(s.ui, "This displays the master seed phrase for this profile."); + warn(s.ui, "Anyone who sees it owns the vault."); + if (!(await confirm(s.ui, "Show it?"))) return; + const password = await s.ui.askHidden("Master password: "); + if (!password) return; + try { + const mnemonic = await s.app.decryptParentSeed(s.fingerprint, password); + s.ui.say(); + s.ui.say(`${ansi.bold}${mnemonic}${ansi.reset}`); + s.ui.say(); + } catch { + fail(s.ui, "Incorrect password."); + } + await pause(s.ui); +} + +async function exportDatabase(s: Session): Promise { + title(s, "Main Menu > Settings > Export database"); + const dest = await s.ui.ask("Destination file: "); + if (!dest) return; + const payload = await exportBackup(s.vault.index as unknown as Record, { + mnemonic: s.vault.mnemonic, + fingerprint: s.fingerprint, + }); + await writeFile(dest, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 }); + ok(s.ui, `Exported to ${dest}`); + await pause(s.ui); +} + +async function importDatabase(s: Session): Promise { + title(s, "Main Menu > Settings > Import database"); + const src = await s.ui.ask("Path to backup file: "); + if (!src) return; + let raw: string; + try { + raw = await readFile(src, "utf8"); + } catch { + fail(s.ui, `Import failed: file '${src}' not found.`); + await pause(s.ui); + return; + } + const imported = (await importBackup(raw, { mnemonic: s.vault.mnemonic })) as { + entries?: Record; + schema_version?: number; + }; + const entries = imported.entries ?? {}; + const count = Object.keys(entries).length; + warn(s.ui, `This REPLACES the current vault (${Object.keys(s.vault.index.entries).length} entries)`); + warn(s.ui, `with the backup's ${count} entries.`); + if (!(await confirm(s.ui, "Continue?"))) return; + await mutate(s, (v) => { + v.index.entries = entries as VaultIndex["entries"]; + v.index.schema_version = Number(imported.schema_version ?? v.index.schema_version); + }); + ok(s.ui, `Imported ${count} entries.`); + await pause(s.ui); +} + +async function exportTotpCodes(s: Session): Promise { + title(s, "Main Menu > Settings > Export 2FA codes"); + const dest = await s.ui.ask("Destination file: "); + if (!dest) return; + const rows = entriesOf(s.vault.index).filter( + ({ entry }) => kindOf(entry) === "totp" && !isArchived(entry), + ); + const uris = rows.map(({ entry }) => ({ + label: String((entry as unknown as Record)["label"] ?? ""), + uri: totpUri(entry, s.vault.mnemonic), + })); + // 0600: this file is every 2FA secret in the vault, in plaintext. + await writeFile(dest, JSON.stringify({ entries: uris }, null, 2), { + encoding: "utf8", + mode: 0o600, + }); + ok(s.ui, `Exported ${uris.length} 2FA entries to ${dest}`); + warn(s.ui, "That file contains the secrets themselves. Move it somewhere safe or delete it."); + await pause(s.ui); +} + +async function setBackupLocation(s: Session): Promise { + title(s, "Main Menu > Settings > Additional backup location"); + s.ui.say(`Current: ${String(s.config["additional_backup_path"] || "(none)")}`); + const path = await s.ui.ask("New path (blank to clear): "); + await saveSetting(s, "additional_backup_path", path); + ok(s.ui, path ? `Additional backups will be written to ${path}` : "Additional backup cleared."); + await pause(s.ui); +} + +async function kdfSettings(s: Session): Promise { + title(s, "Main Menu > Settings > KDF strength"); + s.ui.say(`Current iterations: ${String(s.config["kdf_iterations"] ?? 200000)}`); + s.ui.say(`Current mode: ${String(s.config["kdf_mode"] ?? "pbkdf2")}`); + s.ui.say(); + if (await confirm(s.ui, "Benchmark this machine?")) { + const { deriveKeyFromPassword } = await import("@seedpass/core"); + for (const iters of [100_000, 200_000, 400_000]) { + const started = Date.now(); + deriveKeyFromPassword("benchmark", "benchmark-salt", iters); + s.ui.say(` ${String(iters).padStart(7)} iterations: ${Date.now() - started} ms`); + } + s.ui.say(); + } + const iterations = await askInt(s, "New iteration count (blank to keep): ", { min: 50_000 }); + if (iterations === null) { + await pause(s.ui); + return; + } + await saveSetting(s, "kdf_iterations", iterations); + warn( + s.ui, + "Saved. This applies to profiles created from now on; use Change password " + + "to re-wrap this profile's seed at the new strength.", + ); + await pause(s.ui); +} + +async function setInactivityTimeout(s: Session): Promise { + title(s, "Main Menu > Settings > Inactivity timeout"); + s.ui.say(`Current: ${String(s.config["inactivity_timeout"] ?? 900)} seconds`); + const seconds = await askInt(s, "New timeout in seconds: ", { min: 30 }); + if (seconds === null) { + await pause(s.ui); + return; + } + await saveSetting(s, "inactivity_timeout", seconds); + ok(s.ui, `Inactivity timeout set to ${seconds} seconds.`); + await pause(s.ui); +} + +async function lockVault(s: Session): Promise { + warn(s.ui, "Vault locked. Please re-enter your password."); + const mnemonic = await s.relock(s.fingerprint); + s.vault = await openVault(s.vault.path, mnemonic); + ok(s.ui, "Unlocked."); + await pause(s.ui); +} + +async function showStats(s: Session): Promise { + title(s, "Main Menu > Settings > Stats"); + await reload(s); + const rows = entriesOf(s.vault.index); + const counts = new Map(); + let archived = 0; + for (const { entry } of rows) { + counts.set(kindOf(entry), (counts.get(kindOf(entry)) ?? 0) + 1); + if (isArchived(entry)) archived++; + } + s.ui.say(`\n${ansi.bold}=== Seed Profile Stats ===${ansi.reset}`); + s.ui.say(`Total entries: ${rows.length}`); + for (const kind of [...counts.keys()].sort()) { + s.ui.say(` ${kind}: ${counts.get(kind)}`); + } + s.ui.say(`Archived: ${archived}`); + s.ui.say(`Relays configured: ${relaysOf(s).length}`); + s.ui.say(`Schema version: ${String(s.vault.index.schema_version)}`); + s.ui.say(`Profile: ${s.fingerprint}${s.name ? ` (${s.name})` : ""}`); + s.ui.say(`Secret Mode: ${secretModeOn(s) ? "ON" : "OFF"}`); + s.ui.say(`Offline Mode: ${s.config["offline_mode"] ? "ON" : "OFF"}`); + s.ui.say(); + await pause(s.ui); +} + +export { totpCodeAt }; diff --git a/js/packages/cli/src/tui/terminal.ts b/js/packages/cli/src/tui/terminal.ts deleted file mode 100644 index 2e301203..00000000 --- a/js/packages/cli/src/tui/terminal.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Terminal control for the interactive mode: raw input, the alternate screen, - * and — above all — putting the terminal back the way we found it. - * - * Two properties this file is responsible for: - * - * 1. **The shell survives.** Raw mode with the cursor hidden is a broken - * terminal: no echo, no line editing, no visible cursor. If we exit - * without restoring — including on a crash or a signal — the user's shell - * is left unusable and `reset` is the only way out. Restoration is - * therefore idempotent and wired to every exit path there is. - * - * 2. **Secrets do not reach scrollback.** Everything is drawn on the - * alternate screen buffer, which the terminal discards on exit. A revealed - * password cannot be recovered by scrolling up after quitting, and never - * lands in a terminal-logging setup that captures the main buffer. - * - * No dependencies: this is a security tool, and a TUI framework would pull a - * React runtime and its transitive tree into the process that handles seed - * phrases. - */ - -import process from "node:process"; -import type { ReadStream } from "node:tty"; - -export const ESC = "\x1b"; -const CSI = `${ESC}[`; - -export const ansi = { - reset: `${CSI}0m`, - bold: `${CSI}1m`, - dim: `${CSI}2m`, - italic: `${CSI}3m`, - underline: `${CSI}4m`, - reverse: `${CSI}7m`, - red: `${CSI}31m`, - green: `${CSI}32m`, - yellow: `${CSI}33m`, - blue: `${CSI}34m`, - magenta: `${CSI}35m`, - cyan: `${CSI}36m`, - grey: `${CSI}90m`, -} as const; - -/** Strip SGR sequences so width maths counts glyphs, not escape bytes. */ -export function visibleLength(s: string): number { - // eslint-disable-next-line no-control-regex - return s.replace(/\x1b\[[0-9;]*m/g, "").length; -} - -export interface Key { - /** Symbolic name for non-printing keys, else "char". */ - name: - | "char" - | "up" - | "down" - | "left" - | "right" - | "enter" - | "backspace" - | "delete" - | "tab" - | "escape" - | "home" - | "end" - | "pageup" - | "pagedown" - | "ctrl-c" - | "ctrl-d" - | "unknown"; - /** The character, for name === "char". */ - ch: string; -} - -/** - * Decode one input chunk into keys. - * - * A chunk can hold several keypresses (fast typing, or a paste), so this - * returns a list rather than a single key — dropping the remainder would - * silently swallow input. - */ -export function decodeKeys(data: string): Key[] { - const keys: Key[] = []; - let i = 0; - while (i < data.length) { - const ch = data[i]!; - - if (ch === ESC) { - const rest = data.slice(i); - const seq = matchEscape(rest); - if (seq) { - keys.push(seq.key); - i += seq.length; - continue; - } - keys.push({ name: "escape", ch: "" }); - i += 1; - continue; - } - - switch (ch) { - case "\x03": - keys.push({ name: "ctrl-c", ch: "" }); - break; - case "\x04": - keys.push({ name: "ctrl-d", ch: "" }); - break; - case "\r": - case "\n": - keys.push({ name: "enter", ch: "" }); - break; - case "\x7f": - case "\b": - keys.push({ name: "backspace", ch: "" }); - break; - case "\t": - keys.push({ name: "tab", ch: "" }); - break; - default: - // Ignore remaining C0 controls; they are not text and have no binding. - if (ch >= " ") keys.push({ name: "char", ch }); - break; - } - i += 1; - } - return keys; -} - -function matchEscape(s: string): { key: Key; length: number } | null { - const table: Record = { - "[A": "up", - "[B": "down", - "[C": "right", - "[D": "left", - "[H": "home", - "[F": "end", - "OA": "up", - "OB": "down", - "OC": "right", - "OD": "left", - "OH": "home", - "OF": "end", - "[5~": "pageup", - "[6~": "pagedown", - "[3~": "delete", - "[1~": "home", - "[4~": "end", - "[7~": "home", - "[8~": "end", - }; - for (const [seq, name] of Object.entries(table)) { - if (s.startsWith(ESC + seq)) return { key: { name, ch: "" }, length: seq.length + 1 }; - } - return null; -} - -export class Terminal { - private restored = false; - private readonly cleanup: () => void; - private onKeyHandler: ((key: Key) => void) | null = null; - private onResizeHandler: (() => void) | null = null; - private readonly stdin: ReadStream; - - constructor() { - this.stdin = process.stdin as ReadStream; - this.cleanup = () => this.restore(); - } - - static isInteractive(): boolean { - return Boolean(process.stdin.isTTY && process.stdout.isTTY); - } - - get columns(): number { - return process.stdout.columns ?? 80; - } - - get rows(): number { - return process.stdout.rows ?? 24; - } - - start(): void { - // Alternate screen first, so nothing we draw touches the scrollback the - // user keeps. - this.write(`${CSI}?1049h${CSI}?25l`); - this.stdin.setRawMode?.(true); - this.stdin.resume(); - this.stdin.setEncoding("utf8"); - - this.stdin.on("data", this.handleData); - process.stdout.on("resize", this.handleResize); - - // Every path out of the process restores the terminal. "exit" covers - // normal returns and process.exit; the signals cover Ctrl-C at the OS - // level and `kill`; the exception handlers cover our own bugs, which is - // exactly when a broken terminal is least welcome. - process.on("exit", this.cleanup); - process.on("SIGINT", this.onSignal); - process.on("SIGTERM", this.onSignal); - process.on("SIGHUP", this.onSignal); - process.on("uncaughtException", this.onFatal); - process.on("unhandledRejection", this.onFatal); - } - - private readonly handleData = (chunk: string): void => { - if (!this.onKeyHandler) return; - for (const key of decodeKeys(chunk)) this.onKeyHandler(key); - }; - - private readonly handleResize = (): void => { - this.onResizeHandler?.(); - }; - - private readonly onSignal = (): void => { - this.restore(); - process.exit(130); - }; - - private readonly onFatal = (err: unknown): void => { - this.restore(); - // Report on the real screen, after the alternate one is gone, or the - // message is discarded along with it. - process.stderr.write( - `seedpass-js: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`, - ); - process.exit(1); - }; - - onKey(handler: (key: Key) => void): void { - this.onKeyHandler = handler; - } - - onResize(handler: () => void): void { - this.onResizeHandler = handler; - } - - write(s: string): void { - process.stdout.write(s); - } - - /** Redraw the whole frame. Cheap enough at this size, and never tears. */ - draw(lines: string[]): void { - const rows = this.rows; - const visible = lines.slice(0, rows); - // Home, then clear each line as we go: clearing the whole screen first - // makes the display flicker on slower terminals. - let out = `${CSI}H`; - for (let i = 0; i < rows; i++) { - out += `${CSI}${i + 1};1H${CSI}2K`; - if (i < visible.length) out += visible[i]; - } - this.write(out); - } - - restore(): void { - if (this.restored) return; - this.restored = true; - try { - this.stdin.setRawMode?.(false); - this.stdin.pause(); - this.stdin.removeListener("data", this.handleData); - process.stdout.removeListener("resize", this.handleResize); - process.removeListener("exit", this.cleanup); - process.removeListener("SIGINT", this.onSignal); - process.removeListener("SIGTERM", this.onSignal); - process.removeListener("SIGHUP", this.onSignal); - process.removeListener("uncaughtException", this.onFatal); - process.removeListener("unhandledRejection", this.onFatal); - } finally { - // Show the cursor and leave the alternate screen even if unwinding the - // listeners threw: this is the part the user's shell depends on. - this.write(`${CSI}?25h${CSI}?1049l`); - } - } -} diff --git a/js/packages/cli/test/tui.test.ts b/js/packages/cli/test/tui.test.ts deleted file mode 100644 index 91070f04..00000000 --- a/js/packages/cli/test/tui.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Interactive-mode input decoding and layout helpers. - * - * The screens themselves are exercised through a real pty in - * `test/tui-pty.test.ts`; this file covers the pure logic, where the - * interesting failures are silent: a dropped keypress, or a width - * calculation that counts escape bytes as visible characters and wraps every - * highlighted row. - */ - -import { describe, expect, it } from "vitest"; -import { decodeKeys, visibleLength, ansi, ESC } from "../src/tui/terminal.js"; - -describe("decodeKeys", () => { - it("decodes printable characters", () => { - expect(decodeKeys("abc")).toEqual([ - { name: "char", ch: "a" }, - { name: "char", ch: "b" }, - { name: "char", ch: "c" }, - ]); - }); - - it("decodes arrow keys in both cursor modes", () => { - // Terminals send SS3 (ESC O A) in application cursor mode and CSI - // (ESC [ A) otherwise; handling only one leaves arrows dead in some - // terminals and not others. - expect(decodeKeys(`${ESC}[A`)[0]!.name).toBe("up"); - expect(decodeKeys(`${ESC}OA`)[0]!.name).toBe("up"); - expect(decodeKeys(`${ESC}[B`)[0]!.name).toBe("down"); - expect(decodeKeys(`${ESC}[D`)[0]!.name).toBe("left"); - }); - - it("decodes navigation and editing keys", () => { - expect(decodeKeys("\r")[0]!.name).toBe("enter"); - expect(decodeKeys("\n")[0]!.name).toBe("enter"); - expect(decodeKeys("\x7f")[0]!.name).toBe("backspace"); - expect(decodeKeys("\t")[0]!.name).toBe("tab"); - expect(decodeKeys("\x03")[0]!.name).toBe("ctrl-c"); - expect(decodeKeys(`${ESC}[5~`)[0]!.name).toBe("pageup"); - expect(decodeKeys(`${ESC}[6~`)[0]!.name).toBe("pagedown"); - expect(decodeKeys(`${ESC}[H`)[0]!.name).toBe("home"); - }); - - it("treats a lone escape as escape", () => { - expect(decodeKeys(ESC)).toEqual([{ name: "escape", ch: "" }]); - }); - - it("decodes every keypress in a chunk, not just the first", () => { - // Fast typing and pastes arrive as one chunk. Returning a single key - // silently drops the rest of what the user typed. - const keys = decodeKeys(`ab${ESC}[Bc\r`); - expect(keys.map((k) => k.name)).toEqual(["char", "char", "down", "char", "enter"]); - expect(keys.map((k) => k.ch).join("")).toBe("abc"); - }); - - it("ignores control bytes that are not bound to anything", () => { - expect(decodeKeys("\x01\x02")).toEqual([]); - }); - - it("reports editing keys in sequence rather than applying them", () => { - // The decoder's job is to say what was pressed; the screen decides what - // a backspace means. Keeping that split is why the same decoder serves - // the search box and the add form. - const keys = decodeKeys("git\x7fhub"); - expect(keys.map((k) => k.name)).toEqual([ - "char", - "char", - "char", - "backspace", - "char", - "char", - "char", - ]); - }); -}); - -describe("visibleLength", () => { - it("counts glyphs, not escape sequences", () => { - expect(visibleLength("abc")).toBe(3); - expect(visibleLength(`${ansi.bold}abc${ansi.reset}`)).toBe(3); - expect(visibleLength(`${ansi.dim}${ansi.red}x${ansi.reset}`)).toBe(1); - }); - - it("is what keeps a highlighted row from wrapping", () => { - // A styled row is much longer in bytes than on screen; padding to a - // terminal width using raw .length would overflow every line. - const styled = `${ansi.reverse}${ansi.cyan}entry${ansi.reset}`; - expect(styled.length).toBeGreaterThan(visibleLength(styled)); - expect(visibleLength(styled)).toBe(5); - }); -}); diff --git a/js/packages/cli/test/tuiMenus.test.ts b/js/packages/cli/test/tuiMenus.test.ts new file mode 100644 index 00000000..30d6561e --- /dev/null +++ b/js/packages/cli/test/tuiMenus.test.ts @@ -0,0 +1,391 @@ +/** + * The interactive menus, driven by a scripted UI. + * + * Two things are being checked. First, that the menu tree matches Python's + * legacy TUI — the items, their order, and "blank goes back" — since that is + * the navigation the port exists to reproduce. Second, that the agent-blind + * rule survives the move to a screen that shows the whole vault: listings + * carry metadata only, and a stored secret appears solely through the action + * whose purpose is to produce it. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, writeFile, mkdir, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { mnemonics } from "@seedpass/test-vectors"; +import { + generateFingerprint, + deriveIndexKeyBytes, + encryptV3, + utf8, + addPasswordEntry, + addKeyValueEntry, + addTotpDeterministic, + type VaultIndex, +} from "@seedpass/core"; +import { runTui } from "../src/tui/app.js"; +import { AppDir, INDEX_FILENAME } from "../src/appDir.js"; +import { openVault } from "../src/vaultFile.js"; +import type { Ui } from "../src/tui/console.js"; + +const MNEMONIC = mnemonics["abandon12"]!; +const FINGERPRINT = generateFingerprint(MNEMONIC); +const STORED_SECRET = "stored-ci-token-value"; + +let appDir: string; + +/** + * A UI that answers prompts from a script. + * + * Running out of answers ends the session rather than hanging: a test that + * mis-counts its inputs should fail with the transcript in hand, not time out. + */ +class ScriptedUi implements Ui { + readonly lines: string[] = []; + readonly prompts: string[] = []; + private i = 0; + + constructor(private readonly answers: string[]) {} + + say(line = ""): void { + this.lines.push(line); + } + clear(): void { + this.lines.push("\f"); + } + async ask(prompt: string): Promise { + this.prompts.push(prompt); + if (this.i >= this.answers.length) { + // Blank is "go back"/"exit" everywhere, so this unwinds the menus. + return ""; + } + const answer = this.answers[this.i++]!; + this.lines.push(`${prompt}${answer}`); + return answer; + } + async askHidden(prompt: string): Promise { + const answer = await this.ask(prompt); + // Record that it was asked, never what was typed. + this.lines[this.lines.length - 1] = `${prompt}`; + return answer; + } + + /** Everything drawn, with styling removed so assertions read plainly. */ + get text(): string { + // eslint-disable-next-line no-control-regex + return this.lines.join("\n").replace(/\x1b\[[0-9;]*m/g, ""); + } + /** Everything drawn since the last screen clear. */ + get screen(): string { + const last = this.lines.lastIndexOf("\f"); + // eslint-disable-next-line no-control-regex + return this.lines.slice(last + 1).join("\n").replace(/\x1b\[[0-9;]*m/g, ""); + } +} + +async function run(...answers: string[]): Promise { + const ui = new ScriptedUi(answers); + await runTui({ appDir }, ui); + return ui; +} + +beforeEach(async () => { + appDir = await mkdtemp(join(tmpdir(), "seedpass-menus-")); + const app = new AppDir(appDir); + const index = { schema_version: 4, entries: {} } as VaultIndex; + addPasswordEntry(index, "github.com", 20, { username: "adam" }); + addPasswordEntry(index, "gitlab.com", 16, {}); + addKeyValueEntry(index, "ci-token", "CI_TOKEN", STORED_SECRET); + addTotpDeterministic(index, "email-2fa", MNEMONIC); + + await app.mutateFingerprints((data) => { + data.fingerprints.push(FINGERPRINT); + data.names[FINGERPRINT] = "daily"; + data.last_used = FINGERPRINT; + }); + const dir = app.profileDir(FINGERPRINT); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, INDEX_FILENAME), + await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))), + ); + process.env["SEEDPASS_MNEMONIC"] = MNEMONIC; +}); + +afterEach(() => { + delete process.env["SEEDPASS_MNEMONIC"]; +}); + +describe("main menu", () => { + it("offers Python's eight items, in Python's order", async () => { + const ui = await run(); + for (const [n, label] of [ + ["1", "Add Entry"], + ["2", "Retrieve Entry"], + ["3", "Search Entries"], + ["4", "List Entries"], + ["5", "Modify an Existing Entry"], + ["6", "2FA Codes"], + ["7", "Settings"], + ["8", "List Archived"], + ] as const) { + expect(ui.text).toContain(`${n}.`); + expect(ui.text).toContain(label); + } + }); + + it("exits on a blank choice", async () => { + const ui = new ScriptedUi([""]); + expect(await runTui({ appDir }, ui)).toBe(0); + }); + + it("rejects an unknown choice without leaving the menu", async () => { + const ui = await run("99", ""); + expect(ui.text).toContain("Invalid choice"); + }); +}); + +describe("settings", () => { + it("lists all eighteen items in Python's order", async () => { + const ui = await run("7"); + const expected = [ + "Profiles", + "Nostr", + "Change password", + "Verify Script Checksum", + "Generate Script Checksum", + "Backup Parent Seed", + "Export database", + "Import database", + "Export 2FA codes", + "Set additional backup location", + "KDF strength & benchmark", + "Set inactivity timeout", + "Lock Vault", + "Stats", + "Toggle Secret Mode", + "Toggle Offline Mode", + "Toggle Quick Unlock", + "Semantic Index", + ]; + for (const label of expected) expect(ui.text).toContain(label); + // Numbering must line up with Python's, or muscle memory picks the wrong + // item — 13 is Lock Vault there and must be here too. + expect(ui.text).toMatch(/13\.\s+Lock Vault/); + expect(ui.text).toMatch(/18\.\s+Semantic Index/); + }); + + it("opens the Profiles submenu with its five items", async () => { + const ui = await run("7", "1"); + for (const label of [ + "Switch Seed Profile", + "Add a New Seed Profile", + "Remove an Existing Seed Profile", + "List All Seed Profiles", + "Set Seed Profile Name", + ]) { + expect(ui.text).toContain(label); + } + }); + + it("opens the Nostr submenu with its nine items", async () => { + const ui = await run("7", "2"); + for (const label of [ + "Backup to Nostr", + "Restore from Nostr", + "View current relays", + "Add a relay URL", + "Remove a relay by number", + "Reset to default relays", + "Display Nostr Public Key", + "Reset Nostr sync state", + "Start fresh Nostr namespace", + ]) { + expect(ui.text).toContain(label); + } + }); + + it("toggles Secret Mode and persists it", async () => { + const ui = await run("7", "15", "", "", ""); + expect(ui.text).toContain("Secret Mode is now ON"); + const { loadConfig } = await import("../src/configFile.js"); + const cfg = await loadConfig(new AppDir(appDir).profileDir(FINGERPRINT), MNEMONIC); + expect(cfg["secret_mode_enabled"]).toBe(true); + }); + + it("reports stats without printing any stored secret", async () => { + const ui = await run("7", "14", ""); + expect(ui.text).toContain("Total entries: 4"); + expect(ui.text).toContain("password: 2"); + expect(ui.text).toContain("key_value: 1"); + expect(ui.text).not.toContain(STORED_SECRET); + }); + + it("adds and removes a relay", async () => { + const added = await run("7", "2", "4", "wss://relay.example", "", "", "", ""); + expect(added.text).toContain("Relay added"); + const { loadConfig } = await import("../src/configFile.js"); + const cfg = await loadConfig(new AppDir(appDir).profileDir(FINGERPRINT), MNEMONIC); + expect(cfg["relays"]).toContain("wss://relay.example"); + }); + + it("refuses a relay URL that is not a websocket URL", async () => { + const ui = await run("7", "2", "4", "https://not-a-relay.example", "", "", "", ""); + expect(ui.text).toContain("must start with ws:// or wss://"); + }); +}); + +describe("listing and details", () => { + it("lists entries by index, type and label", async () => { + const ui = await run("4", "1", ""); + expect(ui.text).toContain("0."); + expect(ui.text).toContain("Password - github.com"); + expect(ui.text).toContain("Key Value - ci-token"); + // The stored secret must not be in a listing. + expect(ui.text).not.toContain(STORED_SECRET); + }); + + it("filters the list by entry type", async () => { + // 4 = List Entries, 8 = Key Value (1 is All, then the nine types in order). + const ui = await run("4", "8", ""); + expect(ui.text).toContain("ci-token"); + expect(ui.text).not.toContain("github.com"); + }); + + it("shows entry details with stored secrets reduced to flags", async () => { + const ui = await run("4", "1", "2", ""); + expect(ui.text).toContain("ci-token"); + expect(ui.text).toContain("has_value"); + expect(ui.text).not.toContain(STORED_SECRET); + }); + + it("offers the entry actions Python offers", async () => { + const ui = await run("4", "1", "0", ""); + for (const label of [ + "Archive", + "Add Note", + "Add Custom Field", + "Add Hidden Field", + "Edit", + "Edit Tags", + ]) { + expect(ui.text).toContain(label); + } + }); + + it("shows a stored secret only when asked", async () => { + const before = await run("4", "1", "2", ""); + expect(before.text).not.toContain(STORED_SECRET); + const after = await run("4", "1", "2", "s", "", ""); + expect(after.text).toContain(STORED_SECRET); + }); + + it("offers document export only for documents", async () => { + const ui = await run("4", "1", "0", ""); + expect(ui.text).not.toContain("Export Document to File"); + }); +}); + +describe("search and retrieve", () => { + it("finds entries by label", async () => { + const ui = await run("3", "git", ""); + expect(ui.text).toContain("github.com"); + expect(ui.text).toContain("gitlab.com"); + expect(ui.text).not.toContain("ci-token"); + }); + + it("says so when nothing matches", async () => { + const ui = await run("3", "nothing-matches-this", ""); + expect(ui.text).toContain("No matching entries found"); + }); + + it("retrieves by index and by label", async () => { + expect((await run("2", "0", "")).text).toContain("github.com"); + expect((await run("2", "ci-token", "")).text).toContain("CI_TOKEN"); + }); +}); + +describe("mutations", () => { + async function reopen(): Promise { + const vault = await openVault( + join(new AppDir(appDir).profileDir(FINGERPRINT), INDEX_FILENAME), + MNEMONIC, + ); + return vault.index; + } + + it("adds a password entry through the menus", async () => { + // 1 Add Entry, 1 Password, then label/length/username/url/notes/tags. + await run("1", "1", "new-site.example", "24", "bob", "", "", "work,prod", "", ""); + const index = await reopen(); + const added = Object.values(index.entries).find((e) => e.label === "new-site.example"); + expect(added).toBeTruthy(); + expect(added!["length"]).toBe(24); + expect(added!["username"]).toBe("bob"); + expect(added!["tags"]).toEqual(["work", "prod"]); + }); + + it("adds a key/value entry without echoing the value", async () => { + const ui = await run("1", "7", "deploy", "DEPLOY_TOKEN", "sup3r-s3cret", "", ""); + expect(ui.text).not.toContain("sup3r-s3cret"); + const index = await reopen(); + const added = Object.values(index.entries).find((e) => e.label === "deploy"); + expect(added!["value"]).toBe("sup3r-s3cret"); + }); + + it("refuses a non-numeric password length rather than storing NaN", async () => { + const ui = await run("1", "1", "bad", "not-a-number", "", ""); + expect(ui.text).toContain("whole number"); + const index = await reopen(); + expect(Object.values(index.entries).find((e) => e.label === "bad")).toBeUndefined(); + }); + + it("archives an entry and lists it under List Archived", async () => { + await run("4", "1", "0", "a", "", "", "", ""); + const index = await reopen(); + expect(index.entries["0"]!["archived"]).toBe(true); + const archived = await run("8", ""); + expect(archived.text).toContain("github.com"); + }); + + it("edits a label through Edit", async () => { + await run("4", "1", "0", "e", "l", "renamed.example", "", "", "", ""); + const index = await reopen(); + expect(index.entries["0"]!.label).toBe("renamed.example"); + }); + + it("adds a note", async () => { + await run("4", "1", "0", "n", "a useful note", "", "", "", ""); + const index = await reopen(); + expect(index.entries["0"]!["notes"]).toBe("a useful note"); + }); + + it("adds a hidden custom field without echoing its value", async () => { + const ui = await run("4", "1", "0", "h", "recovery", "hidden-field-value", "", "", "", ""); + expect(ui.text).not.toContain("hidden-field-value"); + const index = await reopen(); + const fields = index.entries["0"]!["custom_fields"] as Record[]; + expect(fields).toEqual([ + { label: "recovery", value: "hidden-field-value", is_hidden: true }, + ]); + }); + + it("exports the database to a file", async () => { + const dest = join(appDir, "backup.json"); + const ui = await run("7", "7", dest, "", "", ""); + expect(ui.text).toContain("Exported to"); + const wrapper = JSON.parse(await readFile(dest, "utf8")); + expect(wrapper.format_version).toBe(1); + }); +}); + +describe("2FA codes", () => { + it("shows a code for each TOTP entry", async () => { + const ui = await run("6", "", ""); + expect(ui.text).toContain("email-2fa"); + expect(ui.text).toMatch(/\d{6}/); + expect(ui.text).toContain("s left"); + }); +}); diff --git a/js/packages/cli/test/tuiScreens.test.ts b/js/packages/cli/test/tuiScreens.test.ts deleted file mode 100644 index 15983302..00000000 --- a/js/packages/cli/test/tuiScreens.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * The interactive screens, driven through a fake terminal. - * - * The load-bearing assertions are the same agent-blind properties the CLI is - * held to (plan section 9.3), because the TUI is now the default entry point - * and renders the whole vault by itself: - * - * - no screen shows a secret until `r` is pressed - * - `c` copies without ever drawing the value - * - dismissing a reveal removes the value from the next frame - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import process from "node:process"; -import { mnemonics } from "@seedpass/test-vectors"; -import { - generateFingerprint, - deriveIndexKeyBytes, - encryptV3, - utf8, - addPasswordEntry, - addKeyValueEntry, - type VaultIndex, -} from "@seedpass/core"; -import { runTui, type TerminalLike } from "../src/tui/app.js"; -import { decodeKeys, visibleLength, type Key } from "../src/tui/terminal.js"; -import { AppDir, INDEX_FILENAME } from "../src/appDir.js"; - -const MNEMONIC = mnemonics["abandon12"]!; -const FINGERPRINT = generateFingerprint(MNEMONIC); -const SECRET_VALUE = "tui-stored-secret-value"; - -let appDir: string; - -/** A terminal that records frames and lets a test press keys. */ -class FakeTerminal implements TerminalLike { - readonly columns = 100; - readonly rows = 30; - frames: string[][] = []; - private handler: ((key: Key) => void) | null = null; - restored = false; - - start(): void {} - restore(): void { - this.restored = true; - } - onKey(handler: (key: Key) => void): void { - this.handler = handler; - } - onResize(): void {} - draw(lines: string[]): void { - this.frames.push(lines); - } - - /** Feed keystrokes the way the real decoder would produce them. */ - async press(input: string): Promise { - for (const key of decodeKeys(input)) { - // Sample the frame count BEFORE dispatching: a key handled entirely - // synchronously (dismissing the reveal or help overlay) has already - // drawn by the time the handler returns, and sampling afterwards made - // settle() wait out its deadline for a second frame that never came. - const before = this.frames.length; - this.handler?.(key); - await this.settle(before); - } - } - - /** - * Wait for the keypress to actually reach the screen, then for the screen - * to stop changing. - * - * Waiting only for "no change for a while" is not enough, and produced a - * genuinely confusing hang: a mutation reopens the vault, which re-derives - * the index key through PBKDF2 and takes about a second, during which - * nothing is drawn at all. A quiet-only wait returned immediately, the next - * keystroke landed on the screen that was still up, and the test typed its - * "q" into a form field instead of quitting. - * - * Every handled key ends in a render, so waiting for one new frame is the - * reliable signal. Quitting is the exception — it restores the terminal - * instead of drawing — hence the second exit condition. - */ - private async settle(before: number, quietMs = 60, timeoutMs = 15_000): Promise { - const deadline = Date.now() + timeoutMs; - while (this.frames.length === before && !this.restored && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 10)); - } - let seen = this.frames.length; - let quietSince = Date.now(); - while (Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 10)); - if (this.frames.length !== seen) { - seen = this.frames.length; - quietSince = Date.now(); - continue; - } - if (Date.now() - quietSince >= quietMs) return; - } - } - - get lastFrame(): string { - return (this.frames.at(-1) ?? []).join("\n"); - } - - /** Everything ever drawn — for "this never appeared on screen" checks. */ - get everything(): string { - return this.frames.flat().join("\n"); - } -} - -async function buildVault(): Promise { - appDir = await mkdtemp(join(tmpdir(), "seedpass-tui-")); - const app = new AppDir(appDir); - const index: VaultIndex = { schema_version: 4, entries: {} } as VaultIndex; - addPasswordEntry(index, "github.com", 20, { username: "adam" }); - addPasswordEntry(index, "gitlab.com", 16, {}); - addKeyValueEntry(index, "ci-token", "CI_TOKEN", SECRET_VALUE); - addPasswordEntry(index, "aws-prod", 24, {}); - - const dir = app.profileDir(FINGERPRINT); - await app.mutateFingerprints((data) => { - data.fingerprints.push(FINGERPRINT); - data.names[FINGERPRINT] = "daily"; - data.last_used = FINGERPRINT; - }); - const { mkdir } = await import("node:fs/promises"); - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, INDEX_FILENAME), - await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))), - ); -} - -/** Start the TUI and return once it has drawn its first frame. */ -async function launch(): Promise<{ term: FakeTerminal; exit: Promise }> { - const term = new FakeTerminal(); - const exit = runTui({ appDir }, term); - // Let unlock + first render complete. - for (let i = 0; i < 50 && term.frames.length === 0; i++) { - await new Promise((r) => setTimeout(r, 10)); - } - return { term, exit }; -} - -beforeEach(async () => { - await buildVault(); - process.env["SEEDPASS_MNEMONIC"] = MNEMONIC; -}); - -afterEach(() => { - delete process.env["SEEDPASS_MNEMONIC"]; - vi.restoreAllMocks(); -}); - -describe("list screen", () => { - it("shows every entry as a reference with its kind, and no secret", async () => { - const { term, exit } = await launch(); - expect(term.lastFrame).toContain("sp://entry/0"); - expect(term.lastFrame).toContain("github.com"); - expect(term.lastFrame).toContain("ci-token"); - expect(term.lastFrame).toContain("4 entries"); - // The one entry with a stored secret must not render it anywhere. - expect(term.everything).not.toContain(SECRET_VALUE); - await term.press("q"); - expect(await exit).toBe(0); - }); - - it("filters as you type and clears on escape", async () => { - const { term, exit } = await launch(); - await term.press("/git"); - expect(term.lastFrame).toContain("github.com"); - expect(term.lastFrame).toContain("gitlab.com"); - expect(term.lastFrame).not.toContain("aws-prod"); - await term.press("\x1b"); - expect(term.lastFrame).toContain("aws-prod"); - await term.press("q"); - await exit; - }); - - it("moves the selection with the arrow keys", async () => { - const { term, exit } = await launch(); - await term.press("\x1b[B\x1b[B"); - await term.press("\r"); - // Detail of the third entry, reached by two downs and enter. - expect(term.lastFrame).toContain("ci-token"); - expect(term.lastFrame).toContain("CI_TOKEN"); - expect(term.lastFrame).toContain("has_value"); - await term.press("q"); - await term.press("q"); - await exit; - }); -}); - -describe("secret handling", () => { - it("shows a stored secret only after r, and hides it again on the next key", async () => { - const { term, exit } = await launch(); - await term.press("\x1b[B\x1b[B"); // to ci-token - const beforeReveal = term.frames.length; - expect(term.everything).not.toContain(SECRET_VALUE); - - await term.press("r"); - expect(term.lastFrame).toContain(SECRET_VALUE); - - await term.press(" "); - // Gone from the current frame; only the frames drawn while revealing - // ever contained it. - expect(term.lastFrame).not.toContain(SECRET_VALUE); - const revealFrames = term.frames - .slice(beforeReveal) - .filter((f) => f.join("\n").includes(SECRET_VALUE)); - expect(revealFrames).toHaveLength(1); - - // Dismissing lands on the detail screen, where q means "back"; a second - // q leaves the app. - await term.press("qq"); - await exit; - }); - - it("copies without drawing the value", async () => { - const { term, exit } = await launch(); - await term.press("\x1b[B\x1b[B"); - await term.press("c"); - // Either the clipboard tool is present or it is not; what matters is - // that neither outcome puts the secret on screen. - expect(term.everything).not.toContain(SECRET_VALUE); - await term.press("q"); - await exit; - }); - - it("masks a secret being typed into the add form", async () => { - const { term, exit } = await launch(); - await term.press("a"); - await term.press("\x1b[C\x1b[C"); // pick key-value - await term.press("\r"); - await term.press("label-x\rKEY\rhunter2xyz"); - expect(term.lastFrame).not.toContain("hunter2xyz"); - expect(term.lastFrame).toContain("•".repeat("hunter2xyz".length)); - await term.press("\x1b"); - await term.press("q"); - await exit; - }); -}); - -describe("mutations", () => { - it("adds a password entry and persists it to the vault", async () => { - const { term, exit } = await launch(); - await term.press("a"); - await term.press("\r"); // password kind - await term.press("new-site.example\r24\rbob\r"); - expect(term.lastFrame).toContain("new-site.example"); - await term.press("q"); - await exit; - - // Re-open from disk: the TUI must have written, not just redrawn. - const { openVault } = await import("../src/vaultFile.js"); - const reopened = await openVault( - join(new AppDir(appDir).profileDir(FINGERPRINT), INDEX_FILENAME), - MNEMONIC, - ); - const labels = Object.values(reopened.index.entries).map((e) => e.label); - expect(labels).toContain("new-site.example"); - }); - - it("rejects a nonsense password length instead of storing it", async () => { - const { term, exit } = await launch(); - await term.press("a"); - await term.press("\r"); - await term.press("bad-length\rnot-a-number\r\r"); - // NaN once reached the vault through a --length flag and made the index - // unreadable; the form must refuse the same input. - expect(term.lastFrame).toContain("whole number"); - await term.press("\x1b"); - await term.press("q"); - await exit; - }); - - it("archives and unarchives the selected entry", async () => { - const { term, exit } = await launch(); - await term.press("d"); - expect(term.lastFrame).toContain("archived github.com"); - expect(term.lastFrame).toContain("3 entries"); - await term.press("A"); - expect(term.lastFrame).toContain("4 entries"); - await term.press("q"); - await exit; - }); -}); - -describe("chrome", () => { - it("lists the keys in help", async () => { - const { term, exit } = await launch(); - await term.press("?"); - expect(term.lastFrame).toContain("copy secret to clipboard"); - expect(term.lastFrame).toContain("quit"); - await term.press(" "); - await term.press("q"); - await exit; - }); - - it("never draws a line wider than the terminal", async () => { - const { term, exit } = await launch(); - await term.press("/git"); - await term.press("\r"); - for (const frame of term.frames) { - for (const line of frame) { - // Counting glyphs, not escape bytes — a styled row that overflows - // wraps and corrupts every frame after it. - expect(visibleLength(line)).toBeLessThanOrEqual(term.columns); - } - } - await term.press("q"); - await exit; - }); - - it("restores the terminal on exit", async () => { - const { term, exit } = await launch(); - await term.press("q"); - await exit; - expect(term.restored).toBe(true); - }); - - it("exits 130 on ctrl-c, as a signal-terminated program should", async () => { - const { term, exit } = await launch(); - await term.press("\x03"); - expect(await exit).toBe(130); - expect(term.restored).toBe(true); - }); -});