feat: rebuild interactive mode as Python's legacy TUI menus

The list-and-arrow-keys interface I wrote was not the thing to build. What
`seedpass` opens is a numbered menu tree, and navigating it is typing a
number and pressing Enter, with a blank line to go back -- so an interface
driven by arrow keys and single-letter bindings was unnavigable to anyone who
already knows SeedPass, however well it worked on its own terms.

This follows src/main.py item for item: the same eight main-menu entries in
the same order, the same nine entry types under Add Entry, the same eighteen
settings under the same numbers, the Profiles and Nostr submenus, the entry
action and edit menus with Python's letter keys. Numbering is part of the
interface, not decoration -- 7 then 1 has to still be Settings then Profiles
-- so items this build cannot perform stay in place and say why when
selected, rather than vanishing and shifting everything below them.

Line-based means no raw mode, no alternate screen, and no terminal to
restore, which removes a whole class of failure the previous version had to
defend against. Raw mode is now entered only around a password prompt, where
echo must be off.

The agent-blind rule survives the change: listings and details show metadata
with stored secrets as has_* flags, and a secret appears only through Show
Secret or Copy. Secret Mode -- a setting Python already has -- routes both to
the clipboard instead of the screen, so it means the same thing in both
implementations. The key/value field, hidden custom fields, and seed phrases
are read without echo.

Three settings needed new profile operations: changePassword re-wraps only
parent_seed.enc, since the index is keyed by the seed rather than the
password and a half-applied change would leave the two disagreeing;
writeParentSeed and setProfileName back the profile menu.

Verified through a pty against a real vault -- every menu, adding a password
entry, editing a label, revealing a secret -- and the derived values match
the CLI. 28 menu tests drive the tree through a scripted UI, including that
listings never contain a stored secret and that a non-numeric length is
refused rather than stored as NaN.

128 CLI tests, 220 core tests, 36/36 cross-impl checks. test/nostr.test.ts
is flaky under parallel load (it uses a live relay); it failed once during
this work and passed on rerun and three times in isolation.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-15 17:15:17 -04:00
1 parent 1eab3bf137
commit dce30d669a
11 files changed
+2159 -1597

No files matched your search

