mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-03 03:12:07 +00:00
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:
1 parent
1eab3bf137
commit
dce30d669a
11 files changed
+2159
-1597
No files matched your search
@@ -97,11 +97,12 @@ so this only applies if you jump backwards several releases.
|
||||
generation is not reproducible, so the TypeScript port refuses rather than
|
||||
deriving a different key. Keep the Python implementation for RSA entries.
|
||||
- The `semantic` (vector search) and `api` (FastAPI server) command groups.
|
||||
- Most of Python's TUI. `seedpass-js` with no subcommand opens an interactive
|
||||
mode, as `seedpass` does, but it covers the daily path only: browse,
|
||||
search, copy, reveal, add a password/TOTP/key-value entry, archive, and
|
||||
switch profile. Python's v2 and v3 TUIs have screens this does not — the
|
||||
command palette, the inspector, the bulk operations.
|
||||
- Python's v2 and v3 TUIs. Interactive mode follows the **legacy (v1)** menu
|
||||
tree instead, which is the one `seedpass --legacy-tui` opens.
|
||||
- Two legacy-TUI items are stubs that say so when selected: Semantic Index
|
||||
(not part of this build) and QR code display (no QR encoder here; it offers
|
||||
the underlying value instead). Script checksum verification covers the
|
||||
Python source tree and is replaced by the bundle's `.sha256`.
|
||||
|
||||
If you rely on any of those, run both: they operate on the same profile.
|
||||
|
||||
@@ -112,16 +113,24 @@ seedpass-js # opens the vault; asks for your master password
|
||||
```
|
||||
|
||||
It unlocks the same way the CLI does: `SEEDPASS_MNEMONIC` if set, then the
|
||||
session agent, then a password prompt. Press `?` for the keys.
|
||||
session agent, then a password prompt.
|
||||
|
||||
Two things behave deliberately:
|
||||
The menus mirror Python's legacy TUI item for item, in the same order and
|
||||
under the same numbers, so the sequences you already know still work — `7`
|
||||
then `1` is still Settings then Profiles. A blank line goes back; a blank
|
||||
line at the main menu exits.
|
||||
|
||||
- Nothing secret is drawn until you ask. The list and detail views show
|
||||
metadata; `c` copies to the clipboard without displaying anything, and `r`
|
||||
is the one explicit reveal, on screen only until the next keypress.
|
||||
- The whole session runs on the terminal's alternate screen, so quitting
|
||||
takes it with it. A revealed secret cannot be recovered by scrolling back,
|
||||
and it never reaches a terminal log that captures the main buffer.
|
||||
```
|
||||
1. Add Entry 5. Modify an Existing Entry
|
||||
2. Retrieve Entry 6. 2FA Codes
|
||||
3. Search Entries 7. Settings
|
||||
4. List Entries 8. List Archived
|
||||
```
|
||||
|
||||
Entry details list metadata only, with stored secrets shown as `has_*`
|
||||
flags. `S` shows a secret and `C` copies it — the two actions whose purpose
|
||||
is to produce one. Turning on **Secret Mode** (Settings 15) sends both to the
|
||||
clipboard instead of the screen, as it does in Python.
|
||||
|
||||
Piped or redirected, the bare command prints the help instead — there is no
|
||||
terminal to drive.
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Interactive-mode input decoding and layout helpers.
|
||||
*
|
||||
* The screens themselves are exercised through a real pty in
|
||||
* `test/tui-pty.test.ts`; this file covers the pure logic, where the
|
||||
* interesting failures are silent: a dropped keypress, or a width
|
||||
* calculation that counts escape bytes as visible characters and wraps every
|
||||
* highlighted row.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeKeys, visibleLength, ansi, ESC } from "../src/tui/terminal.js";
|
||||
|
||||
describe("decodeKeys", () => {
|
||||
it("decodes printable characters", () => {
|
||||
expect(decodeKeys("abc")).toEqual([
|
||||
{ name: "char", ch: "a" },
|
||||
{ name: "char", ch: "b" },
|
||||
{ name: "char", ch: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("decodes arrow keys in both cursor modes", () => {
|
||||
// Terminals send SS3 (ESC O A) in application cursor mode and CSI
|
||||
// (ESC [ A) otherwise; handling only one leaves arrows dead in some
|
||||
// terminals and not others.
|
||||
expect(decodeKeys(`${ESC}[A`)[0]!.name).toBe("up");
|
||||
expect(decodeKeys(`${ESC}OA`)[0]!.name).toBe("up");
|
||||
expect(decodeKeys(`${ESC}[B`)[0]!.name).toBe("down");
|
||||
expect(decodeKeys(`${ESC}[D`)[0]!.name).toBe("left");
|
||||
});
|
||||
|
||||
it("decodes navigation and editing keys", () => {
|
||||
expect(decodeKeys("\r")[0]!.name).toBe("enter");
|
||||
expect(decodeKeys("\n")[0]!.name).toBe("enter");
|
||||
expect(decodeKeys("\x7f")[0]!.name).toBe("backspace");
|
||||
expect(decodeKeys("\t")[0]!.name).toBe("tab");
|
||||
expect(decodeKeys("\x03")[0]!.name).toBe("ctrl-c");
|
||||
expect(decodeKeys(`${ESC}[5~`)[0]!.name).toBe("pageup");
|
||||
expect(decodeKeys(`${ESC}[6~`)[0]!.name).toBe("pagedown");
|
||||
expect(decodeKeys(`${ESC}[H`)[0]!.name).toBe("home");
|
||||
});
|
||||
|
||||
it("treats a lone escape as escape", () => {
|
||||
expect(decodeKeys(ESC)).toEqual([{ name: "escape", ch: "" }]);
|
||||
});
|
||||
|
||||
it("decodes every keypress in a chunk, not just the first", () => {
|
||||
// Fast typing and pastes arrive as one chunk. Returning a single key
|
||||
// silently drops the rest of what the user typed.
|
||||
const keys = decodeKeys(`ab${ESC}[Bc\r`);
|
||||
expect(keys.map((k) => k.name)).toEqual(["char", "char", "down", "char", "enter"]);
|
||||
expect(keys.map((k) => k.ch).join("")).toBe("abc");
|
||||
});
|
||||
|
||||
it("ignores control bytes that are not bound to anything", () => {
|
||||
expect(decodeKeys("\x01\x02")).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports editing keys in sequence rather than applying them", () => {
|
||||
// The decoder's job is to say what was pressed; the screen decides what
|
||||
// a backspace means. Keeping that split is why the same decoder serves
|
||||
// the search box and the add form.
|
||||
const keys = decodeKeys("git\x7fhub");
|
||||
expect(keys.map((k) => k.name)).toEqual([
|
||||
"char",
|
||||
"char",
|
||||
"char",
|
||||
"backspace",
|
||||
"char",
|
||||
"char",
|
||||
"char",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleLength", () => {
|
||||
it("counts glyphs, not escape sequences", () => {
|
||||
expect(visibleLength("abc")).toBe(3);
|
||||
expect(visibleLength(`${ansi.bold}abc${ansi.reset}`)).toBe(3);
|
||||
expect(visibleLength(`${ansi.dim}${ansi.red}x${ansi.reset}`)).toBe(1);
|
||||
});
|
||||
|
||||
it("is what keeps a highlighted row from wrapping", () => {
|
||||
// A styled row is much longer in bytes than on screen; padding to a
|
||||
// terminal width using raw .length would overflow every line.
|
||||
const styled = `${ansi.reverse}${ansi.cyan}entry${ansi.reset}`;
|
||||
expect(styled.length).toBeGreaterThan(visibleLength(styled));
|
||||
expect(visibleLength(styled)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* The interactive menus, driven by a scripted UI.
|
||||
*
|
||||
* Two things are being checked. First, that the menu tree matches Python's
|
||||
* legacy TUI — the items, their order, and "blank goes back" — since that is
|
||||
* the navigation the port exists to reproduce. Second, that the agent-blind
|
||||
* rule survives the move to a screen that shows the whole vault: listings
|
||||
* carry metadata only, and a stored secret appears solely through the action
|
||||
* whose purpose is to produce it.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, writeFile, mkdir, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import { mnemonics } from "@seedpass/test-vectors";
|
||||
import {
|
||||
generateFingerprint,
|
||||
deriveIndexKeyBytes,
|
||||
encryptV3,
|
||||
utf8,
|
||||
addPasswordEntry,
|
||||
addKeyValueEntry,
|
||||
addTotpDeterministic,
|
||||
type VaultIndex,
|
||||
} from "@seedpass/core";
|
||||
import { runTui } from "../src/tui/app.js";
|
||||
import { AppDir, INDEX_FILENAME } from "../src/appDir.js";
|
||||
import { openVault } from "../src/vaultFile.js";
|
||||
import type { Ui } from "../src/tui/console.js";
|
||||
|
||||
const MNEMONIC = mnemonics["abandon12"]!;
|
||||
const FINGERPRINT = generateFingerprint(MNEMONIC);
|
||||
const STORED_SECRET = "stored-ci-token-value";
|
||||
|
||||
let appDir: string;
|
||||
|
||||
/**
|
||||
* A UI that answers prompts from a script.
|
||||
*
|
||||
* Running out of answers ends the session rather than hanging: a test that
|
||||
* mis-counts its inputs should fail with the transcript in hand, not time out.
|
||||
*/
|
||||
class ScriptedUi implements Ui {
|
||||
readonly lines: string[] = [];
|
||||
readonly prompts: string[] = [];
|
||||
private i = 0;
|
||||
|
||||
constructor(private readonly answers: string[]) {}
|
||||
|
||||
say(line = ""): void {
|
||||
this.lines.push(line);
|
||||
}
|
||||
clear(): void {
|
||||
this.lines.push("\f");
|
||||
}
|
||||
async ask(prompt: string): Promise<string> {
|
||||
this.prompts.push(prompt);
|
||||
if (this.i >= this.answers.length) {
|
||||
// Blank is "go back"/"exit" everywhere, so this unwinds the menus.
|
||||
return "";
|
||||
}
|
||||
const answer = this.answers[this.i++]!;
|
||||
this.lines.push(`${prompt}${answer}`);
|
||||
return answer;
|
||||
}
|
||||
async askHidden(prompt: string): Promise<string> {
|
||||
const answer = await this.ask(prompt);
|
||||
// Record that it was asked, never what was typed.
|
||||
this.lines[this.lines.length - 1] = `${prompt}<hidden>`;
|
||||
return answer;
|
||||
}
|
||||
|
||||
/** Everything drawn, with styling removed so assertions read plainly. */
|
||||
get text(): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return this.lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
/** Everything drawn since the last screen clear. */
|
||||
get screen(): string {
|
||||
const last = this.lines.lastIndexOf("\f");
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return this.lines.slice(last + 1).join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
async function run(...answers: string[]): Promise<ScriptedUi> {
|
||||
const ui = new ScriptedUi(answers);
|
||||
await runTui({ appDir }, ui);
|
||||
return ui;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
appDir = await mkdtemp(join(tmpdir(), "seedpass-menus-"));
|
||||
const app = new AppDir(appDir);
|
||||
const index = { schema_version: 4, entries: {} } as VaultIndex;
|
||||
addPasswordEntry(index, "github.com", 20, { username: "adam" });
|
||||
addPasswordEntry(index, "gitlab.com", 16, {});
|
||||
addKeyValueEntry(index, "ci-token", "CI_TOKEN", STORED_SECRET);
|
||||
addTotpDeterministic(index, "email-2fa", MNEMONIC);
|
||||
|
||||
await app.mutateFingerprints((data) => {
|
||||
data.fingerprints.push(FINGERPRINT);
|
||||
data.names[FINGERPRINT] = "daily";
|
||||
data.last_used = FINGERPRINT;
|
||||
});
|
||||
const dir = app.profileDir(FINGERPRINT);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
join(dir, INDEX_FILENAME),
|
||||
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))),
|
||||
);
|
||||
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["SEEDPASS_MNEMONIC"];
|
||||
});
|
||||
|
||||
describe("main menu", () => {
|
||||
it("offers Python's eight items, in Python's order", async () => {
|
||||
const ui = await run();
|
||||
for (const [n, label] of [
|
||||
["1", "Add Entry"],
|
||||
["2", "Retrieve Entry"],
|
||||
["3", "Search Entries"],
|
||||
["4", "List Entries"],
|
||||
["5", "Modify an Existing Entry"],
|
||||
["6", "2FA Codes"],
|
||||
["7", "Settings"],
|
||||
["8", "List Archived"],
|
||||
] as const) {
|
||||
expect(ui.text).toContain(`${n}.`);
|
||||
expect(ui.text).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("exits on a blank choice", async () => {
|
||||
const ui = new ScriptedUi([""]);
|
||||
expect(await runTui({ appDir }, ui)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an unknown choice without leaving the menu", async () => {
|
||||
const ui = await run("99", "");
|
||||
expect(ui.text).toContain("Invalid choice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
it("lists all eighteen items in Python's order", async () => {
|
||||
const ui = await run("7");
|
||||
const expected = [
|
||||
"Profiles",
|
||||
"Nostr",
|
||||
"Change password",
|
||||
"Verify Script Checksum",
|
||||
"Generate Script Checksum",
|
||||
"Backup Parent Seed",
|
||||
"Export database",
|
||||
"Import database",
|
||||
"Export 2FA codes",
|
||||
"Set additional backup location",
|
||||
"KDF strength & benchmark",
|
||||
"Set inactivity timeout",
|
||||
"Lock Vault",
|
||||
"Stats",
|
||||
"Toggle Secret Mode",
|
||||
"Toggle Offline Mode",
|
||||
"Toggle Quick Unlock",
|
||||
"Semantic Index",
|
||||
];
|
||||
for (const label of expected) expect(ui.text).toContain(label);
|
||||
// Numbering must line up with Python's, or muscle memory picks the wrong
|
||||
// item — 13 is Lock Vault there and must be here too.
|
||||
expect(ui.text).toMatch(/13\.\s+Lock Vault/);
|
||||
expect(ui.text).toMatch(/18\.\s+Semantic Index/);
|
||||
});
|
||||
|
||||
it("opens the Profiles submenu with its five items", async () => {
|
||||
const ui = await run("7", "1");
|
||||
for (const label of [
|
||||
"Switch Seed Profile",
|
||||
"Add a New Seed Profile",
|
||||
"Remove an Existing Seed Profile",
|
||||
"List All Seed Profiles",
|
||||
"Set Seed Profile Name",
|
||||
]) {
|
||||
expect(ui.text).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the Nostr submenu with its nine items", async () => {
|
||||
const ui = await run("7", "2");
|
||||
for (const label of [
|
||||
"Backup to Nostr",
|
||||
"Restore from Nostr",
|
||||
"View current relays",
|
||||
"Add a relay URL",
|
||||
"Remove a relay by number",
|
||||
"Reset to default relays",
|
||||
"Display Nostr Public Key",
|
||||
"Reset Nostr sync state",
|
||||
"Start fresh Nostr namespace",
|
||||
]) {
|
||||
expect(ui.text).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles Secret Mode and persists it", async () => {
|
||||
const ui = await run("7", "15", "", "", "");
|
||||
expect(ui.text).toContain("Secret Mode is now ON");
|
||||
const { loadConfig } = await import("../src/configFile.js");
|
||||
const cfg = await loadConfig(new AppDir(appDir).profileDir(FINGERPRINT), MNEMONIC);
|
||||
expect(cfg["secret_mode_enabled"]).toBe(true);
|
||||
});
|
||||
|
||||
it("reports stats without printing any stored secret", async () => {
|
||||
const ui = await run("7", "14", "");
|
||||
expect(ui.text).toContain("Total entries: 4");
|
||||
expect(ui.text).toContain("password: 2");
|
||||
expect(ui.text).toContain("key_value: 1");
|
||||
expect(ui.text).not.toContain(STORED_SECRET);
|
||||
});
|
||||
|
||||
it("adds and removes a relay", async () => {
|
||||
const added = await run("7", "2", "4", "wss://relay.example", "", "", "", "");
|
||||
expect(added.text).toContain("Relay added");
|
||||
const { loadConfig } = await import("../src/configFile.js");
|
||||
const cfg = await loadConfig(new AppDir(appDir).profileDir(FINGERPRINT), MNEMONIC);
|
||||
expect(cfg["relays"]).toContain("wss://relay.example");
|
||||
});
|
||||
|
||||
it("refuses a relay URL that is not a websocket URL", async () => {
|
||||
const ui = await run("7", "2", "4", "https://not-a-relay.example", "", "", "", "");
|
||||
expect(ui.text).toContain("must start with ws:// or wss://");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listing and details", () => {
|
||||
it("lists entries by index, type and label", async () => {
|
||||
const ui = await run("4", "1", "");
|
||||
expect(ui.text).toContain("0.");
|
||||
expect(ui.text).toContain("Password - github.com");
|
||||
expect(ui.text).toContain("Key Value - ci-token");
|
||||
// The stored secret must not be in a listing.
|
||||
expect(ui.text).not.toContain(STORED_SECRET);
|
||||
});
|
||||
|
||||
it("filters the list by entry type", async () => {
|
||||
// 4 = List Entries, 8 = Key Value (1 is All, then the nine types in order).
|
||||
const ui = await run("4", "8", "");
|
||||
expect(ui.text).toContain("ci-token");
|
||||
expect(ui.text).not.toContain("github.com");
|
||||
});
|
||||
|
||||
it("shows entry details with stored secrets reduced to flags", async () => {
|
||||
const ui = await run("4", "1", "2", "");
|
||||
expect(ui.text).toContain("ci-token");
|
||||
expect(ui.text).toContain("has_value");
|
||||
expect(ui.text).not.toContain(STORED_SECRET);
|
||||
});
|
||||
|
||||
it("offers the entry actions Python offers", async () => {
|
||||
const ui = await run("4", "1", "0", "");
|
||||
for (const label of [
|
||||
"Archive",
|
||||
"Add Note",
|
||||
"Add Custom Field",
|
||||
"Add Hidden Field",
|
||||
"Edit",
|
||||
"Edit Tags",
|
||||
]) {
|
||||
expect(ui.text).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a stored secret only when asked", async () => {
|
||||
const before = await run("4", "1", "2", "");
|
||||
expect(before.text).not.toContain(STORED_SECRET);
|
||||
const after = await run("4", "1", "2", "s", "", "");
|
||||
expect(after.text).toContain(STORED_SECRET);
|
||||
});
|
||||
|
||||
it("offers document export only for documents", async () => {
|
||||
const ui = await run("4", "1", "0", "");
|
||||
expect(ui.text).not.toContain("Export Document to File");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search and retrieve", () => {
|
||||
it("finds entries by label", async () => {
|
||||
const ui = await run("3", "git", "");
|
||||
expect(ui.text).toContain("github.com");
|
||||
expect(ui.text).toContain("gitlab.com");
|
||||
expect(ui.text).not.toContain("ci-token");
|
||||
});
|
||||
|
||||
it("says so when nothing matches", async () => {
|
||||
const ui = await run("3", "nothing-matches-this", "");
|
||||
expect(ui.text).toContain("No matching entries found");
|
||||
});
|
||||
|
||||
it("retrieves by index and by label", async () => {
|
||||
expect((await run("2", "0", "")).text).toContain("github.com");
|
||||
expect((await run("2", "ci-token", "")).text).toContain("CI_TOKEN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutations", () => {
|
||||
async function reopen(): Promise<VaultIndex> {
|
||||
const vault = await openVault(
|
||||
join(new AppDir(appDir).profileDir(FINGERPRINT), INDEX_FILENAME),
|
||||
MNEMONIC,
|
||||
);
|
||||
return vault.index;
|
||||
}
|
||||
|
||||
it("adds a password entry through the menus", async () => {
|
||||
// 1 Add Entry, 1 Password, then label/length/username/url/notes/tags.
|
||||
await run("1", "1", "new-site.example", "24", "bob", "", "", "work,prod", "", "");
|
||||
const index = await reopen();
|
||||
const added = Object.values(index.entries).find((e) => e.label === "new-site.example");
|
||||
expect(added).toBeTruthy();
|
||||
expect(added!["length"]).toBe(24);
|
||||
expect(added!["username"]).toBe("bob");
|
||||
expect(added!["tags"]).toEqual(["work", "prod"]);
|
||||
});
|
||||
|
||||
it("adds a key/value entry without echoing the value", async () => {
|
||||
const ui = await run("1", "7", "deploy", "DEPLOY_TOKEN", "sup3r-s3cret", "", "");
|
||||
expect(ui.text).not.toContain("sup3r-s3cret");
|
||||
const index = await reopen();
|
||||
const added = Object.values(index.entries).find((e) => e.label === "deploy");
|
||||
expect(added!["value"]).toBe("sup3r-s3cret");
|
||||
});
|
||||
|
||||
it("refuses a non-numeric password length rather than storing NaN", async () => {
|
||||
const ui = await run("1", "1", "bad", "not-a-number", "", "");
|
||||
expect(ui.text).toContain("whole number");
|
||||
const index = await reopen();
|
||||
expect(Object.values(index.entries).find((e) => e.label === "bad")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("archives an entry and lists it under List Archived", async () => {
|
||||
await run("4", "1", "0", "a", "", "", "", "");
|
||||
const index = await reopen();
|
||||
expect(index.entries["0"]!["archived"]).toBe(true);
|
||||
const archived = await run("8", "");
|
||||
expect(archived.text).toContain("github.com");
|
||||
});
|
||||
|
||||
it("edits a label through Edit", async () => {
|
||||
await run("4", "1", "0", "e", "l", "renamed.example", "", "", "", "");
|
||||
const index = await reopen();
|
||||
expect(index.entries["0"]!.label).toBe("renamed.example");
|
||||
});
|
||||
|
||||
it("adds a note", async () => {
|
||||
await run("4", "1", "0", "n", "a useful note", "", "", "", "");
|
||||
const index = await reopen();
|
||||
expect(index.entries["0"]!["notes"]).toBe("a useful note");
|
||||
});
|
||||
|
||||
it("adds a hidden custom field without echoing its value", async () => {
|
||||
const ui = await run("4", "1", "0", "h", "recovery", "hidden-field-value", "", "", "", "");
|
||||
expect(ui.text).not.toContain("hidden-field-value");
|
||||
const index = await reopen();
|
||||
const fields = index.entries["0"]!["custom_fields"] as Record<string, unknown>[];
|
||||
expect(fields).toEqual([
|
||||
{ label: "recovery", value: "hidden-field-value", is_hidden: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports the database to a file", async () => {
|
||||
const dest = join(appDir, "backup.json");
|
||||
const ui = await run("7", "7", dest, "", "", "");
|
||||
expect(ui.text).toContain("Exported to");
|
||||
const wrapper = JSON.parse(await readFile(dest, "utf8"));
|
||||
expect(wrapper.format_version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("2FA codes", () => {
|
||||
it("shows a code for each TOTP entry", async () => {
|
||||
const ui = await run("6", "", "");
|
||||
expect(ui.text).toContain("email-2fa");
|
||||
expect(ui.text).toMatch(/\d{6}/);
|
||||
expect(ui.text).toContain("s left");
|
||||
});
|
||||
});
|
||||
@@ -1,331 +0,0 @@
|
||||
/**
|
||||
* The interactive screens, driven through a fake terminal.
|
||||
*
|
||||
* The load-bearing assertions are the same agent-blind properties the CLI is
|
||||
* held to (plan section 9.3), because the TUI is now the default entry point
|
||||
* and renders the whole vault by itself:
|
||||
*
|
||||
* - no screen shows a secret until `r` is pressed
|
||||
* - `c` copies without ever drawing the value
|
||||
* - dismissing a reveal removes the value from the next frame
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import { mnemonics } from "@seedpass/test-vectors";
|
||||
import {
|
||||
generateFingerprint,
|
||||
deriveIndexKeyBytes,
|
||||
encryptV3,
|
||||
utf8,
|
||||
addPasswordEntry,
|
||||
addKeyValueEntry,
|
||||
type VaultIndex,
|
||||
} from "@seedpass/core";
|
||||
import { runTui, type TerminalLike } from "../src/tui/app.js";
|
||||
import { decodeKeys, visibleLength, type Key } from "../src/tui/terminal.js";
|
||||
import { AppDir, INDEX_FILENAME } from "../src/appDir.js";
|
||||
|
||||
const MNEMONIC = mnemonics["abandon12"]!;
|
||||
const FINGERPRINT = generateFingerprint(MNEMONIC);
|
||||
const SECRET_VALUE = "tui-stored-secret-value";
|
||||
|
||||
let appDir: string;
|
||||
|
||||
/** A terminal that records frames and lets a test press keys. */
|
||||
class FakeTerminal implements TerminalLike {
|
||||
readonly columns = 100;
|
||||
readonly rows = 30;
|
||||
frames: string[][] = [];
|
||||
private handler: ((key: Key) => void) | null = null;
|
||||
restored = false;
|
||||
|
||||
start(): void {}
|
||||
restore(): void {
|
||||
this.restored = true;
|
||||
}
|
||||
onKey(handler: (key: Key) => void): void {
|
||||
this.handler = handler;
|
||||
}
|
||||
onResize(): void {}
|
||||
draw(lines: string[]): void {
|
||||
this.frames.push(lines);
|
||||
}
|
||||
|
||||
/** Feed keystrokes the way the real decoder would produce them. */
|
||||
async press(input: string): Promise<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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user