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