feat: interactive mode, so the bare command opens the vault

Python's `seedpass` launches its TUI when invoked with no subcommand. The
port printed a help dump instead, which is why running the bare command
looked broken: nothing was wrong with it, there was simply no way in. This
adds the interactive mode that was missing.

Scope is the daily path -- browse, search, copy, reveal, add a password /
TOTP / key-value entry, archive, switch profile -- not a port of Python's v2
and v3 TUIs, which are around 20k lines of Textual between them. Piped or
redirected, the bare command still prints the help: there is no terminal to
drive.

No new runtime dependencies. Node's tty and ANSI directly, rather than a TUI
framework, because everything bundled here shares a process with unlocked
seed phrases and the CLI's runtime surface is currently commander plus the
noble/scure crypto libraries.

The agent-blind rule from plan section 9.3 carries over intact, and matters
more here than in the CLI because this screen renders the whole vault at
once: list and detail views show metadata only, `c` copies without ever
drawing the value, and `r` is the single explicit reveal -- on screen until
the next keypress, then gone from the following frame. Everything is drawn
on the alternate screen buffer, so a revealed secret cannot be recovered by
scrolling back after quitting, and never lands in a terminal log that
captures the main buffer. The key-value field is masked while being typed
for the same reason.

terminal.ts owes the user one thing above all: their shell back. Raw mode
with a hidden cursor is an unusable terminal, so restoration is idempotent
and wired to exit, SIGINT, SIGTERM, SIGHUP, and the uncaught-exception paths
-- verified through a pty for each. Holding raw mode across the whole unlock
sequence rather than toggling it per prompt also fixes a narrow leak: echo
came back on between password attempts, so a password retyped after a typo
was echoed in clear text.

Tests drive the real screens through an injected terminal, including the
redaction properties above. Two harness bugs worth recording, because both
produced confident nonsense: sampling the frame count after dispatching a
key made a synchronous redraw look like no redraw at all, and waiting only
for "the screen stopped changing" returned during the second-long PBKDF2 a
mutation performs, so the next keystroke landed on the previous screen and
typed "q" into a form field instead of quitting.

122 CLI tests, 220 core tests, 36/36 cross-impl checks.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-15 15:02:06 -04:00
1 parent b0e2a309a4
commit 1eab3bf137
8 files changed
+1699 -11

No files matched your search

+26 -1
View File
@@ -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
+7
View File
@@ -15,6 +15,12 @@ export function capabilities(): Record<string, unknown> {
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/<id>",
reference_first_output: true,
@@ -70,6 +76,7 @@ export function capabilities(): Record<string, unknown> {
"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)",
],
};
}
+21
View File
@@ -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")
+935
View File
@@ -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<string, unknown>;
}
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<AddKind, string[]> = {
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<number> {
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<string, string | null> = {};
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<number> {
// 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<void>((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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<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,
);
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<void> {
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<void> {
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<void> {
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<void> {
this.vault = await openVault(this.vault!.path, this.vault!.mnemonic);
this.refreshRows();
this.setStatus("reloaded", "ok");
}
private async switchProfile(fingerprint: string): Promise<void> {
// 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<string, unknown>): 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<string | null> {
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 };
+274
View File
@@ -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<string, Key["name"]> = {
"[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`);
}
}
}
+14 -10
View File
@@ -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;
});
});
+91
View File
@@ -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);
});
});
+331
View File
@@ -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<void> {
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<void> {
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<void> {
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<number> }> {
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);
});
});