+54
View File
@@ -259,4 +259,58 @@ export class AppDir {
}
throw new Error(`could not decrypt parent seed (wrong password?): ${String(lastError)}`);
}
/**
* Re-encrypt a profile's parent seed under a new master password.
*
* Only `parent_seed.enc` is touched: the vault index is keyed by the seed,
* not the password, so nothing else needs rewriting — and nothing else
* *may* be, since a half-applied change would leave a profile whose seed
* and index disagree.
*/
async changePassword(
fingerprint: string,
oldPassword: string,
newPassword: string,
iterations = DEFAULT_PBKDF2_ITERATIONS,
): Promise<void> {
if (!newPassword) throw new Error("a master password is required");
// Proves the old password before anything is written.
const mnemonic = await this.decryptParentSeed(fingerprint, oldPassword);
await this.writeParentSeed(fingerprint, mnemonic, newPassword, iterations);
}
/** Write parent_seed.enc for `fingerprint`, in Python's kdf/ct wrapper. */
async writeParentSeed(
fingerprint: string,
mnemonic: string,
password: string,
iterations = DEFAULT_PBKDF2_ITERATIONS,
): Promise<void> {
const salt = seedKdfSalt(fingerprint);
const seedKey = base64url.decode(deriveKeyFromPassword(password, salt, iterations));
const ct = await encryptV3(seedKey, utf8(mnemonic));
const kdf: KdfConfig = {
name: "pbkdf2",
version: 1,
params: { iterations },
salt_b64: Buffer.from(salt).toString("base64"),
};
await atomicWrite(
join(this.profileDir(fingerprint), PARENT_SEED_FILENAME),
utf8(JSON.stringify({ kdf, ct: Buffer.from(ct).toString("base64") })),
);
}
/** Set or clear a profile's display name. */
async setProfileName(fingerprint: string, name: string): Promise<void> {
assertValidFingerprint(fingerprint);
await this.mutateFingerprints((data) => {
if (!data.fingerprints.includes(fingerprint)) {
throw new Error(`no profile ${fingerprint}`);
}
if (name) data.names[fingerprint] = name;
else delete data.names[fingerprint];
});
}
}
+4 -3
View File
@@ -17,8 +17,8 @@ export function capabilities(): Record<string, unknown> {
},
interactive: {
command: "seedpass-js (no subcommand, on a terminal)",
covers: ["browse", "search", "copy", "reveal", "add", "archive", "switch profile"],
secrets: "metadata only until 'c' (copy) or 'r' (reveal); alternate screen, no scrollback",
model: "numbered menus mirroring Python's legacy (v1) TUI; blank input goes back",
secrets: "metadata only; 'S' shows and 'C' copies, and Secret Mode routes both to the clipboard",
non_tty: "prints help",
},
agent_surface: {
@@ -76,7 +76,8 @@ export function capabilities(): Record<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)",
"Python's v2/v3 TUIs (interactive mode follows the legacy v1 menus)",
"QR code display in the TUI (no QR encoder in this build)",
],
};
}
+1 -2
View File
@@ -155,7 +155,6 @@ import { loadConfig, saveConfig, mutateConfig } from "./configFile.js";
import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js";
import { AuditLog } from "./audit.js";
import { runTui } from "./tui/app.js";
import { Terminal } from "./tui/terminal.js";
export interface ProgramIo {
out(line: string): void;
@@ -401,7 +400,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
// remains the sensible answer.
program.action(async () => {
const opts = program.opts() as GlobalOpts;
if (!Terminal.isInteractive()) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
program.outputHelp();
process.exitCode = 1;
return;
+60 -883
View File
@@ -1,87 +1,37 @@
/**
* The interactive mode: `seedpass-js` with no subcommand.
* Interactive mode: `seedpass-js` with no subcommand.
*
* Parity note: Python's `seedpass` launches a TUI when invoked bare, and this
* exists because the port did not — a new user typing the bare command got a
* help dump and no way in.
* Python's `seedpass` launches its TUI when invoked bare; this is the port of
* that experience, following the legacy (v1) menu tree in `src/main.py` —
* numbered menus, blank line to go back, the same items in the same order.
*
* The agent-blind rule from plan section 9.3 holds here exactly as it does in
* the CLI: the list and detail views render metadata only, `c` copies to the
* clipboard without ever displaying the value, and `r` is the one explicit
* plaintext egress — it requires a keypress, says what it is about to do, and
* the value is erased from the screen when dismissed. Combined with the
* alternate screen buffer (see terminal.ts) a revealed secret cannot be
* recovered from scrollback after quitting.
* The agent-blind rule from plan section 9.3 still applies: listings show
* metadata with stored secrets reduced to `has_*` flags, and a secret appears
* only through an action whose purpose is to produce it. Secret Mode (a
* setting Python also has) routes those to the clipboard instead.
*/
import process from "node:process";
import { join } from "node:path";
import {
archiveEntry,
restoreEntry,
addPasswordEntry,
addTotpDeterministic,
addKeyValueEntry,
assertValidMnemonic,
isValidMnemonic,
type Entry,
} from "@seedpass/core";
import { assertValidMnemonic } from "@seedpass/core";
import { AppDir, resolveAppDir, INDEX_FILENAME } from "../appDir.js";
import { openVault, saveVaultHoldingLock, withVaultLock, type OpenedVault } from "../vaultFile.js";
import { entryMetadata, refFor } from "../refs.js";
import { materializeSecret } from "../secrets.js";
import { clipboardSink } from "../sinks.js";
import { openVault } from "../vaultFile.js";
import { loadConfig } from "../configFile.js";
import { AgentClient, agentSocketPath } from "../agent.js";
import { Terminal, ansi, visibleLength, type Key } from "./terminal.js";
type Screen = "list" | "detail" | "reveal" | "add" | "help" | "profiles";
interface Row {
id: string;
label: string;
kind: string;
archived: boolean;
meta: Record<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"],
};
import { ConsoleUi, fail, warn, type Ui } from "./console.js";
import { mainMenu, type Session } from "./menus.js";
export interface TuiOptions {
appDir?: string;
fingerprint?: string;
}
/**
* What the app needs from a terminal. Narrowed to an interface so tests can
* drive the real screens through a fake, rather than a pty the test runner
* cannot allocate — the redaction rules below are worth asserting directly.
*/
export interface TerminalLike {
readonly columns: number;
readonly rows: number;
start(): void;
restore(): void;
onKey(handler: (key: Key) => void): void;
onResize(handler: () => void): void;
draw(lines: string[]): void;
function isInteractive(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
export async function runTui(opts: TuiOptions, terminal?: TerminalLike): Promise<number> {
if (!terminal && !Terminal.isInteractive()) {
export async function runTui(opts: TuiOptions, injectedUi?: Ui): Promise<number> {
if (!injectedUi && !isInteractive()) {
process.stderr.write(
"seedpass-js: interactive mode needs a terminal on both stdin and stdout.\n" +
"Run a subcommand instead — 'seedpass-js --help' lists them.\n",
@@ -89,9 +39,11 @@ export async function runTui(opts: TuiOptions, terminal?: TerminalLike): Promise
return 1;
}
const ui = injectedUi ?? new ConsoleUi();
const app = new AppDir(resolveAppDir(opts.appDir));
const fps = await app.readFingerprints();
if (fps.fingerprints.length === 0) {
const registry = await app.readFingerprints();
if (registry.fingerprints.length === 0) {
process.stderr.write(
"No vault yet. Create one with:\n\n" +
" export SEEDPASS_PASSWORD='a master password'\n" +
@@ -100,836 +52,61 @@ export async function runTui(opts: TuiOptions, terminal?: TerminalLike): Promise
return 1;
}
const tui = new Tui(app, opts, terminal ?? new Terminal());
return tui.run();
}
class Tui {
private screen: Screen = "list";
private vault: OpenedVault | null = null;
private fingerprint = "";
private profileNames: Record<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);
}
const fingerprint =
opts.fingerprint ?? registry.last_used ?? registry.fingerprints[0]!;
/**
* Same resolution order as the CLI: environment, then the session agent,
* then ask. Asking is last because the other two mean the user already
* decided how this session is unlocked.
* Resolve a profile's seed: environment, then the session agent, then ask.
* The same order the CLI uses, so a session unlocked with `vault unlock`
* does not ask again.
*/
private async resolveMnemonic(fingerprint: string): Promise<string> {
const relock = async (fp: 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);
const held = await new AgentClient(agentSocketPath(app.root)).ownerMnemonic(fp);
if (held) return held;
} catch {
// no agent, or it holds nothing for this profile — fall through to ask
// no agent running, or it holds nothing for this profile
}
const name = this.profileNames[fingerprint];
const prompt = new HiddenPrompt();
try {
for (let attempt = 0; attempt < 3; attempt++) {
const password = await prompt.ask(
`Master password for ${name ? `${name} (${fingerprint})` : fingerprint}: `,
);
if (password === null) throw new Error("cancelled");
try {
return await this.app.decryptParentSeed(fingerprint, password);
} catch {
process.stderr.write("Wrong password.\n");
}
}
throw new Error("too many failed password attempts");
} finally {
prompt.close();
}
}
private async loadVault(fingerprint: string, mnemonic: string): Promise<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;
const names = (await app.readFingerprints()).names;
const who = names[fp] ? `${names[fp]} (${fp})` : fp;
for (let attempt = 0; attempt < 3; attempt++) {
const password = await ui.askHidden(`Master password for ${who}: `);
if (!password) throw new Error("cancelled");
try {
return await app.decryptParentSeed(fp, password);
} catch {
warn(ui, "Wrong password.");
}
}
throw new Error("too many failed password attempts");
};
switch (key.name) {
case "up":
this.move(-1);
return;
case "down":
this.move(1);
return;
case "pageup":
this.move(-this.pageSize());
return;
case "pagedown":
this.move(this.pageSize());
return;
case "home":
this.cursor = 0;
return;
case "end":
this.cursor = Math.max(0, this.filtered.length - 1);
return;
case "enter":
if (this.current()) this.screen = "detail";
return;
case "escape":
if (this.query) {
this.query = "";
this.applyFilter();
}
return;
case "char":
break;
default:
return;
}
switch (key.ch) {
case "/":
this.searching = true;
this.setStatus("");
return;
case "q":
this.quit(0);
return;
case "?":
this.screen = "help";
return;
case "c":
await this.copyCurrent();
return;
case "r":
this.revealCurrent();
return;
case "a":
this.add = { kind: "password", field: -1, values: ["", "", ""] };
this.screen = "add";
return;
case "d":
await this.toggleArchive();
return;
case "A":
this.showArchived = !this.showArchived;
this.applyFilter();
this.setStatus(this.showArchived ? "showing archived" : "hiding archived");
return;
case "p":
this.screen = "profiles";
this.profileCursor = Math.max(0, this.profiles.indexOf(this.fingerprint));
return;
case "g":
await this.reload();
return;
default:
return;
}
let session: Session;
try {
const mnemonic = await relock(fingerprint);
session = {
ui,
app,
fingerprint,
name: registry.names[fingerprint] ?? null,
vault: await openVault(join(app.profileDir(fingerprint), INDEX_FILENAME), mnemonic),
config: await loadConfig(app.profileDir(fingerprint), mnemonic),
relock,
};
} catch (e) {
process.stderr.write(`seedpass-js: ${(e as Error).message}\n`);
return 1;
}
private async keyDetail(key: Key): Promise<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)}`;
try {
return await mainMenu(session);
} catch (e) {
fail(ui, `Unexpected error: ${(e as Error).message}`);
return 1;
}
}
// ------------------------------------------------------------------ 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 };
+160
View File
@@ -0,0 +1,160 @@
/**
* Console I/O for the interactive menus.
*
* Python's legacy TUI is line-based: clear the screen, print a header and a
* numbered menu, read a line, act, repeat. This mirrors that model rather
* than the full-screen raw-mode approach, because the navigation *is* the
* interface — typing a number and pressing Enter, blank to go back.
*
* Everything the menus do goes through the `Ui` interface so the whole tree
* can be driven by a scripted test without a pty.
*/
import process from "node:process";
import { createInterface } from "node:readline";
export const ansi = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
} as const;
export interface Ui {
/** Print a line. */
say(line?: string): void;
/** Read a line. Returns "" when the user just presses Enter. */
ask(prompt: string): Promise<string>;
/** Read a line without echoing it. */
askHidden(prompt: string): Promise<string>;
/** Clear the screen (no-op when not a terminal). */
clear(): void;
}
/** A menu option: the key the user types, and its label. */
export interface MenuOption {
key: string;
label: string;
}
export class ConsoleUi implements Ui {
constructor(
private readonly out: NodeJS.WritableStream = process.stdout,
private readonly input: NodeJS.ReadableStream & { isTTY?: boolean } = process.stdin,
) {}
say(line = ""): void {
this.out.write(line + "\n");
}
clear(): void {
if ((this.out as unknown as { isTTY?: boolean }).isTTY) {
// Clear and reset the cursor, then clear scrollback: menus redraw
// constantly, and without the third sequence a session leaves hundreds
// of stale screens behind it.
this.out.write("\x1b[2J\x1b[H\x1b[3J");
}
}
async ask(prompt: string): Promise<string> {
const rl = createInterface({ input: this.input, output: this.out, terminal: true });
try {
return (await new Promise<string>((resolve) => rl.question(prompt, resolve))).trim();
} finally {
rl.close();
}
}
/**
* Read without echo.
*
* Not readline: its line editing echoes what it receives, and a master
* password must not reach the screen or a terminal log. Raw mode is entered
* and left around this single read; the menus are otherwise line-mode.
*/
askHidden(prompt: string): Promise<string> {
const stdin = this.input as NodeJS.ReadableStream & {
isTTY?: boolean;
setRawMode?: (v: boolean) => void;
isRaw?: boolean;
};
if (!stdin.isTTY) return this.ask(prompt);
return new Promise<string>((resolve) => {
const wasRaw = stdin.isRaw ?? false;
this.out.write(prompt);
stdin.setRawMode?.(true);
stdin.resume();
(stdin as NodeJS.ReadableStream & { setEncoding(e: string): void }).setEncoding("utf8");
let value = "";
const finish = (result: string): void => {
stdin.removeListener("data", onData);
stdin.setRawMode?.(wasRaw);
stdin.pause();
this.out.write("\n");
resolve(result);
};
const onData = (chunk: string): void => {
for (const ch of chunk) {
if (ch === "\r" || ch === "\n") return finish(value);
// Ctrl-C inside a password prompt should abort the program, not
// silently return an empty password that then "fails to unlock".
if (ch === "\x03") {
finish("");
process.exit(130);
return;
}
if (ch === "\x7f" || ch === "\b") {
value = value.slice(0, -1);
continue;
}
if (ch >= " ") value += ch;
}
};
stdin.on("data", onData);
});
}
}
/** Header shown at the top of every screen: profile, then breadcrumb. */
export function header(ui: Ui, fingerprint: string, name: string | null, title: string): void {
ui.clear();
const who = name ? `${name} (${fingerprint})` : fingerprint;
ui.say(`${ansi.dim}SeedPass — ${who}${ansi.reset}`);
ui.say(`${ansi.bold}${title}${ansi.reset}`);
}
export function menu(ui: Ui, options: MenuOption[]): void {
ui.say();
for (const { key, label } of options) {
ui.say(` ${ansi.cyan}${key}.${ansi.reset} ${label}`);
}
ui.say();
}
export async function pause(ui: Ui): Promise<void> {
await ui.ask(`${ansi.dim}Press Enter to continue.${ansi.reset} `);
}
export async function confirm(ui: Ui, question: string): Promise<boolean> {
const answer = (await ui.ask(`${question} (y/N): `)).toLowerCase();
return answer === "y" || answer === "yes";
}
export function ok(ui: Ui, message: string): void {
ui.say(`${ansi.green}${message}${ansi.reset}`);
}
export function warn(ui: Ui, message: string): void {
ui.say(`${ansi.yellow}${message}${ansi.reset}`);
}
export function fail(ui: Ui, message: string): void {
ui.say(`${ansi.red}${message}${ansi.reset}`);
}
File diff suppressed because it is too large. Load diff
-274
View File
@@ -1,274 +0,0 @@
/**
* Terminal control for the interactive mode: raw input, the alternate screen,
* and — above all — putting the terminal back the way we found it.
*
* Two properties this file is responsible for:
*
* 1. **The shell survives.** Raw mode with the cursor hidden is a broken
* terminal: no echo, no line editing, no visible cursor. If we exit
* without restoring — including on a crash or a signal — the user's shell
* is left unusable and `reset` is the only way out. Restoration is
* therefore idempotent and wired to every exit path there is.
*
* 2. **Secrets do not reach scrollback.** Everything is drawn on the
* alternate screen buffer, which the terminal discards on exit. A revealed
* password cannot be recovered by scrolling up after quitting, and never
* lands in a terminal-logging setup that captures the main buffer.
*
* No dependencies: this is a security tool, and a TUI framework would pull a
* React runtime and its transitive tree into the process that handles seed
* phrases.
*/
import process from "node:process";
import type { ReadStream } from "node:tty";
export const ESC = "\x1b";
const CSI = `${ESC}[`;
export const ansi = {
reset: `${CSI}0m`,
bold: `${CSI}1m`,
dim: `${CSI}2m`,
italic: `${CSI}3m`,
underline: `${CSI}4m`,
reverse: `${CSI}7m`,
red: `${CSI}31m`,
green: `${CSI}32m`,
yellow: `${CSI}33m`,
blue: `${CSI}34m`,
magenta: `${CSI}35m`,
cyan: `${CSI}36m`,
grey: `${CSI}90m`,
} as const;
/** Strip SGR sequences so width maths counts glyphs, not escape bytes. */
export function visibleLength(s: string): number {
// eslint-disable-next-line no-control-regex
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
}
export interface Key {
/** Symbolic name for non-printing keys, else "char". */
name:
| "char"
| "up"
| "down"
| "left"
| "right"
| "enter"
| "backspace"
| "delete"
| "tab"
| "escape"
| "home"
| "end"
| "pageup"
| "pagedown"
| "ctrl-c"
| "ctrl-d"
| "unknown";
/** The character, for name === "char". */
ch: string;
}
/**
* Decode one input chunk into keys.
*
* A chunk can hold several keypresses (fast typing, or a paste), so this
* returns a list rather than a single key — dropping the remainder would
* silently swallow input.
*/
export function decodeKeys(data: string): Key[] {
const keys: Key[] = [];
let i = 0;
while (i < data.length) {
const ch = data[i]!;
if (ch === ESC) {
const rest = data.slice(i);
const seq = matchEscape(rest);
if (seq) {
keys.push(seq.key);
i += seq.length;
continue;
}
keys.push({ name: "escape", ch: "" });
i += 1;
continue;
}
switch (ch) {
case "\x03":
keys.push({ name: "ctrl-c", ch: "" });
break;
case "\x04":
keys.push({ name: "ctrl-d", ch: "" });
break;
case "\r":
case "\n":
keys.push({ name: "enter", ch: "" });
break;
case "\x7f":
case "\b":
keys.push({ name: "backspace", ch: "" });
break;
case "\t":
keys.push({ name: "tab", ch: "" });
break;
default:
// Ignore remaining C0 controls; they are not text and have no binding.
if (ch >= " ") keys.push({ name: "char", ch });
break;
}
i += 1;
}
return keys;
}
function matchEscape(s: string): { key: Key; length: number } | null {
const table: Record<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`);
}
}
}