diff --git a/docs/typescript_migration_guide.md b/docs/typescript_migration_guide.md index a4e3131c..6ad4a3e9 100644 --- a/docs/typescript_migration_guide.md +++ b/docs/typescript_migration_guide.md @@ -97,10 +97,35 @@ 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. -- The TUI. The terminal experience is the CLI. +- 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. If you rely on any of those, run both: they operate on the same profile. +## Interactive mode + +```bash +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. + +Two things behave deliberately: + +- 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. + +Piped or redirected, the bare command prints the help instead — there is no +terminal to drive. + ## Starting fresh (no existing vault) If you have no seed yet, `fingerprint create` generates one — 32 bytes of OS diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index 8bed0e8a..e0eba5a2 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -15,6 +15,12 @@ export function capabilities(): Record { portable_backup_format: 1, nostr_event_kinds: { manifest: 30070, snapshot_chunk: 30071, delta: 30072 }, }, + 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", + non_tty: "prints help", + }, agent_surface: { reference_scheme: "sp://entry/", reference_first_output: true, @@ -70,6 +76,7 @@ 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)", ], }; } diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 1a937f01..edd86f58 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -154,6 +154,8 @@ import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js"; 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; @@ -392,6 +394,25 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { }) .exitOverride(); + // No subcommand: launch the interactive mode, matching Python, whose bare + // `seedpass` opens its TUI. Without this the first thing a new user saw + // was a help dump — the command they were told to run appeared to do + // nothing. Piped or redirected, there is no terminal to drive, so the help + // remains the sensible answer. + program.action(async () => { + const opts = program.opts() as GlobalOpts; + if (!Terminal.isInteractive()) { + program.outputHelp(); + process.exitCode = 1; + return; + } + const code = await runTui({ + ...(opts.appDir !== undefined && { appDir: opts.appDir }), + ...(opts.fingerprint !== undefined && { fingerprint: opts.fingerprint }), + }); + if (code !== 0) process.exitCode = code; + }); + program .command("capabilities") .description("machine-readable capability map") diff --git a/js/packages/cli/src/tui/app.ts b/js/packages/cli/src/tui/app.ts new file mode 100644 index 00000000..4e5d91e7 --- /dev/null +++ b/js/packages/cli/src/tui/app.ts @@ -0,0 +1,935 @@ +/** + * The 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. + * + * 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. + */ + +import process from "node:process"; +import { join } from "node:path"; +import { + archiveEntry, + restoreEntry, + addPasswordEntry, + addTotpDeterministic, + addKeyValueEntry, + assertValidMnemonic, + isValidMnemonic, + type Entry, +} 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 { 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"], +}; + +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; +} + +export async function runTui(opts: TuiOptions, terminal?: TerminalLike): Promise { + if (!terminal && !Terminal.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", + ); + return 1; + } + + const app = new AppDir(resolveAppDir(opts.appDir)); + const fps = await app.readFingerprints(); + if (fps.fingerprints.length === 0) { + process.stderr.write( + "No vault yet. Create one with:\n\n" + + " export SEEDPASS_PASSWORD='a master password'\n" + + " seedpass-js fingerprint create --name personal --words 24 --out ~/seed.txt\n", + ); + 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); + } + + /** + * 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. + */ + private async resolveMnemonic(fingerprint: 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); + if (held) return held; + } catch { + // no agent, or it holds nothing for this profile — fall through to ask + } + + 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; + } + } + + 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; + } + } + + 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)}`; + } +} + +// ------------------------------------------------------------------ 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/terminal.ts b/js/packages/cli/src/tui/terminal.ts new file mode 100644 index 00000000..2e301203 --- /dev/null +++ b/js/packages/cli/src/tui/terminal.ts @@ -0,0 +1,274 @@ +/** + * 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/cli.test.ts b/js/packages/cli/test/cli.test.ts index fbbfff9f..58cfd993 100644 --- a/js/packages/cli/test/cli.test.ts +++ b/js/packages/cli/test/cli.test.ts @@ -435,23 +435,27 @@ describe("document import/export", () => { }); describe("exit behaviour", () => { - it("does not report help output as an error", async () => { - // exitOverride() makes commander throw when it prints help, so a bare - // invocation printed the help and then claimed to have failed with - // "(outputHelp)". + it("prints help without reporting it as an error when stdout is not a terminal", async () => { + // Bare invocation launches the interactive mode on a terminal, but there + // is no terminal to drive here, so it falls back to the help. Two bugs + // met on this path: exitOverride() makes commander throw when it prints + // help, and the handler reported that throw as "error: (outputHelp)". const out: string[] = []; const err: string[] = []; const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) }; - let thrown: { code?: string; exitCode?: number } | undefined; + const previous = process.exitCode; + let thrown: unknown; try { await buildProgram(io).parseAsync(["node", "seedpass-js"]); } catch (e) { - thrown = e as { code?: string; exitCode?: number }; + thrown = e; } - // commander signals help with its own code; main.ts must treat any - // commander.* code as commander's business, not as a SeedPass error. - expect(thrown?.code).toMatch(/^commander\./); - expect(err.join("\n")).not.toContain("outputHelp"); + const printed = [...out, ...err].join("\n"); + expect(thrown).toBeUndefined(); + expect(printed).toContain("Usage: seedpass-js"); + expect(printed).not.toContain("outputHelp"); + expect(process.exitCode).toBe(1); + process.exitCode = previous; }); }); diff --git a/js/packages/cli/test/tui.test.ts b/js/packages/cli/test/tui.test.ts new file mode 100644 index 00000000..91070f04 --- /dev/null +++ b/js/packages/cli/test/tui.test.ts @@ -0,0 +1,91 @@ +/** + * 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/tuiScreens.test.ts b/js/packages/cli/test/tuiScreens.test.ts new file mode 100644 index 00000000..15983302 --- /dev/null +++ b/js/packages/cli/test/tuiScreens.test.ts @@ -0,0 +1,331 @@ +/** + * 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); + }); +});